949 lines
30 KiB
TypeScript
949 lines
30 KiB
TypeScript
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<typeof setInterval> | null = null;
|
|
|
|
constructor(receptionRefArg: Reception) {
|
|
this.receptionRef = receptionRefArg;
|
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
|
|
|
this.typedRouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization>(
|
|
'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<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization>(
|
|
'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<Response> {
|
|
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<string> {
|
|
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<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['request'], 'jwt'>
|
|
): Promise<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response']> {
|
|
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<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'], 'jwt'>
|
|
) {
|
|
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<Response> {
|
|
// 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<Response> {
|
|
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<Response> {
|
|
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<plugins.idpInterfaces.data.ITokenResponse> {
|
|
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<string> {
|
|
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<Response> {
|
|
// 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<plugins.idpInterfaces.data.IUserInfoResponse> {
|
|
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<Response> {
|
|
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<App | null> {
|
|
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();
|
|
}
|
|
}
|
|
}
|