Authorization ‐ OIDC Device Authorization - demingongo/kaapi GitHub Wiki

🚀 Kaapi + OIDC Device Authorization Flow Example

This is a fully working example of a Kaapi server implementing the OIDC Device Authorization 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

  • Device Authorization flow (/oauth2/device_authorization)
  • Token polling (/oauth2/token)
  • JWKS endpoint (/.well-known/jwks.json)
  • Access token validation
  • Key rotation
  • Grouping multiple auth strategies with GroupAuthDesign
  • Swagger/OpenAPI integration with Bearer token
  • Restricted route example

🛠 Full Example Code

import { Kaapi, GroupAuthDesign, BearerAuthDesign } from '@kaapi/kaapi';
import {
    OIDCDeviceAuthorizationBuilder,
    createInMemoryKeyStore,
    OAuth2TokenResponse,
    NoneAuthMethod,
    DeviceFlowOAuth2ErrorCode,
} from '@kaapi/oauth2-auth-design';

// Registered clients and allowed scopes
const REGISTERED_CLIENTS = {
    'service-api-client': ['read', 'write'],
    'internal-service': ['read', 'write', 'admin'],
};

/**
 * In-memory device codes store
 * @type {Map<string, { clientId: string; scopes: string[]; verified: boolean; userCode: string }>}
 */
const deviceCodesStore = new Map();

// === OIDC Device Authorization Builder ===
const authDesign = OIDCDeviceAuthorizationBuilder.create()
    .strategyName('device-authorization')
    .setTokenTTL(600)
    .setJwksKeyStore(createInMemoryKeyStore())
    .setJwksRotatorOptions({
        intervalMs: 7.862e9,
        timestampStore: createInMemoryKeyStore(),
    })
    .setPublicKeyExpiry(8.64e6)
    .useAccessTokenJwks(true)
    .addClientAuthenticationMethod(new NoneAuthMethod())
    .setScopes({
        read: 'Read access',
        write: 'Write access',
        admin: 'Administrative access',
    })

    // Step 1: Device Authorization
    .authorizationRoute((route) =>
        route.setPath('/oauth2/device_authorization').generateCode(async ({ clientId, scope }) => {
            const allowedScopes = REGISTERED_CLIENTS[clientId];
            if (!allowedScopes) return null;

            const requestedScopes = scope?.split(' ') ?? [];
            const grantedScopes = requestedScopes.filter((s) => allowedScopes.includes(s));
            if (grantedScopes.length === 0) return null;

            const deviceCode = `device-${Date.now()}`;
            const userCode = `user-${Math.random().toString(36).substring(2, 8)}`;

            deviceCodesStore.set(deviceCode, { clientId, scopes: grantedScopes, verified: false, userCode });

            return {
                device_code: deviceCode,
                user_code: userCode,
                verification_uri: 'http://localhost:3000/verify',
                verification_uri_complete: `http://localhost:3000/verify?user_code=${userCode}`,
                expires_in: 600,
                interval: 5,
            };
        })
    )

    // Step 2: Token polling
    .tokenRoute((route) =>
        route
            .setPath('/oauth2/token')
            .generateToken(async ({ deviceCode, clientId, ttl, tokenType, createJwtAccessToken }) => {
                const entry = deviceCodesStore.get(deviceCode);
                if (!entry || entry.clientId !== clientId) return null;

                if (!entry.verified) return { error: DeviceFlowOAuth2ErrorCode.AUTHORIZATION_PENDING };

                const { token: accessToken } = await createJwtAccessToken({
                    sub: 'device-user',
                    scope: entry.scopes,
                });

                deviceCodesStore.delete(deviceCode);

                return new OAuth2TokenResponse({ access_token: accessToken })
                    .setExpiresIn(ttl)
                    .setTokenType(tokenType)
                    .setScope(entry.scopes.join(' '));
            })
    )

    // 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,
                    aud: jwtAccessTokenPayload.aud,
                },
                scope: Array.isArray(jwtAccessTokenPayload.scope)
                    ? jwtAccessTokenPayload.scope
                    : [],
            },
        };
    })
    .build();

// === GroupAuthDesign for multiple auth strategies ===
const groupAuth = new GroupAuthDesign([
    authDesign,
    new BearerAuthDesign({ strategyName: 'bearer-auth' }) // for SwaggerUI token auth
]);

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

// Extend app with grouped auth strategies
await app.extend(groupAuth);

// Default strategy
app.base().auth.default({
    strategies: groupAuth.getStrategies(),
    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 Device Authorization Server running at http://localhost:3000');

🔑 Verification Endpoint (/verify)

import Joi from 'joi';

// === Verification Endpoint ===
app.route(
    {
        method: 'GET',
        path: '/verify',
        options: {
            validate: {
                query: Joi.object({
                    user_code: Joi.string(),
                }),
            },
        },
    },
    (request, h) => {
        const userCode = request.query.user_code;
        if (!userCode) return h.response('Missing user_code').code(400);

        const entry = Array.from(deviceCodesStore.values()).find((v) => v.userCode === userCode);
        if (!entry) return h.response('Invalid user_code').code(404);

        entry.verified = true;
        return h.response(
            `Device verified successfully for client: ${entry.clientId}, scopes: ${entry.scopes.join(' ')}`
        );
    }
);

💡 Tip: /verify is where the user confirms the device code. Use this to mark device codes as verified.

👋 Restricted Route Example (/greetings)

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

💡 Tip: /greetings is an authenticated route example. Make sure you pass a valid Bearer token.


📌 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. SwaggerUI Limitation: Device flow cannot be executed directly in SwaggerUI.

    ✅ Workflow:

    1. Check the device authorization endpoint and other OIDC info at /.well-known/openid-configuration.
    2. Run the device flow with an external tool.
    3. Get the access token.
    4. Use it in SwaggerUI via Bearer token authorization.
  3. Bearer Token in OpenAPI Docs: Achieved by adding BearerAuthDesign to the Kaapi app (already included in the GroupAuthDesign above). SwaggerUI will then show the Authorize button.

  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, you can adjust both the check frequency and the rotation interval as needed.