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
openidscope - 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:
/greetingsis an authenticated route example. Call it with a valid Bearer token obtained via the authorization code flow.
📌 Notes & Tips
-
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.
-
Token Exchange:
/oauth2/tokenexchanges the code for Access Token and optional ID Token (openidscope).- Supports
client_secret_basic,client_secret_post, and PKCE (codeChallenge/codeVerifier).
-
SwaggerUI Limitation: In SwaggerUI, the Authorization Code flow can only be tested using
client_secret_postauthentication method. Simply click Authorize, enter yourclient_idandclient_secret, select scopes, and Swagger will handle the flow automatically. -
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. -
OIDC Metadata: The server automatically exposes its configuration at
/.well-known/openid-configuration, listing available endpoints, grant types, and supported scopes.