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
| Route | Standard |
|---|---|
GET /.well-known/openid-configuration | OpenID Connect Discovery |
GET /.well-known/oauth-authorization-server | RFC 8414 authorization server metadata |
GET /oauth/jwks | The RS256 public key (JWK Set, kid rustybin-rs256-key) |
GET, POST /oauth/authorize | Authorization endpoint with a demo login page |
POST /oauth/token | Token endpoint |
GET, POST /oauth/userinfo | OIDC UserInfo |
POST /oauth/introspect | RFC 7662 token introspection |
POST /oauth/revoke | RFC 7009 token revocation |
POST /oauth/register | RFC 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
| Client | Secret | Type | Grants |
|---|---|---|---|
rustybin | secret | confidential (client_secret_basic or client_secret_post) | all five |
rustybin-public | none | public, PKCE required | authorization_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.
| User | Password | Name | Groups | |
|---|---|---|---|---|
demo | demo | Demo User | demo@rustybin.local | users |
alice | alice | Alice Admin | alice@rustybin.local | users, admins |
bob | bob | Bob Builder | bob@rustybin.local | users |
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).