Authorization ‐ OIDC Client Credentials - demingongo/kaapi GitHub Wiki

🚀 Kaapi + OIDC Client Credentials Flow Example

This is a fully working example of a Kaapi server implementing the OIDC Client Credentials 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, or enhanced client validation).

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


✅ Features Demonstrated

  • Client Credentials Flow (/oauth2/token)

  • JWKS endpoint (/.well-known/jwks.json)

  • Access token validation

  • Multiple client authentication methods:

    • ClientSecretBasic
    • ClientSecretPost
  • Key rotation

  • Swagger/OpenAPI integration (works directly in Swagger UI)

  • Restricted route example with access control


🛠 Full Example Code

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

// Valid clients and allowed scopes
const VALID_CLIENTS = [
    {
        client_id: 'service-api-client',
        client_secret: 's3cr3tK3y123!',
        allowed_scopes: ['read', 'write'],
    },
    {
        client_id: 'internal-service',
        client_secret: 'Int3rnalK3y!',
        allowed_scopes: ['read', 'write', 'admin'],
    },
];

// === OIDC Client Credentials Builder ===
const authDesign = OIDCClientCredentialsBuilder.create()
    .strategyName('client-credentials')
    .setTokenTTL(600)
    .setJwksKeyStore(createInMemoryKeyStore())
    .setJwksRotatorOptions({
        intervalMs: 7.862e9,
        timestampStore: createInMemoryKeyStore(),
    })
    .setPublicKeyExpiry(8.64e6)
    .useAccessTokenJwks(true)

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

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

    // Token route definition
    .tokenRoute((route) =>
        route
            .setPath('/oauth2/token')
            .generateToken(async ({ clientId, clientSecret, ttl, tokenType, scope, createJwtAccessToken }) => {
                // Validate client credentials
                const client = VALID_CLIENTS.find(
                    (c) => c.client_id === clientId && c.client_secret === clientSecret
                );

                if (!client) {
                    return {
                        error: OAuth2ErrorCode.INVALID_CLIENT,
                        error_description: 'Invalid client_id or client_secret',
                    };
                }

                // Determine requested scopes
                const requestedScopes = (scope ?? '').split(/\s+/).filter(Boolean);

                // Compute granted scopes
                let grantedScopes = client.allowed_scopes;
                if (requestedScopes.length > 0) {
                    grantedScopes = requestedScopes.filter((s) =>
                        client.allowed_scopes.includes(s)
                    );
                }

                if (grantedScopes.length === 0) {
                    return {
                        error: OAuth2ErrorCode.INVALID_SCOPE,
                        error_description: 'No valid scopes granted for this client',
                    };
                }

                // Generate a signed JWT access token
                const { token: accessToken } = await createJwtAccessToken({
                    sub: clientId,
                    scope: grantedScopes,
                });

                // Return token response
                return new OAuth2TokenResponse({ access_token: accessToken })
                    .setExpiresIn(ttl)
                    .setTokenType(tokenType)
                    .setScope(grantedScopes.join(' '));
            })
    )

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

    // Access Token Validation
    .validate(async (_req, { jwtAccessTokenPayload }) => {
        if (!jwtAccessTokenPayload?.sub) return { isValid: false };
        return {
            isValid: true,
            credentials: {
                app: {
                    id: jwtAccessTokenPayload.sub,
                },
                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 Client Credentials Auth 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: 'app',
                    scope: ['read', 'admin'],
                },
            },
        },
    },
    (req) => `Hello ${req.auth.credentials.app.id}`
);

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


📌 Notes & Tips

  1. Copy/Paste Ready: This is a working example. Developers can copy it, run it, and experiment. Replace in-memory clients with database-backed or secure storage in production.

  2. SwaggerUI Compatible: The Client Credentials flow can be fully tested in SwaggerUI. Simply click Authorize, enter your client_id and client_secret, select scopes, and Swagger will handle the token request automatically.

  3. 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.

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