OAuth Authorization Server
Willow exposes a full OAuth 2.0 / OpenID Connect authorization server that MCP clients use to authenticate users. This page documents every endpoint, the discovery metadata, and the end-to-end flow.
All OAuth endpoints live on the Connect host for your organization:
https://<org>.connect.your-domain.com
Discovery
Before calling any endpoint, MCP clients can discover the server configuration through standard metadata documents.
GET /.well-known/oauth-authorization-server
Returns RFC 8414 OAuth Authorization Server Metadata, which is used by MCP clients implementing the MCP OAuth spec.
curl https://<org>.connect.your-domain.com/.well-known/oauth-authorization-server
{
"issuer": "https://<org>.connect.your-domain.com",
"authorization_endpoint": "https://<org>.connect.your-domain.com/authorize",
"token_endpoint": "https://<org>.connect.your-domain.com/token",
"registration_endpoint": "https://<org>.connect.your-domain.com/register",
"revocation_endpoint": "https://<org>.connect.your-domain.com/revoke",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["openid", "email", "profile"]
}
GET /.well-known/openid-configuration
Returns OpenID Connect Discovery metadata. Identical to the OAuth metadata above, with the addition of the userinfo_endpoint.
curl https://<org>.connect.your-domain.com/.well-known/openid-configuration
{
"issuer": "https://<org>.connect.your-domain.com",
"authorization_endpoint": "https://<org>.connect.your-domain.com/authorize",
"token_endpoint": "https://<org>.connect.your-domain.com/token",
"userinfo_endpoint": "https://<org>.connect.your-domain.com/userinfo",
"registration_endpoint": "https://<org>.connect.your-domain.com/register",
"revocation_endpoint": "https://<org>.connect.your-domain.com/revoke",
"scopes_supported": ["openid", "email", "profile"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"code_challenge_methods_supported": ["S256"],
"subject_types_supported": ["public"]
}
GET /.well-known/oauth-protected-resource
Returns RFC 9728 Protected Resource Metadata. MCP clients use this to identify which authorization server protects a given resource.
curl https://<org>.connect.your-domain.com/.well-known/oauth-protected-resource
{
"resource": "https://<org>.connect.your-domain.com",
"authorization_servers": ["https://<org>.connect.your-domain.com"],
"scopes_supported": ["openid", "email", "profile"],
"resource_name": "MCP-S",
"resource_documentation": "https://<org>.connect.your-domain.com"
}
Endpoints
1. Dynamic Client Registration
POST /register
MCP clients register themselves before starting the authorization flow. Willow implements RFC 7591 Dynamic Client Registration.
Request Body
{
"client_name": "My MCP Client",
"redirect_uris": ["http://localhost:3000/callback"]
}
| Field | Type | Required | Description |
|---|---|---|---|
client_name | string | No | Human-readable name for the client |
redirect_uris | string[] | Yes | Allowed redirect URIs after authorization |
client_id | string | No | Custom client ID (auto-generated if omitted) |
client_secret | string | No | Custom client secret (auto-generated if omitted) |
Response — 200 OK
{
"client_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"client_secret": "s3cr3t-v4lu3-h3r3",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code"],
"client_name": "My MCP Client"
}
2. Authorization
GET /authorize
Starts the authorization code flow. The MCP client redirects the user's browser to this endpoint.
Query Parameters
| Parameter | Required | Description |
|---|---|---|
client_id | Yes | The registered client ID |
redirect_uri | Yes | Must match one of the client's registered redirect URIs |
state | No | Opaque value for CSRF protection, returned unchanged in the callback |
code_challenge | No | PKCE code challenge (recommended, S256 method) |
nonce | No | Value included in the id_token for replay protection |
Flow
- Willow validates the
client_idandredirect_uriagainst the registered client. - The user is redirected to the organization's sign-in page (SSO, default auth, etc.).
- If the client is not a recognized MCP client in the organization, a consent screen is shown.
- After successful authentication, the user is redirected back to
redirect_uriwith acodeandstate:
http://localhost:3000/callback?code=abc123...&state=xyz
Errors
If redirect_uri is missing or doesn't match, the user is shown an error page instead of being redirected.
3. Token Exchange
POST /token
Exchanges an authorization code for tokens, or refreshes an expired access token. The request body must be sent as application/x-www-form-urlencoded.
Grant Type: authorization_code
| Field | Required | Description |
|---|---|---|
grant_type | Yes | Must be authorization_code |
client_id | Yes | The registered client ID (can also be sent via Basic auth header) |
code | Yes | The authorization code from the /authorize callback |
redirect_uri | Yes | Must match the URI used in the authorization request |
code_verifier | Conditional | Required if code_challenge was used during authorization (PKCE) |
curl -X POST https://<org>.connect.your-domain.com/token \
-d "grant_type=authorization_code" \
-d "client_id=a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-d "code=abc123..." \
-d "redirect_uri=http://localhost:3000/callback" \
-d "code_verifier=your-pkce-verifier"
Grant Type: refresh_token
| Field | Required | Description |
|---|---|---|
grant_type | Yes | Must be refresh_token |
client_id | Yes | The registered client ID |
refresh_token | Yes | The refresh token from a previous token response |
curl -X POST https://<org>.connect.your-domain.com/token \
-d "grant_type=refresh_token" \
-d "client_id=a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-d "refresh_token=def456..."
Response — 200 OK
{
"access_token": "a1b2c3d4e5f6...",
"token_type": "Bearer",
"access_token_expired_at": 1719878400000,
"scope": "openid email profile",
"refresh_token": "f6e5d4c3b2a1...",
"expires_in": 86400000,
"id_token": "eyJhbGciOiJIUzI1NiIs..."
}
| Field | Description |
|---|---|
access_token | Bearer token for authenticating API and MCP requests |
token_type | Always Bearer |
access_token_expired_at | Unix timestamp (ms) when the token expires |
scope | Granted scopes |
refresh_token | Use to obtain a new access token when the current one expires |
expires_in | Token lifetime in milliseconds |
id_token | JWT containing the nonce claim (only present if nonce was provided during authorization) |
The default token lifetime is 24 hours. Organizations can configure a custom expiration in Admin Settings → Security.
Errors
| Status | Error | Description |
|---|---|---|
| 400 | invalid_request | Missing required parameters |
| 400 | invalid_grant | Invalid authorization code or refresh token |
| 400 | invalid_client | Unknown client ID |
4. UserInfo
GET /userinfo
POST /userinfo
Returns identity claims about the authenticated user, following the OpenID Connect UserInfo specification.
Authentication
Include the access token from the /token endpoint as a Bearer token:
curl -H "Authorization: Bearer <access_token>" \
https://<org>.connect.your-domain.com/userinfo
Response — 200 OK
{
"sub": "user-id-123",
"name": "John Doe"
}
Claims by Scope
| Scope | Claims |
|---|---|
openid | sub (user ID) |
email | email |
profile | name |
Errors — 401 Unauthorized
{
"error": "invalid_token",
"error_description": "Access token has expired"
}
| Scenario | Error Description |
|---|---|
No Authorization header | Missing access token |
| Token not found | Token not found |
| Token expired | Access token has expired |
| User not found | User not found |
5. Token Revocation
POST /revoke
Revokes an access token, following RFC 7009. The request body must be sent as application/x-www-form-urlencoded.
curl -X POST https://<org>.connect.your-domain.com/revoke \
-d "token=a1b2c3d4e5f6..."
| Field | Required | Description |
|---|---|---|
token | Yes | The access token to revoke |
token_type_hint | No | Hint about the token type (e.g. access_token) |
Response
200 OK— Token revoked successfully (also returned if the token was already invalid, per RFC 7009).400 Bad Request— Missingtokenparameter.
Full Authorization Flow
Here's the complete flow an MCP client follows to authenticate a user:
- Register — The client registers itself via
POST /registerto get aclient_id. - Authorize — The client redirects the user to
/authorizewith PKCE. The user signs in through their organization's auth provider. - Token — The client exchanges the authorization code for an access token via
POST /token. - UserInfo — The client calls
/userinfoto identify the user. - Refresh — When the access token expires, the client uses the refresh token to get a new one.
- Revoke — When the user signs out, the client revokes the token via
POST /revoke.
Supported Scopes
| Scope | Description |
|---|---|
openid | Required for OpenID Connect. Returns the sub claim. |
email | Returns the user's email address. |
profile | Returns the user's display name. |
Security Considerations
- PKCE is recommended — Use
code_challenge(S256) during authorization andcode_verifierduring token exchange to prevent authorization code interception. - Localhost redirect URIs — Following RFC 8252 §7.3, loopback redirects match on scheme, host, and path only — any port is allowed.
- Known-client enforcement — Organizations can enable "Allow only authenticated clients" in security settings. When enabled, only MCP clients explicitly configured in the organization are allowed; unrecognized clients are rejected (localhost is always exempt).
- Token revocation is idempotent — Revoking an already-revoked or unknown token returns
200 OKto prevent token scanning.