feat(oidc): feat(oidc): add OIDC provider (OidcManager, endpoints, and interfaces)
This commit is contained in:
@@ -0,0 +1,684 @@
|
||||
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<string, plugins.idpInterfaces.data.IAuthorizationCode>();
|
||||
|
||||
// In-memory store for access tokens (for validation)
|
||||
private accessTokens = new Map<string, plugins.idpInterfaces.data.IOidcAccessToken>();
|
||||
|
||||
// In-memory store for refresh tokens
|
||||
private refreshTokens = new Map<string, plugins.idpInterfaces.data.IOidcRefreshToken>();
|
||||
|
||||
// In-memory store for user consents (should be persisted later)
|
||||
private userConsents = new Map<string, plugins.idpInterfaces.data.IUserConsent>();
|
||||
|
||||
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(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const params = 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<string> {
|
||||
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(request: Request): Promise<Response> {
|
||||
// Parse form data
|
||||
const contentType = request.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 request.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 = request.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<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 = 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<Response> {
|
||||
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<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 = 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<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(request: Request): Promise<Response> {
|
||||
// Get access token from Authorization header
|
||||
const authHeader = request.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<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(request: Request): Promise<Response> {
|
||||
const formData = await request.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<App | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user