Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

OAuth 2.0 / OIDC provider

Rustybin includes a small but standards-shaped identity provider, so a gateway's OpenID Connect, OAuth introspection or JWT plugin, an MCP client and the A2A secure agent all have a real issuer to talk to without any external setup.

Endpoints

RouteStandard
GET /.well-known/openid-configurationOpenID Connect Discovery
GET /.well-known/oauth-authorization-serverRFC 8414 authorization server metadata
GET /oauth/jwksThe RS256 public key (JWK Set, kid rustybin-rs256-key)
GET, POST /oauth/authorizeAuthorization endpoint with a demo login page
POST /oauth/tokenToken endpoint
GET, POST /oauth/userinfoOIDC UserInfo
POST /oauth/introspectRFC 7662 token introspection
POST /oauth/revokeRFC 7009 token revocation
POST /oauth/registerRFC 7591 dynamic client registration

The issuer is derived from each request: http://<Host>, or https:// on the HTTPS listener. With RUSTYBIN_TRUST_FORWARD=true, Forwarded, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port and X-Forwarded-Prefix are honoured, so the issuer matches the public URL when Rustybin sits behind a gateway (route the gateway's /.well-known and /oauth paths to it and forward those headers).

GET {{base_url}}/.well-known/openid-configuration
HTTP 200
[Asserts]
jsonpath "$.issuer" == "{{base_url}}"
jsonpath "$.token_endpoint" == "{{base_url}}/oauth/token"
jsonpath "$.jwks_uri" == "{{base_url}}/oauth/jwks"
jsonpath "$.code_challenge_methods_supported" contains "S256"
jsonpath "$.grant_types_supported" contains "urn:ietf:params:oauth:grant-type:token-exchange"
jsonpath "$.id_token_signing_alg_values_supported" contains "RS256"

GET {{base_url}}/.well-known/oauth-authorization-server
HTTP 200
[Asserts]
jsonpath "$.registration_endpoint" == "{{base_url}}/oauth/register"
GET {{base_url}}/oauth/jwks
HTTP 200
[Asserts]
jsonpath "$.keys[0].kty" == "RSA"
jsonpath "$.keys[0].alg" == "RS256"
jsonpath "$.keys[0].kid" == "rustybin-rs256-key"

Demo clients and users

ClientSecretTypeGrants
rustybinsecretconfidential (client_secret_basic or client_secret_post)all five
rustybin-publicnonepublic, PKCE requiredauthorization_code, refresh_token

Both demo clients accept any absolute http(s) redirect URI. Dynamically registered clients must use one of their registered redirect URIs exactly.

UserPasswordNameEmailGroups
demodemoDemo Userdemo@rustybin.localusers
alicealiceAlice Adminalice@rustybin.localusers, admins
bobbobBob Builderbob@rustybin.localusers

Tokens

Access tokens are RS256 JWTs (typ: at+jwt) valid for one hour with iss, sub, aud, exp, iat, jti, client_id, scope and, for user grants, name, email, preferred_username, groups and auth_time. The audience is rustybin unless the request names the target with resource (RFC 8707, up to five absolute URIs). ID tokens (for user grants with the openid scope) are addressed to the client and carry nonce when one was sent. Refresh tokens are opaque (rt_...), valid 24 hours and rotate: each use returns a new one and invalidates the old.

The signing key is generated at startup: tokens do not survive a restart. Scopes are free-form (for example orders:read or mcp:tools); the defaults are openid for client_credentials and openid profile email for user grants.

Token responses are sent with Cache-Control: no-store. Errors follow RFC 6749 ({"error": "invalid_grant", "error_description": "..."}), 401 with WWW-Authenticate for client authentication failures.

Grants

Client credentials

POST {{base_url}}/oauth/token
[BasicAuth]
rustybin: secret
[Form]
grant_type: client_credentials
scope: orders:read
resource: https://api.example.com
HTTP 200
[Captures]
cc_token: jsonpath "$.access_token"
[Asserts]
header "Cache-Control" == "no-store"
jsonpath "$.token_type" == "Bearer"
jsonpath "$.expires_in" == 3600
jsonpath "$.scope" == "orders:read"
jsonpath "$.refresh_token" not exists
POST {{base_url}}/oauth/token
[Form]
grant_type: client_credentials
client_id: rustybin
client_secret: wrong
HTTP 401
[Asserts]
jsonpath "$.error" == "invalid_client"
header "WWW-Authenticate" exists

Password

Handy for scripted demos (and still common in legacy gateways). It returns an ID token and a refresh token.

POST {{base_url}}/oauth/token
[Form]
grant_type: password
client_id: rustybin
client_secret: secret
username: alice
password: alice
HTTP 200
[Captures]
user_token: jsonpath "$.access_token"
[Asserts]
jsonpath "$.scope" == "openid profile email"
jsonpath "$.id_token" exists
jsonpath "$.refresh_token" startsWith "rt_"

GET {{base_url}}/oauth/userinfo
Authorization: Bearer {{user_token}}
HTTP 200
[Asserts]
jsonpath "$.sub" == "alice"
jsonpath "$.name" == "Alice Admin"
jsonpath "$.email" == "alice@rustybin.local"
jsonpath "$.groups" contains "admins"

Authorization code with PKCE

/oauth/authorize validates the request and shows a login form (prefilled with login_hint, else demo). Submitting it redirects (303) to the redirect URI with code and state; "Deny" returns error=access_denied. Codes are single use and expire after five minutes. PKCE supports S256 and plain; it is mandatory for public clients. Errors that can be sent back safely go to the redirect URI as error and error_description; an unknown client or redirect URI gets an error page instead.

# A browser would open this URL and see the demo login form.
GET {{base_url}}/oauth/authorize
[Query]
response_type: code
client_id: rustybin-public
redirect_uri: http://localhost:3000/callback
scope: openid profile email
state: xyz
nonce: n-0S6
code_challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
code_challenge_method: S256
HTTP 200
[Asserts]
header "Content-Type" contains "text/html"
body contains "Rustybin OIDC Login"

The browser step, done with a form post:

# Submitting the form (what the browser does) redirects back with a code.
POST {{base_url}}/oauth/authorize
[Form]
response_type: code
client_id: rustybin-public
redirect_uri: http://localhost:3000/callback
scope: openid profile email
state: xyz
nonce: n-0S6
code_challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
code_challenge_method: S256
username: demo
password: demo
action: approve
HTTP 303
[Captures]
code: header "Location" urlQueryParam "code"
[Asserts]
header "Location" startsWith "http://localhost:3000/callback?code="
header "Location" urlQueryParam "state" == "xyz"
POST {{base_url}}/oauth/token
[Form]
grant_type: authorization_code
client_id: rustybin-public
code: {{code}}
redirect_uri: http://localhost:3000/callback
code_verifier: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
HTTP 200
[Captures]
access_token: jsonpath "$.access_token"
id_token: jsonpath "$.id_token"
refresh_token: jsonpath "$.refresh_token"
[Asserts]
jsonpath "$.token_type" == "Bearer"
jsonpath "$.scope" == "openid profile email"

# The ID token carries the nonce and is addressed to the client.
GET {{base_url}}/auth/jwt/decode
Authorization: Bearer {{id_token}}
HTTP 200
[Asserts]
jsonpath "$.claims.nonce" == "n-0S6"
jsonpath "$.claims.aud" == "rustybin-public"
jsonpath "$.claims.preferred_username" == "demo"
POST {{base_url}}/oauth/token
[Form]
grant_type: authorization_code
client_id: rustybin-public
code: {{code}}
redirect_uri: http://localhost:3000/callback
code_verifier: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
HTTP 400
[Asserts]
jsonpath "$.error" == "invalid_grant"
# The public client must use PKCE: the error goes back to the redirect URI.
GET {{base_url}}/oauth/authorize
[Query]
response_type: code
client_id: rustybin-public
redirect_uri: http://localhost:3000/callback
state: abc
HTTP 303
[Asserts]
header "Location" urlQueryParam "error" == "invalid_request"
header "Location" urlQueryParam "state" == "abc"

Refresh

A refresh may narrow the scope (never widen it) and may name a new resource.

# Refresh tokens rotate: the old one stops working.
POST {{base_url}}/oauth/token
[Form]
grant_type: refresh_token
client_id: rustybin-public
refresh_token: {{refresh_token}}
scope: openid
HTTP 200
[Asserts]
jsonpath "$.scope" == "openid"
jsonpath "$.refresh_token" != "{{refresh_token}}"

POST {{base_url}}/oauth/token
[Form]
grant_type: refresh_token
client_id: rustybin-public
refresh_token: {{refresh_token}}
HTTP 400
[Asserts]
jsonpath "$.error" == "invalid_grant"

Token exchange (RFC 8693)

grant_type=urn:ietf:params:oauth:grant-type:token-exchange exchanges a subject_token (access token, ID token or JWT issued by this server, RS256 or the HS256 demo secret) for a new access token with the requested audience / resource and an optionally narrowed scope. An actor_token adds an act claim (delegation, nested when the subject was already delegated). requested_token_type may be access_token, jwt or id_token.

# RFC 8693: exchange alice's token for one aimed at another API, with an actor.
POST {{base_url}}/oauth/token
[BasicAuth]
rustybin: secret
[Form]
grant_type: urn:ietf:params:oauth:grant-type:token-exchange
subject_token: {{user_token}}
subject_token_type: urn:ietf:params:oauth:token-type:access_token
actor_token: {{cc_token}}
actor_token_type: urn:ietf:params:oauth:token-type:access_token
audience: https://payments.example.com
scope: openid
HTTP 200
[Captures]
exchanged: jsonpath "$.access_token"
[Asserts]
jsonpath "$.issued_token_type" == "urn:ietf:params:oauth:token-type:access_token"
jsonpath "$.scope" == "openid"

POST {{base_url}}/oauth/introspect
[BasicAuth]
rustybin: secret
[Form]
token: {{exchanged}}
HTTP 200
[Asserts]
jsonpath "$.sub" == "alice"
jsonpath "$.aud" == "https://payments.example.com"
jsonpath "$.act.sub" == "rustybin"

UserInfo, introspection, revocation

/oauth/userinfo accepts an access token from this provider (Bearer header, or an access_token form field on POST) and requires the openid scope (403 insufficient_scope otherwise).

GET {{base_url}}/oauth/userinfo
Authorization: Bearer {{access_token}}
HTTP 200
[Asserts]
jsonpath "$.sub" == "demo"
jsonpath "$.email_verified" == true

/oauth/introspect needs a confidential client and reports active, token_type, scope, client_id, sub, aud, iss, exp, iat, jti, act and username; refresh tokens can be introspected too.

POST {{base_url}}/oauth/introspect
[Form]
token: {{cc_token}}
client_id: rustybin
client_secret: secret
HTTP 200
[Asserts]
jsonpath "$.active" == true
jsonpath "$.aud" == "https://api.example.com"
jsonpath "$.client_id" == "rustybin"
jsonpath "$.sub" == "rustybin"
jsonpath "$.iss" == "{{base_url}}"

/oauth/revoke always answers 200 for a well-formed request; a client can only revoke its own tokens. Revoked access tokens are rejected by introspection, UserInfo and token exchange (but not by /auth/jwt, which checks only the signature and claims, like a gateway validating JWTs locally would).

POST {{base_url}}/oauth/revoke
[BasicAuth]
rustybin: secret
[Form]
token: {{cc_token}}
HTTP 200

POST {{base_url}}/oauth/introspect
[BasicAuth]
rustybin: secret
[Form]
token: {{cc_token}}
HTTP 200
[Asserts]
jsonpath "$.active" == false

Dynamic client registration

POST /oauth/register takes RFC 7591 metadata (client_name, redirect_uris (required for authorization_code, at most 10), grant_types, response_types (only code), token_endpoint_auth_method: client_secret_basic (default), client_secret_post or none for a public client, and scope). It returns 201 with a dyn-... client id (and a secret for confidential clients). Registrations are kept for 24 hours in a bounded registry (1000 clients, 200 in public mode). MCP clients use this to onboard themselves.

# RFC 7591 dynamic client registration, as MCP clients do it.
POST {{base_url}}/oauth/register
Content-Type: application/json
{
  "client_name": "docs demo app",
  "redirect_uris": ["http://localhost:3000/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_method": "client_secret_basic"
}
HTTP 201
[Asserts]
jsonpath "$.client_id" startsWith "dyn-"
jsonpath "$.client_secret" exists
jsonpath "$.client_secret_expires_at" exists
jsonpath "$.token_endpoint_auth_method" == "client_secret_basic"
POST {{base_url}}/oauth/register
Content-Type: application/json
{"client_name": "cli", "redirect_uris": ["http://127.0.0.1:8976/cb"], "token_endpoint_auth_method": "none"}
HTTP 201
[Asserts]
jsonpath "$.client_secret" not exists
jsonpath "$.grant_types" contains "authorization_code"

The conformance/oauth/oauth_flow.py script walks through the same flows the way an MCP client does (metadata, registration, PKCE with resource, refresh, UserInfo, introspection, revocation and token exchange).