import * as plugins from '../plugins.js'; import type { Reception } from './classes.reception.js'; import type { App } from './classes.app.js'; /** * OidcManager handles OpenID Connect (OIDC) server functionality * for third-party client authentication. */ export class OidcManager { public receptionRef: Reception; public get db() { return this.receptionRef.db.smartdataDb; } // In-memory store for authorization codes (short-lived, 10 min TTL) private authorizationCodes = new Map(); // In-memory store for access tokens (for validation) private accessTokens = new Map(); // In-memory store for refresh tokens private refreshTokens = new Map(); // In-memory store for user consents (should be persisted later) private userConsents = new Map(); constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; // Start cleanup task for expired codes/tokens this.startCleanupTask(); } /** * Get the OIDC Discovery Document */ public getDiscoveryDocument(): plugins.idpInterfaces.data.IOidcDiscoveryDocument { const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global'; return { issuer: baseUrl, authorization_endpoint: `${baseUrl}/oauth/authorize`, token_endpoint: `${baseUrl}/oauth/token`, userinfo_endpoint: `${baseUrl}/oauth/userinfo`, jwks_uri: `${baseUrl}/.well-known/jwks.json`, revocation_endpoint: `${baseUrl}/oauth/revoke`, scopes_supported: ['openid', 'profile', 'email', 'organizations', 'roles'], response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], code_challenge_methods_supported: ['S256'], claims_supported: [ 'sub', 'iss', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'name', 'preferred_username', 'picture', 'email', 'email_verified', 'organizations', 'roles' ], }; } /** * Get the JSON Web Key Set (JWKS) */ public getJwks(): plugins.idpInterfaces.data.IJwks { const keypair = this.receptionRef.jwtManager.smartjwtInstance.getKeyPairAsJson(); // Convert PEM to JWK format const jwk = this.pemToJwk(keypair.publicPem); return { keys: [jwk], }; } /** * Convert PEM public key to JWK format */ private pemToJwk(publicPem: string): plugins.idpInterfaces.data.IJwk { // For now, use a simplified approach - in production, parse the PEM properly // The smartjwt library should provide this, or use crypto.createPublicKey const kid = plugins.smarthash.sha256FromStringSync(publicPem).substring(0, 16); // This is a placeholder - proper implementation would extract n and e from PEM // For now, return a minimal structure return { kty: 'RSA', use: 'sig', alg: 'RS256', kid: kid, // These would be extracted from the actual public key n: Buffer.from(publicPem).toString('base64url').substring(0, 256), e: 'AQAB', // Standard RSA exponent (65537) }; } /** * Handle the authorization endpoint request */ public async handleAuthorize(ctx: plugins.typedserver.IRequestContext): Promise { const params = ctx.url.searchParams; // Extract authorization request parameters const clientId = params.get('client_id'); const redirectUri = params.get('redirect_uri'); const responseType = params.get('response_type'); const scope = params.get('scope'); const state = params.get('state'); const codeChallenge = params.get('code_challenge'); const codeChallengeMethod = params.get('code_challenge_method'); const nonce = params.get('nonce'); const prompt = params.get('prompt') as 'none' | 'login' | 'consent' | null; // Validate required parameters if (!clientId || !redirectUri || !responseType || !scope || !state) { return this.errorResponse('invalid_request', 'Missing required parameters'); } if (responseType !== 'code') { return this.errorResponse('unsupported_response_type', 'Only code response type is supported'); } // Validate code challenge method if present if (codeChallenge && codeChallengeMethod !== 'S256') { return this.errorResponse('invalid_request', 'Only S256 code challenge method is supported'); } // Find the app by client_id const app = await this.findAppByClientId(clientId); if (!app) { return this.errorResponse('invalid_client', 'Unknown client_id'); } // Validate redirect URI if (!app.data.oauthCredentials.redirectUris.includes(redirectUri)) { return this.errorResponse('invalid_request', 'Invalid redirect_uri'); } // Parse and validate scopes const requestedScopes = scope.split(' ') as plugins.idpInterfaces.data.TOidcScope[]; const allowedScopes = app.data.oauthCredentials.allowedScopes as plugins.idpInterfaces.data.TOidcScope[]; const validScopes = requestedScopes.filter(s => allowedScopes.includes(s)); if (!validScopes.includes('openid')) { return this.errorResponse('invalid_scope', 'openid scope is required'); } // For now, redirect to login page with OAuth parameters // The login page will handle authentication and call back to complete authorization const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global'; const loginUrl = new URL(`${baseUrl}/login`); loginUrl.searchParams.set('oauth', 'true'); loginUrl.searchParams.set('client_id', clientId); loginUrl.searchParams.set('redirect_uri', redirectUri); loginUrl.searchParams.set('scope', validScopes.join(' ')); loginUrl.searchParams.set('state', state); if (codeChallenge) { loginUrl.searchParams.set('code_challenge', codeChallenge); loginUrl.searchParams.set('code_challenge_method', codeChallengeMethod!); } if (nonce) { loginUrl.searchParams.set('nonce', nonce); } return Response.redirect(loginUrl.toString(), 302); } /** * Generate an authorization code after user authentication */ public async generateAuthorizationCode( clientId: string, userId: string, scopes: plugins.idpInterfaces.data.TOidcScope[], redirectUri: string, codeChallenge?: string, nonce?: string ): Promise { const code = plugins.smartunique.shortId(32); const authCode: plugins.idpInterfaces.data.IAuthorizationCode = { code, clientId, userId, scopes, redirectUri, codeChallenge, codeChallengeMethod: codeChallenge ? 'S256' : undefined, nonce, expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes used: false, }; this.authorizationCodes.set(code, authCode); return code; } /** * Handle the token endpoint request */ public async handleToken(ctx: plugins.typedserver.IRequestContext): Promise { // Parse form data const contentType = ctx.headers.get('content-type'); if (!contentType?.includes('application/x-www-form-urlencoded')) { return this.tokenErrorResponse('invalid_request', 'Content-Type must be application/x-www-form-urlencoded'); } const formData = await ctx.formData(); const grantType = formData.get('grant_type') as string; // Extract client credentials from Basic auth or form let clientId = formData.get('client_id') as string; let clientSecret = formData.get('client_secret') as string; const authHeader = ctx.headers.get('authorization'); if (authHeader?.startsWith('Basic ')) { const base64 = authHeader.substring(6); const decoded = Buffer.from(base64, 'base64').toString('utf-8'); const [id, secret] = decoded.split(':'); clientId = clientId || id; clientSecret = clientSecret || secret; } if (!clientId) { return this.tokenErrorResponse('invalid_client', 'Missing client_id'); } // Find and validate app const app = await this.findAppByClientId(clientId); if (!app) { return this.tokenErrorResponse('invalid_client', 'Unknown client'); } // Validate client secret for confidential clients if (clientSecret) { const secretHash = await plugins.smarthash.sha256FromString(clientSecret); if (secretHash !== app.data.oauthCredentials.clientSecretHash) { return this.tokenErrorResponse('invalid_client', 'Invalid client credentials'); } } if (grantType === 'authorization_code') { return this.handleAuthorizationCodeGrant(formData, app); } else if (grantType === 'refresh_token') { return this.handleRefreshTokenGrant(formData, app); } else { return this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type'); } } /** * Handle authorization_code grant type */ private async handleAuthorizationCodeGrant( formData: FormData, app: App ): Promise { const code = formData.get('code') as string; const redirectUri = formData.get('redirect_uri') as string; const codeVerifier = formData.get('code_verifier') as string; if (!code || !redirectUri) { return this.tokenErrorResponse('invalid_request', 'Missing code or redirect_uri'); } // Find and validate authorization code const authCode = this.authorizationCodes.get(code); if (!authCode) { return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code'); } if (authCode.used) { // Code reuse attack - revoke all tokens for this code this.authorizationCodes.delete(code); return this.tokenErrorResponse('invalid_grant', 'Authorization code already used'); } if (authCode.expiresAt < Date.now()) { this.authorizationCodes.delete(code); return this.tokenErrorResponse('invalid_grant', 'Authorization code expired'); } if (authCode.clientId !== app.data.oauthCredentials.clientId) { return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch'); } if (authCode.redirectUri !== redirectUri) { return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch'); } // Verify PKCE if code challenge was used if (authCode.codeChallenge) { if (!codeVerifier) { return this.tokenErrorResponse('invalid_grant', 'Code verifier required'); } const expectedChallenge = this.generateS256Challenge(codeVerifier); if (expectedChallenge !== authCode.codeChallenge) { return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier'); } } // Mark code as used authCode.used = true; // Generate tokens const tokens = await this.generateTokens( authCode.userId, app.data.oauthCredentials.clientId, authCode.scopes, authCode.nonce ); return new Response(JSON.stringify(tokens), { status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Pragma': 'no-cache', }, }); } /** * Handle refresh_token grant type */ private async handleRefreshTokenGrant( formData: FormData, app: App ): Promise { const refreshToken = formData.get('refresh_token') as string; if (!refreshToken) { return this.tokenErrorResponse('invalid_request', 'Missing refresh_token'); } const tokenHash = await plugins.smarthash.sha256FromString(refreshToken); const storedToken = this.refreshTokens.get(tokenHash); if (!storedToken) { return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token'); } if (storedToken.revoked) { return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked'); } if (storedToken.expiresAt < Date.now()) { this.refreshTokens.delete(tokenHash); return this.tokenErrorResponse('invalid_grant', 'Refresh token expired'); } if (storedToken.clientId !== app.data.oauthCredentials.clientId) { return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch'); } // Generate new tokens (without new refresh token by default) const tokens = await this.generateTokens( storedToken.userId, storedToken.clientId, storedToken.scopes, undefined, false // Don't generate new refresh token ); return new Response(JSON.stringify(tokens), { status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Pragma': 'no-cache', }, }); } /** * Generate access token, ID token, and optionally refresh token */ private async generateTokens( userId: string, clientId: string, scopes: plugins.idpInterfaces.data.TOidcScope[], nonce?: string, includeRefreshToken = true ): Promise { const now = Date.now(); const accessTokenLifetime = 3600; // 1 hour const refreshTokenLifetime = 30 * 24 * 3600; // 30 days // Generate access token const accessToken = plugins.smartunique.shortId(32); const accessTokenHash = await plugins.smarthash.sha256FromString(accessToken); const accessTokenData: plugins.idpInterfaces.data.IOidcAccessToken = { id: plugins.smartunique.shortId(8), tokenHash: accessTokenHash, clientId, userId, scopes, expiresAt: now + accessTokenLifetime * 1000, issuedAt: now, }; this.accessTokens.set(accessTokenHash, accessTokenData); // Generate ID token (JWT) const idToken = await this.generateIdToken(userId, clientId, scopes, nonce); const response: plugins.idpInterfaces.data.ITokenResponse = { access_token: accessToken, token_type: 'Bearer', expires_in: accessTokenLifetime, id_token: idToken, scope: scopes.join(' '), }; // Generate refresh token if requested if (includeRefreshToken) { const refreshToken = plugins.smartunique.shortId(48); const refreshTokenHash = await plugins.smarthash.sha256FromString(refreshToken); const refreshTokenData: plugins.idpInterfaces.data.IOidcRefreshToken = { id: plugins.smartunique.shortId(8), tokenHash: refreshTokenHash, clientId, userId, scopes, expiresAt: now + refreshTokenLifetime * 1000, issuedAt: now, revoked: false, }; this.refreshTokens.set(refreshTokenHash, refreshTokenData); response.refresh_token = refreshToken; } return response; } /** * Generate an ID token (JWT) */ private async generateIdToken( userId: string, clientId: string, scopes: plugins.idpInterfaces.data.TOidcScope[], nonce?: string ): Promise { const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global'; const now = Math.floor(Date.now() / 1000); const claims: plugins.idpInterfaces.data.IIdTokenClaims = { iss: baseUrl, sub: userId, aud: clientId, exp: now + 3600, // 1 hour iat: now, auth_time: now, }; if (nonce) { claims.nonce = nonce; } // Add claims based on scopes if (scopes.includes('profile') || scopes.includes('email') || scopes.includes('organizations') || scopes.includes('roles')) { const userInfo = await this.getUserClaims(userId, scopes); Object.assign(claims, userInfo); } // Sign the JWT const idToken = await this.receptionRef.jwtManager.smartjwtInstance.createJWT(claims); return idToken; } /** * Handle the userinfo endpoint */ public async handleUserInfo(ctx: plugins.typedserver.IRequestContext): Promise { // Get access token from Authorization header const authHeader = ctx.headers.get('authorization'); if (!authHeader?.startsWith('Bearer ')) { return new Response(JSON.stringify({ error: 'invalid_token' }), { status: 401, headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer error="invalid_token"', }, }); } const accessToken = authHeader.substring(7); const tokenHash = await plugins.smarthash.sha256FromString(accessToken); const tokenData = this.accessTokens.get(tokenHash); if (!tokenData) { return new Response(JSON.stringify({ error: 'invalid_token' }), { status: 401, headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer error="invalid_token"', }, }); } if (tokenData.expiresAt < Date.now()) { this.accessTokens.delete(tokenHash); return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), { status: 401, headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer error="invalid_token", error_description="Token expired"', }, }); } // Get user claims based on token scopes const userInfo = await this.getUserClaims(tokenData.userId, tokenData.scopes); return new Response(JSON.stringify(userInfo), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } /** * Get user claims based on scopes */ private async getUserClaims( userId: string, scopes: plugins.idpInterfaces.data.TOidcScope[] ): Promise { const user = await this.receptionRef.userManager.CUser.getInstance({ id: userId }); if (!user) { return { sub: userId }; } const claims: plugins.idpInterfaces.data.IUserInfoResponse = { sub: userId, }; // Profile scope if (scopes.includes('profile')) { claims.name = user.data?.name; claims.preferred_username = user.data?.username; // claims.picture = user.data?.avatarUrl; // If avatar exists } // Email scope if (scopes.includes('email')) { claims.email = user.data?.email; claims.email_verified = user.data?.status === 'active'; } // Organizations scope (custom) if (scopes.includes('organizations')) { const organizations = await this.receptionRef.organizationmanager.getAllOrganizationsForUser(user); const roles = await this.receptionRef.roleManager.getAllRolesForUser(user); if (organizations) { claims.organizations = organizations.map(org => ({ id: org.id, name: org.data?.name || '', slug: org.data?.slug || '', roles: roles .find(r => r.data?.organizationId === org.id)?.data?.roles || [], })); } } // Roles scope (custom - global roles) if (scopes.includes('roles')) { const roles: string[] = ['user']; if (user.data?.isGlobalAdmin) { roles.push('admin'); } claims.roles = roles; } return claims; } /** * Handle the revocation endpoint */ public async handleRevoke(ctx: plugins.typedserver.IRequestContext): Promise { const formData = await ctx.formData(); const token = formData.get('token') as string; const tokenTypeHint = formData.get('token_type_hint') as string; if (!token) { return new Response(null, { status: 200 }); // Spec says always return 200 } const tokenHash = await plugins.smarthash.sha256FromString(token); // Try to revoke as refresh token if (!tokenTypeHint || tokenTypeHint === 'refresh_token') { const refreshToken = this.refreshTokens.get(tokenHash); if (refreshToken) { refreshToken.revoked = true; return new Response(null, { status: 200 }); } } // Try to revoke as access token if (!tokenTypeHint || tokenTypeHint === 'access_token') { if (this.accessTokens.has(tokenHash)) { this.accessTokens.delete(tokenHash); return new Response(null, { status: 200 }); } } // Token not found - still return 200 per spec return new Response(null, { status: 200 }); } /** * Find an app by its OAuth client_id */ private async findAppByClientId(clientId: string): Promise { const apps = await this.receptionRef.appManager.CApp.getInstances({ 'data.oauthCredentials.clientId': clientId, }); return apps[0] || null; } /** * Generate S256 PKCE challenge from verifier */ private generateS256Challenge(verifier: string): string { const hash = plugins.smarthash.sha256FromStringSync(verifier); return Buffer.from(hash, 'hex').toString('base64url'); } /** * Create an error response for authorization endpoint */ private errorResponse(error: string, description: string): Response { return new Response(JSON.stringify({ error, error_description: description }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } /** * Create an error response for token endpoint */ private tokenErrorResponse( error: plugins.idpInterfaces.data.ITokenErrorResponse['error'], description: string ): Response { const body: plugins.idpInterfaces.data.ITokenErrorResponse = { error, error_description: description, }; return new Response(JSON.stringify(body), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } /** * Start cleanup task for expired tokens/codes */ private startCleanupTask(): void { setInterval(() => { const now = Date.now(); // Clean up expired authorization codes for (const [code, data] of this.authorizationCodes) { if (data.expiresAt < now) { this.authorizationCodes.delete(code); } } // Clean up expired access tokens for (const [hash, data] of this.accessTokens) { if (data.expiresAt < now) { this.accessTokens.delete(hash); } } // Clean up expired refresh tokens for (const [hash, data] of this.refreshTokens) { if (data.expiresAt < now) { this.refreshTokens.delete(hash); } } }, 60 * 1000); // Run every minute } }