Authorization ‐ OIDC Authorization Code - demingongo/kaapi GitHub Wiki

🚀 Kaapi + OIDC Authorization Code Flow Example

This is a fully working example of a Kaapi server implementing the OIDC Authorization Code Flow.

It is intended for documentation and learning purposes, so you can copy/paste it, run it, and experiment. Then adapt the logic for your production needs (e.g., database persistence, encryption, full client authentication, etc.).

⚠️ Note: This example uses in-memory storage and simplified logic for demonstration only.


✅ Features Demonstrated

  • OIDC Authorization Code flow (/oauth2/authorize)
  • Token exchange (/oauth2/token)
  • PKCE (Proof Key for Code Exchange) support
  • JWKS endpoint (/.well-known/jwks.json)
  • ID Token creation for openid scope
  • Access token validation
  • Key rotation
  • Restricted route example
  • Swagger/OpenAPI integration with Bearer token support

🛠 Full Example Code

import { Kaapi } from '@kaapi/kaapi';
import {
    OIDCAuthorizationCodeBuilder,
    createInMemoryKeyStore,
    createMatchAuthCodeResult,
    OAuth2TokenResponse,
    ClientSecretBasic,
    ClientSecretPost,
    NoneAuthMethod,
    OAuth2ErrorCode,
} from '@kaapi/oauth2-auth-design';

// === Valid clients and users ===
const VALID_CLIENTS = [
    { client_id: 'service-api-client', client_secret: 's3cr3tK3y123!', allowed_scopes: ['openid', 'read', 'write'] },
];

const REGISTERED_USERS = [
    { id: 'user-1234', username: 'user', password: 'password' },
];

/**
 * In-memory auth codes store
 * @type {Map<string, { clientId: string; scopes: string[]; userId: string; codeChallenge?: string; }>}
 */
const authCodesStore = new Map();

// === OIDC Authorization Code Builder ===
const authDesign = OIDCAuthorizationCodeBuilder.create()
    .strategyName('oidc-auth-code')
    .setTokenTTL(3600)
    .setJwksKeyStore(createInMemoryKeyStore())
    .setJwksRotatorOptions({
        intervalMs: 7.862e9,
        timestampStore: createInMemoryKeyStore(),
    })
    .setPublicKeyExpiry(8.64e6)
    .useAccessTokenJwks(true)

    // Client authentication methods
    .addClientAuthenticationMethod(new ClientSecretBasic())
    .addClientAuthenticationMethod(new ClientSecretPost())
    .addClientAuthenticationMethod(new NoneAuthMethod())

    // Define scopes
    .setScopes({
        read: 'Grants read-only access to protected resources',
        write: 'Grants write access to protected resources',
    })

    // Step 1: Authorization
    .authorizationRoute((route) =>
        route
            .setPath('/oauth2/authorize')
            .setUsernameField('username')
            .setPasswordField('password')
            .setClientId(VALID_CLIENTS[0].client_id)
            .generateCode(async ({ clientId, codeChallenge, scope }, req, _h) => {
                const client = VALID_CLIENTS.find((c) => c.client_id === clientId);
                if (!client) return;

                const requestedScopes = (scope ?? '').split(/\s+/).filter(Boolean);
                let grantedScopes = requestedScopes.length ? requestedScopes.filter((s) => client.allowed_scopes.includes(s)) : client.allowed_scopes;
                if (grantedScopes.length === 0) return null;

                const user = REGISTERED_USERS.find(u => u.username === req.payload.username && u.password === req.payload.password);
                if (!user) return null;

                const code = `auth-${Date.now()}`;
                authCodesStore.set(code, { clientId, scopes: grantedScopes, userId: user.id, codeChallenge });
                return { type: 'code', value: code };
            })
            .finalizeAuthorization(async (ctx, _params, _req, h) => {
                const matcher = createMatchAuthCodeResult({
                    code: async () => h.redirect(ctx.fullRedirectUri),
                    continue: async () => h.redirect(ctx.fullRedirectUri),
                    deny: async () => h.redirect(ctx.fullRedirectUri),
                });
                return matcher(ctx.authorizationResult);
            })
    )

    // Step 2: Token exchange
    .tokenRoute((route) =>
        route
            .setPath('/oauth2/token')
            .generateToken(async ({ clientId, ttl, tokenType, code, clientSecret, codeVerifier, createJwtAccessToken, createIdToken, verifyCodeVerifier }) => {
                const entry = authCodesStore.get(code);
                if (!entry || entry.clientId !== clientId) return null;

                const client = VALID_CLIENTS.find(c => c.client_id === clientId);
                if (!client) {
                    return {
                        error: OAuth2ErrorCode.INVALID_CLIENT,
                        error_description: 'Client authentication failed.',
                    };
                }

                if (entry.codeChallenge) {
                    if (!verifyCodeVerifier(codeVerifier, entry.codeChallenge)) {
                        return { error: OAuth2ErrorCode.INVALID_GRANT, error_description: 'Invalid authorization grant.' };
                    }
                } else if (clientSecret) {
                    if (client.client_secret !== clientSecret) {
                        return { error: OAuth2ErrorCode.INVALID_CLIENT, error_description: 'Client authentication failed.' };
                    }
                } else {
                    return { error: OAuth2ErrorCode.INVALID_REQUEST, error_description: 'Missing or invalid request parameter.' };
                }

                const user = REGISTERED_USERS.find(u => u.id === entry.userId);
                if (!user) {
                    return {
                        error: OAuth2ErrorCode.INVALID_GRANT,
                        error_description: 'Invalid authorization grant.',
                    };
                }

                // Generate a signed JWT access token
                const { token: accessToken } = await createJwtAccessToken({ sub: entry.userId, client_id: clientId, scope: entry.scopes });

                // Generate a signed JWT id token
                const idToken = entry.scopes.includes('openid') ? (await createIdToken({ sub: entry.userId, name: user.username, aud: clientId })).token : undefined;

                // Return token response
                return new OAuth2TokenResponse({ access_token: accessToken })
                    .setExpiresIn(ttl)
                    .setTokenType(tokenType)
                    .setScope(entry.scopes)
                    .setIdToken(idToken);
            })
    )

    // Step 3: JWKS endpoint
    .jwksRoute((route) => route.setPath('/.well-known/jwks.json'))

    // Step 4: Access Token Validation
    .validate(async (_req, { jwtAccessTokenPayload }) => {
        if (!jwtAccessTokenPayload?.sub) return { isValid: false };
        return {
            isValid: true,
            credentials: {
                user: {
                    id: jwtAccessTokenPayload.sub,
                    clientId: jwtAccessTokenPayload.client_id,
                },
                scope: Array.isArray(jwtAccessTokenPayload.scope) ? jwtAccessTokenPayload.scope : [],
            },
        };
    })
    .build();

// === Kaapi App Setup ===
const app = new Kaapi({
    port: 3000,
    host: 'localhost',
    docs: { path: '/docs/api', host: { url: 'http://localhost:3000' } },
});

// Extend app with auth strategy
await app.extend(authDesign);

// Default strategy
app.base().auth.default({ strategy: authDesign.getStrategyName(), mode: 'try' });

// === Key rotation check every hour (rotation happens according to intervalMs) ===
setInterval(() => authDesign.checkAndRotateKeys().catch(console.error), 3600 * 1000);

// Start server
await app.listen();
app.log.info('🚀 Kaapi OIDC Authorization Code Server running at http://localhost:3000');

👋 Restricted Route Example (/greetings)

// === Restricted Route Example ===
app.route({
    method: 'GET',
    path: '/greetings',
    auth: true,
    options: { auth: { access: { entity: 'user', scope: ['read'] } } },
}, req => `Hello ${req.auth.credentials.user.id} in ${req.auth.credentials.user.clientId}`);

💡 Tip: /greetings is an authenticated route example. Call it with a valid Bearer token obtained via the authorization code flow.


📌 Notes & Tips

  1. Copy/Paste Ready: This is a working example. Developers can copy, run, and experiment, then replace the in-memory logic with DB/persistent storage or modify the authentication logic.

  2. Token Exchange:

    • /oauth2/token exchanges the code for Access Token and optional ID Token (openid scope).
    • Supports client_secret_basic, client_secret_post, and PKCE (codeChallenge / codeVerifier).
  3. SwaggerUI Limitation: In SwaggerUI, the Authorization Code flow can only be tested using client_secret_post authentication method. Simply click Authorize, enter your client_id and client_secret, select scopes, and Swagger will handle the flow automatically.

  4. Key Rotation: The server calls authDesign.checkAndRotateKeys() every hour to check if keys need rotation. Actual rotation happens according to the configured interval (intervalMs: 7.862e9 ≈ 91 days). In production, adjust both intervals as needed.

  5. OIDC Metadata: The server automatically exposes its configuration at /.well-known/openid-configuration, listing available endpoints, grant types, and supported scopes.