Id RFC 004 oauth2 oidc service

RFC-004 — OAuth2/OIDC Service

  • *tatus:*Draft
  • *ate:*20260408
  • *uthor:*Koder Team
  • *epends on:*RFC001, RFC002, RFC-003

Summary

The OAuth2OIDC service implements an OpenID Connect Provider (OP) compliant with [OpenID Connect Core 1.0](https:openid.netspecsopenidconnectcore-1_0.html) and [OAuth 2.1](https:datatracker.ietf.orgdochtmldraftietfoauthv21-07). It handles client registration, authorization flows, token issuance, and JWKS publication.

Supported Flows

MVP

Flow Use Case
*uthorization Code + PKCE* Web apps, mobile apps, SPAs — the primary flow
*lient Credentials* Machinetomachine (service accounts)
*efresh Token* Token renewal without re-authentication

Explicitly NOT Supported

Flow Reason
Implicit Deprecated in OAuth 2.1, insecure
Resource Owner Password Credentials (ROPC) Deprecated in OAuth 2.1

OIDC Discovery

GET /.well-known/openid-configuration

Auto-generated per tenant. Example for tenant koder:

{
    "issuer": "https://koder.id.koder.dev",
    "authorization_endpoint": "https://koder.id.koder.dev/oauth/v2/authorize",
    "token_endpoint": "https://koder.id.koder.dev/oauth/v2/token",
    "userinfo_endpoint": "https://koder.id.koder.dev/oidc/v1/userinfo",
    "jwks_uri": "https://koder.id.koder.dev/oauth/v2/keys",
    "revocation_endpoint": "https://koder.id.koder.dev/oauth/v2/revoke",
    "introspection_endpoint": "https://koder.id.koder.dev/oauth/v2/introspect",
    "end_session_endpoint": "https://koder.id.koder.dev/oidc/v1/end_session",
    "registration_endpoint": "https://koder.id.koder.dev/oauth/v2/register",

    "response_types_supported": ["code"],
    "grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"],
    "subject_types_supported": ["public"],
    "id_token_signing_alg_values_supported": ["RS256", "ES256"],
    "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
    "scopes_supported": ["openid", "profile", "email", "offline_access"],
    "claims_supported": ["sub", "iss", "aud", "exp", "iat", "name", "email", "email_verified", "locale", "picture"],
    "code_challenge_methods_supported": ["S256"],
    "request_parameter_supported": false,
    "request_uri_parameter_supported": false
}

Authorization Code Flow + PKCE

Step 1 — Authorization Request

GET /oauth/v2/authorize
    ?response_type=code
    &client_id=my-app
    &redirect_uri=https://example.koder.dev/auth/callback
    &scope=openid profile email
    &state=random-csrf-token
    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
    &code_challenge_method=S256
    &nonce=random-nonce

*KCE is mandatory*for all clients (public and confidential). This follows OAuth 2.1.

*erver behavior:*

  1. Validate client_id exists and redirect_uri matches registered URIs
  2. If user is not authenticated → redirect to login page (Auth service)
  3. If user is authenticated → check consent
  4. If consent exists for requested scopes → generate code and redirect
  5. If no consent → show consent screen, then generate code and redirect

Step 2 — Token Exchange

POST /oauth/v2/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://app.example.com/callback
&client_id=my-app
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

*erver behavior:*

  1. Validate authorization code exists, not used, not expired
  2. Validate redirect_uri matches the one used in authorization request
  3. Validate PKCE: SHA256(code_verifier) == code_challenge
  4. For confidential clients: validate client_secret (via Basic auth or body)
  5. Mark authorization code as used (single-use)
  6. Create session via Session service (gRPC) — the Session service generates the access token (JWT) and refresh token (see RFC-005)
  7. Return tokens to client:

*esponse:*

{
    "access_token": "eyJhbGciOi...",
    "token_type": "Bearer",
    "expires_in": 900,
    "refresh_token": "krt_...",
    "id_token": "eyJhbGciOi...",
    "scope": "openid profile email"
}

Token Formats

Access Token (JWT)

{
    "header": {
        "alg": "RS256",
        "typ": "at+jwt",
        "kid": "key-2026-04"
    },
    "payload": {
        "iss": "https://koder.id.koder.dev",
        "sub": "01HXK...",
        "aud": ["my-app"],
        "exp": 1712577600,
        "iat": 1712576700,
        "nbf": 1712576700,
        "jti": "01HXK...",
        "client_id": "my-app",
        "scope": "openid profile email",
        "tenant_id": "koder"
    }
}
  • *ifetime:*15 minutes (configurable per client: 5-60 min)
  • *igning:*RS256 (default) or ES256

ID Token (JWT)

{
    "header": {
        "alg": "RS256",
        "typ": "JWT",
        "kid": "key-2026-04"
    },
    "payload": {
        "iss": "https://koder.id.koder.dev",
        "sub": "01HXK...",
        "aud": "my-app",
        "exp": 1712577600,
        "iat": 1712576700,
        "nonce": "random-nonce",
        "at_hash": "base64url...",
        "name": "Rodrigo Pereira",
        "email": "rodrigo@koder.dev",
        "email_verified": true,
        "locale": "pt-BR",
        "picture": "https://id.koder.dev/avatars/01HXK..."
    }
}
  • *ifetime:*same as access token
  • *ontains:*identity claims based on requested scopes

Refresh Token (opaque)

krt_01HXKa8b9c0d1e2f3g4h5i6j7k8l9m0n
  • *ormat:*prefix krt_ + ULID (easily identifiable, revocable)
  • *ifetime:*30 days (configurable per client)
  • *torage:*hashed in Session service database
  • *otation:*new refresh token issued on each use (old one invalidated)

Key Rotation (JWKS)

GET /oauth/v2/keys

Returns the JSON Web Key Set with all active public keys:

{
    "keys": [
        {
            "kty": "RSA",
            "alg": "RS256",
            "use": "sig",
            "kid": "key-2026-04",
            "n": "base64url...",
            "e": "AQAB"
        }
    ]
}

Rotation Strategy

  1. New key pair generated every *4 hours*(configurable)
  2. New key becomes active immediately for signing
  3. Old key remains in JWKS for *8 hours*(to validate tokens signed with it)
  4. After 48 hours, old key removed from JWKS and marked rotated in DB
  5. Maximum 3 active keys in JWKS at any time

Token Revocation

POST /oauth/v2/revoke

POST /oauth/v2/revoke
Content-Type: application/x-www-form-urlencoded

token=krt_01HXK...
&token_type_hint=refresh_token
  • Revokes the refresh token and its associated session
  • All access tokens from that session become invalid on next introspection
  • Returns 200 OK always (even if token doesn't exist — prevents enumeration)

Token Introspection

POST /oauth/v2/introspect

For resource servers to validate opaque tokens or check revocation status:

{
    "active": true,
    "sub": "01HXK...",
    "client_id": "my-app",
    "scope": "openid profile email",
    "exp": 1712577600,
    "iat": 1712576700,
    "tenant_id": "koder"
}

UserInfo Endpoint

GET /oidc/v1/userinfo

Authorization: Bearer eyJhbGciOi...

*esponse:*

{
    "sub": "01HXK...",
    "name": "Rodrigo Pereira",
    "email": "rodrigo@koder.dev",
    "email_verified": true,
    "locale": "pt-BR",
    "picture": "https://id.koder.dev/avatars/01HXK..."
}

Claims returned depend on the scopes granted in the access token.

Client Credentials Flow

For servicetoservice authentication (no user involved):

POST /oauth/v2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)

grant_type=client_credentials
&scope=admin:read admin:write

*esponse:*access token only (no refresh token, no ID token).

gRPC Service Definition

syntax = "proto3";
package koder.id.oauth.v1;

service OAuthService {
    // Authorization code operations
    rpc CreateAuthorizationCode(CreateAuthCodeRequest) returns (AuthorizationCode);
    rpc ExchangeAuthorizationCode(ExchangeCodeRequest) returns (TokenResponse);

    // Token operations
    rpc RefreshToken(RefreshTokenRequest) returns (TokenResponse);
    rpc RevokeToken(RevokeTokenRequest) returns (RevokeTokenResponse);
    rpc IntrospectToken(IntrospectRequest) returns (IntrospectResponse);

    // Client management (called by Admin service)
    rpc CreateClient(CreateClientRequest) returns (Client);
    rpc GetClient(GetClientRequest) returns (Client);
    rpc UpdateClient(UpdateClientRequest) returns (Client);
    rpc DeleteClient(DeleteClientRequest) returns (DeleteClientResponse);
    rpc ListClients(ListClientsRequest) returns (ListClientsResponse);

    // Key management
    rpc GetJWKS(GetJWKSRequest) returns (JWKS);
    rpc RotateSigningKey(RotateKeyRequest) returns (SigningKey);

    // Consent
    rpc GetConsent(GetConsentRequest) returns (Consent);
    rpc GrantConsent(GrantConsentRequest) returns (Consent);
    rpc RevokeConsent(RevokeConsentRequest) returns (RevokeConsentResponse);
}

Interaction with Other Services

┌────────┐                ┌──────────┐
│ OAuth2 │─── gRPC ──────▶│   Auth   │  Trigger auth flow if user not logged in
│ /OIDC  │                │ Service  │
│Service │                └──────────┘
│        │
│        │─── gRPC ──────▶┌──────────┐
│        │                │ Identity │  Get user claims for ID token / userinfo
│        │                │ Service  │
│        │                └──────────┘
│        │
│        │─── gRPC ──────▶┌──────────┐
│        │                │ Session  │  Create/validate sessions, issue refresh tokens
└────────┘                │ Service  │
                          └──────────┘

Security Considerations

  • *KCE mandatory*for all clients (OAuth 2.1 requirement)
  • *uthorization codes*are single-use and expire in 30 seconds
  • *efresh token rotation*— new token issued on each use, old one invalidated
  • *edirect URI exact matching*— no wildcards, no partial matches
  • *tate parameter*validated to prevent CSRF
  • *oken binding*— access tokens are bound to the client that requested them
  • *o implicit flow*— eliminated per OAuth 2.1

PAT Scopes

Personal Access Tokens (machinetoAPI auth, non-interactive) carry a distinct set of canonical scopes from the OAuth/OIDC consent scopes above (openid, profile, email, …). PAT scopes follow the GitHub convention verb:resource (lowercase, colon-separated) and never surface on a consent screen because PATs are issued outofband (CLI / settings page).

The canonical registry is pkg/scopes/pat.go::WellKnownPATScopes. The PAT issuance flow MUST reject any scope name not in the registry; the consuming resource service MUST gate the protected endpoint on the scope being present in the bearer's PAT.

Catalog

Scope Resource Description Consumer
read:usage usage Read your own usage data (rate-limit windows, token cost histograms, billing aggregates). services/ai/gateway GET /v1/usage per RFC001tokenusagetracker.kmd §Q2 (ratified 20260509)

New PAT scopes register by adding a constant + map entry to pkg/scopes/pat.go and a row above; the pat_test.go constantvsmap back-ref test guards drift.

*tatus (20260517, #071 Wave 1):*the catalog is established; PAT issuance flow (CLI koder-id-cli pat create --scope=read:usage, settings UI, validation gate at issue-time) is tracked as a separate follow-up — until it lands, read:usage is consumed only by gateway-side enforcement and rendered in this catalog for downstream reference.

Compliance

This service targets conformance with:

Source: ../home/koder/dev/koder/meta/docs/stack/rfcs/id-RFC-004-oauth2-oidc-service.md