Access to AO Platform Ontology from 3rd Party Products (Preview)

MCP Server - Security Services

Intended audience: Developers administrators

AO Platform: 5.0


Overview

The ao-mcp-security-service is a Spring Boot 3.4 / Java 21 OAuth 2.0 proxy service. It sits between MCP clients (Claude Desktop, ao-mcp-bridge, or Claude's cloud connector) and an upstream Identity Provider (Okta, Azure Entra ID, PingOne, etc.).

Core function: Act as an OAuth authorization server that forwards authentication to the real IdP, then returns the IdP-issued tokens unchanged. The proxy never mints its own JWTs — AO Platform always validates tokens against the IdP's own issuer and JWKS endpoint.

Why it exists:

  • MCP clients need an OAuth authorization server they can discover via standard OIDC metadata.

  • The upstream IdP cannot be reconfigured to point directly at every MCP client's redirect URI.

  • The proxy bridges this gap while keeping security anchored at the IdP.

Runs on: port 9000 by default.


Architecture

What This Service Does

Capability

Detail

OAuth 2.0 discovery

Serves /.well-known/oauth-authorization-server and /.well-known/openid-configuration

Authorization Code + PKCE

Implements dual-leg PKCE: one verifier for the desktop client, a separate internal one for the IdP

Dynamic Client Registration

POST /oauth/register — loopback URIs only (for ao-mcp-bridge)

Admin Client Registration

POST /admin/clients — any redirect URI, protected by X-Admin-Key (for Claude connector)

Token passthrough

Returns the IdP's token JSON verbatim to the MCP client

Refresh token forwarding

Proxies grant_type=refresh_token to the IdP and passes the response back

Client persistence

Stores registered clients in PostgreSQL with BCrypt-hashed secrets

Component Diagram

image-20260622-221112.png


OAuth Flows

Flow A — Desktop PKCE (ao-mcp-bridge / Claude Desktop)

image-20260622-221154.png


Flow B — Claude Connector (Cloud-to-Cloud, Confidential Client)

At the token endpoint, Claude sends client_secret_basic credentials plus the PKCE verifier; the proxy follows the same internal IdP exchange as Flow A.

image-20260622-221439.png


Flow C — Refresh Token

image-20260622-221519.png



API Reference

All endpoints are HTTP (no authentication required by default — access control is at the network or admin-key level).


OAuth Discovery

GET /.well-known/oauth-authorization-server

GET /.well-known/openid-configuration

Both paths return identical JSON.

Response 200 OK:

JSON
{
  "issuer": "https://your-auth-host.example.com",
  "authorization_endpoint": "https://your-auth-host.example.com/oauth/authorize",
  "token_endpoint": "https://your-auth-host.example.com/oauth/token",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": [
    "client_secret_post",
    "client_secret_basic",
    "none"
  ],
  "scopes_supported": ["openid", "profile", "email", "offline_access"],
  "registration_endpoint": "https://your-auth-host.example.com/oauth/register"
}

Note: registration_endpoint is only present when ADVERTISE_REGISTRATION_ENDPOINT=true. Set it to false for the Claude connector deployment.


Authorization Endpoints

GET /oauth/authorize

Initiates an Authorization Code + PKCE flow. Redirects the user's browser to the upstream IdP.

Query parameters:

Parameter

Required

Description

response_type

Yes

Must be code

client_id

Yes

Client ID from DCR or admin registration

redirect_uri

Yes

Must exactly match the registered URI

code_challenge

Yes

Base64url-encoded S256 hash of the PKCE verifier

code_challenge_method

Yes

Must be S256

state

Yes

Opaque value returned on callback

scope

No

Space-separated; merged with default scopes

Response: 302 Found → upstream IdP's authorization URL (user sees the IdP login page).


GET /oauth/idp-callback

Internal callback. The upstream IdP redirects here after the user authenticates. Never call this endpoint directly.

Query parameters (from IdP):

Parameter

Description

code

IdP authorization code

state

Internal state set by the proxy during authorization

error

OAuth error code (if authorization failed)

error_description

Human-readable error (optional)

Response: 302 Found → client's registered redirect_uri with:

  • code = opaque proxy code (UUID, valid 300 s)

  • state = original client state


Token Endpoint

POST /oauth/token

Content-Type: application/x-www-form-urlencoded

Authorization Code Exchange:

Parameter

Required

Description

grant_type

Yes

authorization_code

code

Yes

Proxy code received in the callback

redirect_uri

Yes

Must match what was used in /oauth/authorize

client_id

Yes

Registered client ID

client_secret

Yes*

Required unless token_endpoint_auth_method=none

code_verifier

Yes

PKCE verifier whose S256 hash matches the original challenge

*Can be sent as HTTP Basic Auth header instead when token_endpoint_auth_method=client_secret_basic.

Refresh Token:

Parameter

Required

Description

grant_type

Yes

refresh_token

refresh_token

Yes

Refresh token received from a prior exchange

client_id

Yes

Registered client ID

client_secret

Yes*

Required unless token_endpoint_auth_method=none

Successful response 200 OK (passthrough of IdP token JSON):

JSON
{
  "access_token": "<IdP-issued JWT>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "id_token": "<IdP-issued OIDC JWT>",
  "refresh_token": "<IdP-issued refresh token>",
  "scope": "openid profile email offline_access"
}

Error response 400 Bad Request:

JSON
{
  "error": "invalid_client",
  "error_description": "Unknown client_id"
}


Dynamic Client Registration (DCR)

POST /oauth/register

Registers a new OAuth client. Only loopback redirect URIs are accepted (http://127.0.0.1:* or http://localhost:*) unless ALLOW_LOOPBACK_REDIRECTS_ONLY=false.

Used by ao-mcp-bridge automatically. Do not use for Claude connector (use admin endpoint instead).

Requires ADVERTISE_REGISTRATION_ENDPOINT=true for DCR to be discoverable; the endpoint still functions regardless.

Request body (application/json):

JSON
{
  "redirect_uris": ["http://127.0.0.1:8400/callback"],
  "token_endpoint_auth_method": "client_secret_post"
}

Field

Required

Description

redirect_uris

Yes

Array of redirect URIs; must be loopback in production

token_endpoint_auth_method

No

client_secret_post (default), client_secret_basic, or none

Response 201 Created:

JSON
{
  "client_id": "proxy-<uuid>",
  "client_secret": "<plaintext secret — shown once only>",
  "client_id_issued_at": 1749984000,
  "client_secret_expires_at": 0,
  "redirect_uris": ["http://127.0.0.1:8400/callback"],
  "token_endpoint_auth_method": "client_secret_post"
}

Important: The client_secret is shown only on creation. It is stored as a BCrypt hash and cannot be retrieved later.


Admin Client Management

All admin endpoints require the X-Admin-Key header matching the ADMIN_KEY environment variable.

POST /admin/clients

Registers a confidential client. Accepts any redirect URI (not limited to loopback). Used to create the Claude connector client.

Request headers:

X-Admin-Key: <value of ADMIN_KEY env var>
Content-Type: application/json

Request body:

JSON
{
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "token_endpoint_auth_method": "client_secret_basic"
}

Response 201 Created:

JSON
{
  "client_id": "proxy-<uuid>",
  "client_secret": "<plaintext secret — shown once only>",
  "client_id_issued_at": 1749984000,
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "token_endpoint_auth_method": "client_secret_basic"
}


GET /admin/clients/{clientId}

Returns client metadata. Secret is never returned.

Response 200 OK:

JSON
{
  "client_id": "proxy-a1b2c3d4-...",
  "client_id_issued_at": 1749984000,
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "token_endpoint_auth_method": "client_secret_basic"
}


GET /admin/clients

Lists all registered clients. Secrets are never returned.

Response 200 OK: Array of client objects (same shape as single GET above).


Configuration Reference

Environment Variables

Variable

Required

Default

Description

AUTH_ISSUER

Yes

http://localhost:9000

Public HTTPS URL of this service. Appears as issuer in discovery metadata. No trailing slash.

OIDC_ISSUER_URI (alias OKTA_ISSUER_URI)

Yes

—

Upstream IdP issuer URI. Found in Okta Admin Console → Security → API → Authorization Servers → [server] → Issuer.

OIDC_CLIENT_ID (alias OKTA_CLIENT_ID)

Yes

—

Client ID of the server-side IdP app registered at the IdP. Never expose to end users.

OIDC_CLIENT_SECRET (alias OKTA_CLIENT_SECRET)

Yes

—

Client secret of the IdP app. Treat as a password. Store in a vault.

ADMIN_KEY

Yes (for admin API)

(empty)

Shared secret for POST /admin/clients. Generate with openssl rand -hex 32. When blank, admin endpoint is disabled.

DB_URL

Yes

jdbc:postgresql://localhost:5432/ao_mcp_security

JDBC URL for the PostgreSQL database.

DB_USERNAME

Yes

postgres

PostgreSQL username with read/write on ao_mcp_security.

DB_PASSWORD

Yes

postgres

Password for DB_USERNAME. Never hard-code; use a secrets manager.

ADVERTISE_REGISTRATION_ENDPOINT

No

false

false for Claude connector. true only for ao-mcp-bridge deployments.

ALLOW_LOOPBACK_REDIRECTS_ONLY

No

true

When true, public DCR only accepts http://127.0.0.1 or http://localhost redirect URIs.

DB_POOL_MAX_SIZE

No

15

HikariCP maximum pool size.

DB_POOL_MIN_IDLE

No

5

HikariCP minimum idle connections.

DB_POOL_CONNECTION_TIMEOUT_MS

No

10000

Max wait for a connection from the pool (ms).

DB_POOL_VALIDATION_TIMEOUT_MS

No

5000

Max time for connection validation (ms).

DB_POOL_IDLE_TIMEOUT_MS

No

600000

Time before idle connections are removed (ms).

DB_POOL_MAX_LIFETIME_MS

No

1800000

Maximum connection lifetime (ms).

DB_POOL_KEEPALIVE_MS

No

300000

Keepalive interval (ms).

DB_POOL_LEAK_DETECTION_MS

No

60000

Alert threshold for connection leak detection (ms). 0 = disabled.

LOG_SENSITIVE_INFO

No

false

When true, logs headers (Authorization, X-Admin-Key) and form/JSON bodies for /oauth/token, /oauth/register, /admin/*. Never enable in production.

SPRING_PROFILES_ACTIVE

No

(none)

Add oauth-proxy to explicitly activate the proxy profile (already enabled by default).


Application.yml Defaults

server:
  port: 9000

app:
  oauth-proxy:
    enabled: true
    pending-ttl-seconds: 300        # Proxy code lifetime (seconds)
    idp-callback-path: /oauth/idp-callback
    upstream-token-auth-method: client_secret_post  # or client_secret_basic
    default-scopes:
      - openid
      - profile
      - email
      - offline_access

  cors:
    allowed-origins: ["*"]
    allowed-methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
    allowed-headers: ["*"]
    max-age: 3600
    allow-credentials: false

management:
  endpoints.web.exposure.include: health,info
  tracing.sampling.probability: 1.0


Database Connection Pool Tuning

For production deployments, tune HikariCP to your expected concurrency. Key relationships:

  • DB_POOL_MAX_SIZE should be ≤ PostgreSQL's max_connections minus connections from other services.

  • DB_POOL_KEEPALIVE_MS should be less than PostgreSQL's idle_in_transaction_session_timeout.

  • Set DB_POOL_LEAK_DETECTION_MS to around 2–3× the expected longest query time.


Database Schema

Schema is managed by Liquibase and applied automatically on startup. Migration files are in src/main/resources/db/changelog/.


Tables

registered_client

Stores OAuth client registrations.

Column

Type

Constraints

Description

client_id

VARCHAR(80)

PRIMARY KEY

Generated UUID prefixed with proxy-

client_secret_hash

VARCHAR(200)

NOT NULL

BCrypt(10) hash of the client secret

token_endpoint_auth_method

VARCHAR(40)

NOT NULL

client_secret_post, client_secret_basic, or none

created_at

TIMESTAMPTZ

NOT NULL, DEFAULT now()

Registration timestamp

Index: idx_registered_client_created_at on created_at.

registered_client_redirect_uri

One-to-many child table for redirect URIs.

Column

Type

Constraints

Description

client_id

VARCHAR(80)

FK → registered_client(client_id) ON DELETE CASCADE

Parent client

redirect_uri

VARCHAR(500)

NOT NULL

Registered callback URI

Primary key: (client_id, redirect_uri).


Entity Lifecycle

  1. Registration: JpaClientRegistrationStore.register() hashes the plaintext secret with BCrypt(10) and persists both tables in a transaction. Returns the plaintext secret once (to the registration response); it is not stored.

  2. Lookup: findById() eagerly loads redirect URIs. The returned RegisteredClient has clientSecret = null — secret never leaves the store after initial registration.

  3. Verification: verifySecret() calls BCryptPasswordEncoder.matches(provided, storedHash). The hash is never returned to callers.

  4. Cascade delete: Removing a registered_client row also deletes all associated redirect_uri rows.


Security

Authentication & Authorization

Endpoint

Protection

/.well-known/*

Public (no auth)

GET /oauth/authorize

Validates registered client_id and redirect_uri

GET /oauth/idp-callback

Protected by internal state nonce (SSRF-safe)

POST /oauth/token

Validates client credentials (BCrypt) + PKCE verifier

POST /oauth/register

Public; loopback URI restriction enforced

POST /admin/clients

X-Admin-Key header must match ADMIN_KEY env var

GET /admin/clients/*

X-Admin-Key header must match ADMIN_KEY env var

GET /actuator/health

Public

GET /actuator/info

Public; details require authorization


PKCE (Proof Key for Code Exchange)

The service implements dual-leg PKCE:

  • Desktop leg: Desktop generates its own code_verifier and sends S256(code_verifier) as code_challenge. The proxy stores this challenge and validates it at token exchange.

  • Internal IdP leg: The proxy independently generates a separate code_verifier / code_challenge pair for its own call to the upstream IdP. The IdP validates this; the desktop never sees these values.

This ensures neither leg can be replayed independently.


Client Secret Storage

  • Secrets are hashed with BCrypt(strength=10) immediately on receipt.

  • Plaintext secrets are never persisted.

  • Plaintext is returned only once in the registration response.

  • GET admin endpoints never include the secret field.


Sensitive Logging

When LOG_SENSITIVE_INFO=true, the OAuthProxySensitiveLoggingFilter logs:

  • Authorization and X-Admin-Key request headers

  • Full form body of POST /oauth/token and POST /oauth/register

  • Full JSON body of POST /admin/*

This will expose tokens and secrets in logs. Only enable temporarily for debugging in a non-production environment.


CORS

Default configuration allows all origins (*). Restrict allowed-origins via app.cors.allowed-origins in production if required.


Network Security

  • Anthropic's cloud egress for Claude connectors: 160.79.104.0/21. Allow this range through any firewall protecting this service and ao-mcp-server.


Deployment

Docker

The Dockerfile uses a multi-stage build:

  1. Build stage (Amazon Corretto 21 + Gradle): Extracts Spring Boot layers for optimal Docker cache reuse.

  2. Runtime stage (Amazon Linux 2023 + Corretto 21 JRE): Runs as non-root user app:app. Exposes port 9000.

Build the image:

docker build -t ao-mcp-security-service:latest .

Run the container:

docker run -p 9000:9000 \
  -e AUTH_ISSUER=https://your-auth-host.example.com \
  -e OIDC_ISSUER_URI=https://dev-12345.okta.com/oauth2/ausXXX \
  -e OIDC_CLIENT_ID=0oaXXX \
  -e OIDC_CLIENT_SECRET=<secret> \
  -e ADMIN_KEY=<32-hex-chars> \
  -e DB_URL=jdbc:postgresql://db-host:5432/ao_mcp_security \
  -e DB_USERNAME=ao_mcp_user \
  -e DB_PASSWORD=<password> \
  -e ADVERTISE_REGISTRATION_ENDPOINT=false \
  ao-mcp-security-service:latest

Pass JAVA_OPTS for JVM tuning:

-e JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC"


Environment Checklist

Before going live, verify:

  • AUTH_ISSUER is the exact public HTTPS URL — no trailing slash, matches what clients use

  • OIDC_ISSUER_URI points to the correct upstream authorization server

  • OIDC_CLIENT_ID and OIDC_CLIENT_SECRET are from a server-side (confidential) IdP app, not a public SPA app

  • The IdP app's allowed redirect URI includes {AUTH_ISSUER}/oauth/idp-callback

  • ADMIN_KEY is set to a cryptographically random value (openssl rand -hex 32) and stored in a vault

  • ADVERTISE_REGISTRATION_ENDPOINT=false for Claude connector deployments

  • LOG_SENSITIVE_INFO=false (or not set) in production

  • Database migrations run cleanly (check startup logs for Liquibase output)

  • GET /.well-known/oauth-authorization-server returns a valid JSON response with your AUTH_ISSUER

  • Anthropic IP range 160.79.104.0/21 is allowlisted (if Claude connector is used)


Local Development

Prerequisites

  • Java 21

  • Docker (for PostgreSQL)

  • Gradle 8.11.1 (wrapper included)


Start the Service

# Starts PostgreSQL via Docker Compose automatically, then boots the app
./gradlew bootRun

The service starts at http://localhost:9000. PostgreSQL is auto-started on localhost:5432 (see compose.yaml).


Required Env Vars for Local Dev

At minimum, set:

export OIDC_ISSUER_URI=https://dev-12345.okta.com/oauth2/ausXXX
export OIDC_CLIENT_ID=0oaXXX
export OIDC_CLIENT_SECRET=<secret>

AUTH_ISSUER defaults to http://localhost:9000 and all DB vars have working defaults for local Docker Compose.


Useful Local Verification Commands

Bash
# Discovery metadata
curl -s http://localhost:9000/.well-known/oauth-authorization-server | python -m json.tool

# Register a test client (loopback)
curl -s -X POST http://localhost:9000/oauth/register \
  -H "Content-Type: application/json" \
  -d '{"redirect_uris":["http://127.0.0.1:8400/callback"]}' | python -m json.tool

# Create a Claude connector client (admin)
curl -s -X POST http://localhost:9000/admin/clients \
  -H "X-Admin-Key: $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"redirect_uris":["https://claude.ai/api/mcp/auth_callback"],"token_endpoint_auth_method":"client_secret_basic"}' \
  | python -m json.tool


Observability

Health Check

GET /actuator/health

Returns {"status":"UP"} when the service and database are healthy.


Tracing

  • OpenTelemetry tracing is enabled by default (management.tracing.sampling.probability: 1.0 — 100% sampling).

  • Each request carries a traceId and spanId in MDC, visible in logs.

  • Configure an OTLP exporter via standard Spring Boot / OTel environment variables (OTEL_EXPORTER_OTLP_ENDPOINT, etc.) for sending traces to a collector.


Logging

Log output is structured (JSON in production recommended). Key log events:

Event

Level

What To Look For

Startup

INFO

AUTH_ISSUER value confirmed; Liquibase migration results

IdP discovery

INFO

Upstream metadata fetched successfully

Authorization initiated

INFO

client_id, state, scopes

IdP callback received

INFO

state matched; proxy code issued

Token exchange

INFO

client_id, grant type

Invalid client / bad verifier

WARN

Validation failures

Upstream IdP error

WARN

Non-2xx from IdP token endpoint

Sensitive data (opt-in)

DEBUG

Only when LOG_SENSITIVE_INFO=true


Unit Testing

# Run all tests
./gradlew test

# Run with coverage report
./gradlew test jacocoTestReport
# Coverage report: build/reports/jacoco/test/html/index.html

# Run a single test class
./gradlew test --tests "com.apporchid.oauthproxy.OAuthProxyFlowIntegrationTest"


Test Infrastructure

Tool

Purpose

Testcontainers

Spins up a real PostgreSQL 16 container per test run

WireMock

Stubs the upstream IdP (port 29444 in tests)

MockMvc

HTTP-level integration tests against the Spring context

JUnit 5

Test framework

JaCoCo

Code coverage; minimum 50% line coverage enforced in CI


Connectivity Paths — Choosing the Right Mode


ao-mcp-bridge (Local)

Claude Connector (Cloud)

Who uses it

Individual developers on their own machine

Teams, enterprise, any http://Claude.ai user

MCP transport

stdio (local process)

HTTPS (cloud-to-cloud)

Client registration

DCR — automatic, loopback URIs

Admin API — manual, once per deployment

ADVERTISE_REGISTRATION_ENDPOINT

true

false

Redirect URI

http://127.0.0.1:<port>/callback

https://claude.ai/api/mcp/auth_callback

Do not set ADVERTISE_REGISTRATION_ENDPOINT=true in a deployment also used by the Claude connector. Use separate deployments (e.g., dev vs. prod) or configure it per environment.


Troubleshooting

Symptom

Most Likely Cause

Resolution

mcp_registration_failed when adding Claude connector

registration_endpoint still appears in AS discovery metadata

Set ADVERTISE_REGISTRATION_ENDPOINT=false and redeploy

unknown_client on token exchange

Wrong client_id or client_secret in Claude Advanced settings, or client not in DB

Re-run POST /admin/clients; update Claude connector credentials

401 loop after Okta login

Token audience (aud) mismatch — AO Platform doesn't accept tokens from this proxy's issuer

Verify AO Platform accepts tokens issued by AUTH_ISSUER with the correct aud claim

invalid_grant on token exchange

Proxy code expired (> 300 s), replay attempt, or PKCE verifier mismatch

Restart the authorization flow; check pending-ttl-seconds if codes expire too quickly

"Invalid admin key"

ADMIN_KEY env var not set or not matching the header value

Print $ADMIN_KEY in the deployment environment and compare; redeploy if needed

Tools available but every call returns 401

AO Platform rejects the access token (wrong aud, expired, wrong issuer)

Check PLATFORM_SERVER_BASE_URL in ao-mcp-server; confirm platform accepts tokens from OIDC_ISSUER_URI

Cannot connect to upstream IdP on startup

OIDC_ISSUER_URI is wrong or unreachable from the pod

curl {OIDC_ISSUER_URI}/.well-known/openid-configuration from inside the container

Liquibase migration fails on startup

DB not ready, wrong credentials, or schema out of sync

Check DB_URL, DB_USERNAME, DB_PASSWORD; inspect Liquibase logs for the failing changeset

High DB connection wait times

Pool exhausted; DB_POOL_MAX_SIZE too low for traffic

Increase DB_POOL_MAX_SIZE; check for connection leaks via DB_POOL_LEAK_DETECTION_MS

Cannot reach auth server from Claude Cloud

Anthropic egress IPs blocked by firewall

Allowlist 160.79.104.0/21 on both ao-mcp-server and ao-mcp-security-service

CORS errors in browser during DCR flow

allowed-origins too restrictive

Add the browser origin to app.cors.allowed-origins; for local dev * is the default






Contact App Orchid | Disclaimer