feat(oidc): feat(oidc): add OIDC provider (OidcManager, endpoints, and interfaces)

This commit is contained in:
2025-12-15 19:45:57 +00:00
parent 02c700e44d
commit a24b0d8be7
8 changed files with 1006 additions and 3 deletions
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@idp.global/idp.global',
version: '1.12.1',
version: '1.13.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.'
}
+42 -1
View File
@@ -4,6 +4,10 @@ import { Reception } from './reception/classes.reception.js';
export const runCli = async () => {
const serviceQenv = new plugins.qenv.Qenv('./', './.nogit', false);
// Create reception first so we can reference it in routes
let reception: Reception;
const websiteServer = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
feedMetadata: null,
domain: 'idp.global',
@@ -22,11 +26,48 @@ export const runCli = async () => {
addCustomRoutes: async (typedserver) => {
// Enable SPA fallback - serves index.html for non-file routes (e.g., /login, /dashboard)
typedserver.options.spaFallback = true;
// OIDC Discovery endpoint
typedserver.addRoute('/.well-known/openid-configuration', 'GET', async (req) => {
return new Response(JSON.stringify(reception.oidcManager.getDiscoveryDocument()), {
headers: { 'Content-Type': 'application/json' },
});
});
// JWKS endpoint
typedserver.addRoute('/.well-known/jwks.json', 'GET', async (req) => {
return new Response(JSON.stringify(reception.oidcManager.getJwks()), {
headers: { 'Content-Type': 'application/json' },
});
});
// OAuth Authorization endpoint
typedserver.addRoute('/oauth/authorize', 'GET', async (req) => {
return reception.oidcManager.handleAuthorize(req);
});
// OAuth Token endpoint
typedserver.addRoute('/oauth/token', 'POST', async (req) => {
return reception.oidcManager.handleToken(req);
});
// OAuth UserInfo endpoint (GET and POST)
typedserver.addRoute('/oauth/userinfo', 'GET', async (req) => {
return reception.oidcManager.handleUserInfo(req);
});
typedserver.addRoute('/oauth/userinfo', 'POST', async (req) => {
return reception.oidcManager.handleUserInfo(req);
});
// OAuth Revocation endpoint
typedserver.addRoute('/oauth/revoke', 'POST', async (req) => {
return reception.oidcManager.handleRevoke(req);
});
},
});
// lets add the reception routes
const reception = new Reception({
reception = new Reception({
name: (await serviceQenv.getEnvVarOnDemand('INSTANCE_NAME')) || 'idp.global',
mongoDescriptor: {
mongoDbUrl: await serviceQenv.getEnvVarOnDemand('MONGODB_URL'),
+684
View File
@@ -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
}
}
+2
View File
@@ -17,6 +17,7 @@ import { AppManager } from './classes.appmanager.js';
import { AppConnectionManager } from './classes.appconnectionmanager.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
import { UserInvitationManager } from './classes.userinvitationmanager.js';
import { OidcManager } from './classes.oidcmanager.js';
export interface IReceptionOptions {
/**
@@ -49,6 +50,7 @@ export class Reception {
public appConnectionManager = new AppConnectionManager(this);
public activityLogManager = new ActivityLogManager(this);
public userInvitationManager = new UserInvitationManager(this);
public oidcManager = new OidcManager(this);
housekeeping = new ReceptionHousekeeping(this);
constructor(public options: IReceptionOptions) {