import * as plugins from '../plugins.js'; import type { Reception } from './classes.reception.js'; import type { App } from './classes.app.js'; import { OidcAccessToken } from './classes.oidcaccesstoken.js'; import { OidcAuthorizationCode } from './classes.oidcauthorizationcode.js'; import { OidcRefreshToken } from './classes.oidcrefreshtoken.js'; import { OidcUserConsent } from './classes.oidcuserconsent.js'; /** * OidcManager handles OpenID Connect (OIDC) server functionality * for third-party client authentication. */ export class OidcManager { private readonly abuseProtectionConfig = { oidcTokenExchange: { maxAttempts: 10, windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }), blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), }, }; public receptionRef: Reception; public get db() { return this.receptionRef.db.smartdataDb; } public typedRouter = new plugins.typedrequest.TypedRouter(); public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc( this, OidcAuthorizationCode ); public COidcAccessToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcAccessToken); public COidcRefreshToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcRefreshToken); public COidcUserConsent = plugins.smartdata.setDefaultManagerForDoc(this, OidcUserConsent); private cleanupInterval: ReturnType | null = null; constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; this.receptionRef.typedrouter.addTypedRouter(this.typedRouter); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'prepareOidcAuthorization', async (requestArg) => { const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt); if (!jwt) { throw new plugins.typedrequest.TypedResponseError('Invalid JWT'); } return this.prepareAuthorizationForUser(jwt.data.userId, requestArg); } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'completeOidcAuthorization', async (requestArg) => { const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt); if (!jwt) { throw new plugins.typedrequest.TypedResponseError('Invalid JWT'); } return this.completeAuthorizationForUser(jwt.data.userId, requestArg); } ) ); this.startCleanupTask(); } public async stop() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } } /** * 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'); } if (prompt && !this.isSupportedPrompt(prompt)) { return this.errorResponse('invalid_request', 'Unsupported prompt value'); } // 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); } if (prompt) { loginUrl.searchParams.set('prompt', prompt); } 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 = this.createOpaqueToken(); const authCode = new OidcAuthorizationCode(); authCode.id = plugins.smartunique.shortId(12); authCode.data = { codeHash: OidcAuthorizationCode.hashCode(code), clientId, userId, scopes, redirectUri, codeChallenge, codeChallengeMethod: codeChallenge ? 'S256' : undefined, nonce, expiresAt: Date.now() + 10 * 60 * 1000, issuedAt: Date.now(), used: false, }; await authCode.save(); return code; } public async prepareAuthorizationForUser( userIdArg: string, requestArg: Omit ): Promise { const resolvedRequest = await this.resolveAuthorizationRequest(requestArg); const consentState = await this.evaluateConsentRequirement( userIdArg, resolvedRequest.clientId, resolvedRequest.validScopes, resolvedRequest.prompt ); return { status: consentState.consentRequired ? ('consent_required' as const) : ('ready' as const), clientId: resolvedRequest.clientId, appName: resolvedRequest.app.data.name, appUrl: resolvedRequest.app.data.appUrl, logoUrl: resolvedRequest.app.data.logoUrl, requestedScopes: resolvedRequest.validScopes, grantedScopes: consentState.grantedScopes, }; } public async completeAuthorizationForUser( userIdArg: string, requestArg: Omit ) { const resolvedRequest = await this.resolveAuthorizationRequest(requestArg); const consentState = await this.evaluateConsentRequirement( userIdArg, resolvedRequest.clientId, resolvedRequest.validScopes, resolvedRequest.prompt ); if (consentState.consentRequired && !requestArg.consentApproved) { throw new Error('Consent required'); } if (requestArg.consentApproved) { await this.upsertUserConsent(userIdArg, resolvedRequest.clientId, resolvedRequest.validScopes); } const code = await this.generateAuthorizationCode( resolvedRequest.clientId, userIdArg, resolvedRequest.validScopes, resolvedRequest.redirectUri, resolvedRequest.codeChallenge, resolvedRequest.nonce ); const redirectUrl = new URL(resolvedRequest.redirectUri); redirectUrl.searchParams.set('code', code); redirectUrl.searchParams.set('state', resolvedRequest.state); return { code, redirectUrl: redirectUrl.toString(), }; } /** * 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'); } await this.receptionRef.abuseProtectionManager.consumeAttempt( 'oidcTokenExchange', clientId, this.abuseProtectionConfig.oidcTokenExchange, 'Too many token endpoint attempts. Please wait before retrying.' ); // 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'); } } let response: Response; if (grantType === 'authorization_code') { response = await this.handleAuthorizationCodeGrant(formData, app); } else if (grantType === 'refresh_token') { response = await this.handleRefreshTokenGrant(formData, app); } else { response = this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type'); } if (response.status === 200) { await this.receptionRef.abuseProtectionManager.clearAttempts('oidcTokenExchange', clientId); } return response; } /** * 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 = await this.getAuthorizationCodeByCode(code); if (!authCode) { return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code'); } if (authCode.data.used) { return this.tokenErrorResponse('invalid_grant', 'Authorization code already used'); } if (authCode.isExpired()) { await authCode.delete(); return this.tokenErrorResponse('invalid_grant', 'Authorization code expired'); } if (authCode.data.clientId !== app.data.oauthCredentials.clientId) { return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch'); } if (authCode.data.redirectUri !== redirectUri) { return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch'); } // Verify PKCE if code challenge was used if (authCode.data.codeChallenge) { if (!codeVerifier) { return this.tokenErrorResponse('invalid_grant', 'Code verifier required'); } const expectedChallenge = this.generateS256Challenge(codeVerifier); if (expectedChallenge !== authCode.data.codeChallenge) { return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier'); } } // Mark code as used await authCode.markUsed(); // Generate tokens const tokens = await this.generateTokens( authCode.data.userId, app.data.oauthCredentials.clientId, authCode.data.scopes, authCode.data.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 storedToken = await this.getRefreshTokenByToken(refreshToken); if (!storedToken) { return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token'); } if (storedToken.data.revoked) { return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked'); } if (storedToken.isExpired()) { await storedToken.delete(); return this.tokenErrorResponse('invalid_grant', 'Refresh token expired'); } if (storedToken.data.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.data.userId, storedToken.data.clientId, storedToken.data.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 = this.createOpaqueToken(); const accessTokenData = new OidcAccessToken(); accessTokenData.id = plugins.smartunique.shortId(12); accessTokenData.data = { tokenHash: OidcAccessToken.hashToken(accessToken), clientId, userId, scopes, expiresAt: now + accessTokenLifetime * 1000, issuedAt: now, }; await accessTokenData.save(); // 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 = this.createOpaqueToken(48); const refreshTokenData = new OidcRefreshToken(); refreshTokenData.id = plugins.smartunique.shortId(12); refreshTokenData.data = { tokenHash: OidcRefreshToken.hashToken(refreshToken), clientId, userId, scopes, expiresAt: now + refreshTokenLifetime * 1000, issuedAt: now, revoked: false, }; await refreshTokenData.save(); 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 tokenData = await this.getAccessTokenByToken(accessToken); 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.isExpired()) { await tokenData.delete(); 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.data.userId, tokenData.data.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 } // Try to revoke as refresh token if (!tokenTypeHint || tokenTypeHint === 'refresh_token') { const refreshToken = await this.getRefreshTokenByToken(token); if (refreshToken) { await refreshToken.revoke(); return new Response(null, { status: 200 }); } } // Try to revoke as access token if (!tokenTypeHint || tokenTypeHint === 'access_token') { const accessToken = await this.getAccessTokenByToken(token); if (accessToken) { await accessToken.delete(); 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; } private isSupportedPrompt(promptArg: string): promptArg is 'none' | 'login' | 'consent' { return ['none', 'login', 'consent'].includes(promptArg); } private async resolveAuthorizationRequest( requestArg: Pick< plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'], 'clientId' | 'redirectUri' | 'scope' | 'state' | 'prompt' | 'codeChallenge' | 'codeChallengeMethod' | 'nonce' > ) { if (!requestArg.clientId || !requestArg.redirectUri || !requestArg.scope || !requestArg.state) { throw new Error('Missing required OAuth authorization parameters'); } if (requestArg.prompt && !this.isSupportedPrompt(requestArg.prompt)) { throw new Error('Unsupported prompt value'); } if (requestArg.codeChallenge && requestArg.codeChallengeMethod !== 'S256') { throw new Error('Only S256 code challenge method is supported'); } const app = await this.findAppByClientId(requestArg.clientId); if (!app) { throw new Error('Unknown client_id'); } if (!app.data.oauthCredentials.redirectUris.includes(requestArg.redirectUri)) { throw new Error('Invalid redirect_uri'); } const requestedScopes = requestArg.scope .split(' ') .filter(Boolean) as plugins.idpInterfaces.data.TOidcScope[]; const allowedScopes = app.data.oauthCredentials.allowedScopes as plugins.idpInterfaces.data.TOidcScope[]; const validScopes = requestedScopes.filter((scopeArg) => allowedScopes.includes(scopeArg)); if (!validScopes.includes('openid')) { throw new Error('openid scope is required'); } return { app, clientId: requestArg.clientId, redirectUri: requestArg.redirectUri, state: requestArg.state, prompt: requestArg.prompt, codeChallenge: requestArg.codeChallenge, codeChallengeMethod: requestArg.codeChallengeMethod, nonce: requestArg.nonce, validScopes, }; } private async evaluateConsentRequirement( userIdArg: string, clientIdArg: string, scopesArg: plugins.idpInterfaces.data.TOidcScope[], promptArg?: 'none' | 'login' | 'consent' ) { const existingConsent = await this.getUserConsent(userIdArg, clientIdArg); const grantedScopes = existingConsent?.data.scopes || []; const missingScopes = scopesArg.filter((scopeArg) => !grantedScopes.includes(scopeArg)); return { grantedScopes, missingScopes, consentRequired: promptArg === 'consent' || missingScopes.length > 0, }; } private createOpaqueToken(byteLength = 32): string { return plugins.crypto.randomBytes(byteLength).toString('base64url'); } private async getAuthorizationCodeByCode(codeArg: string) { return this.COidcAuthorizationCode.getInstance({ 'data.codeHash': OidcAuthorizationCode.hashCode(codeArg), }); } private async getAccessTokenByToken(tokenArg: string) { return this.COidcAccessToken.getInstance({ 'data.tokenHash': OidcAccessToken.hashToken(tokenArg), }); } private async getRefreshTokenByToken(tokenArg: string) { return this.COidcRefreshToken.getInstance({ 'data.tokenHash': OidcRefreshToken.hashToken(tokenArg), }); } public async getUserConsent(userIdArg: string, clientIdArg: string) { return this.COidcUserConsent.getInstance({ 'data.userId': userIdArg, 'data.clientId': clientIdArg, }); } public async upsertUserConsent( userIdArg: string, clientIdArg: string, scopesArg: plugins.idpInterfaces.data.TOidcScope[] ) { let userConsent = await this.getUserConsent(userIdArg, clientIdArg); if (!userConsent) { userConsent = new OidcUserConsent(); userConsent.id = plugins.smartunique.shortId(12); userConsent.data.userId = userIdArg; userConsent.data.clientId = clientIdArg; } await userConsent.grantScopes(scopesArg); return userConsent; } /** * 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 { this.cleanupInterval = setInterval(() => { void this.cleanupExpiredOidcState(); }, 60 * 1000); } private async cleanupExpiredOidcState() { const now = Date.now(); const expiredAuthorizationCodes = await this.COidcAuthorizationCode.getInstances({ data: { expiresAt: { $lt: now, } as any, }, }); for (const authCode of expiredAuthorizationCodes) { await authCode.delete(); } const expiredAccessTokens = await this.COidcAccessToken.getInstances({ data: { expiresAt: { $lt: now, } as any, }, }); for (const accessToken of expiredAccessTokens) { await accessToken.delete(); } const expiredRefreshTokens = await this.COidcRefreshToken.getInstances({ data: { expiresAt: { $lt: now, } as any, }, }); for (const refreshToken of expiredRefreshTokens) { await refreshToken.delete(); } } }