UNPKG

@syngrisi/syngrisi

Version:
425 lines (375 loc) 16.1 kB
/** * JWT Auth Plugin * * Provides M2M (Machine-to-Machine) authentication via JWT tokens. * Validates JWT tokens using JWKS. */ import * as jose from 'jose'; import { SyngrisiPlugin, PluginContext, AuthResult } from '../../sdk/types'; import { registerPluginSchema } from '../../../controllers/plugin-settings.controller'; interface JwtAuthConfig { /** JWKS endpoint URL for token validation */ jwksUrl: string; /** Expected JWT issuer */ issuer: string; /** Header name containing the JWT token */ headerName: string; /** Header value prefix (e.g. "Bearer ") */ headerPrefix: string; /** Role to assign to service users */ serviceUserRole: 'user' | 'reviewer' | 'admin'; /** Whether to auto-provision service users in DB */ autoProvisionUsers: boolean; /** Cache TTL for JWKS in milliseconds */ jwksCacheTtl: number; /** Optional audience(s) to validate */ audience?: string[] | string; /** Optional required scopes */ requiredScopes?: string[] | string; /** Issuer matching mode: strict (default) or host */ issuerMatch?: 'strict' | 'host'; } const DEFAULT_CONFIG: JwtAuthConfig = { jwksUrl: '', issuer: '', headerName: 'Authorization', headerPrefix: 'Bearer ', serviceUserRole: 'user', autoProvisionUsers: true, jwksCacheTtl: 3600000, // 1 hour issuerMatch: 'strict', }; const SETTINGS_SCHEMA = [ { key: 'jwksUrl', label: 'JWKS URL', description: 'URL to the JWKS (JSON Web Key Set) endpoint for token validation', type: 'string' as const, envVariable: 'SYNGRISI_PLUGIN_JWT_AUTH_JWKS_URL', required: true, }, { key: 'issuer', label: 'JWT Issuer', description: 'Expected issuer claim in the JWT token', type: 'string' as const, envVariable: 'SYNGRISI_PLUGIN_JWT_AUTH_ISSUER', required: true, }, { key: 'headerName', label: 'Header Name', description: 'HTTP header name containing the token (e.g. Authorization)', type: 'string' as const, defaultValue: 'Authorization', envVariable: 'SYNGRISI_PLUGIN_JWT_AUTH_HEADER_NAME', }, { key: 'headerPrefix', label: 'Header Prefix', description: 'Prefix to strip from header value (e.g. "Bearer "). Leave empty if none.', type: 'string' as const, defaultValue: 'Bearer ', envVariable: 'SYNGRISI_PLUGIN_JWT_AUTH_HEADER_PREFIX', }, { key: 'serviceUserRole', label: 'Service User Role', description: 'Role to assign to auto-provisioned service users', type: 'select' as const, defaultValue: 'user', envVariable: 'SYNGRISI_PLUGIN_JWT_AUTH_SERVICE_USER_ROLE', options: [ { value: 'user', label: 'User' }, { value: 'reviewer', label: 'Reviewer' }, { value: 'admin', label: 'Admin' }, ], }, { key: 'autoProvisionUsers', label: 'Auto-provision Users', description: 'Automatically create service users in database for new clients', type: 'boolean' as const, defaultValue: true, envVariable: 'SYNGRISI_PLUGIN_JWT_AUTH_AUTO_PROVISION', }, { key: 'jwksCacheTtl', label: 'JWKS Cache TTL (ms)', description: 'Cache duration for JWKS keys in milliseconds', type: 'number' as const, defaultValue: 3600000, envVariable: 'SYNGRISI_PLUGIN_JWT_AUTH_JWKS_CACHE_TTL', }, { key: 'audience', label: 'Audience', description: 'Expected audience claim(s). Comma- or space-separated.', type: 'string' as const, envVariable: 'SYNGRISI_PLUGIN_JWT_AUTH_AUDIENCE', }, { key: 'requiredScopes', label: 'Required Scopes', description: 'Required scopes. Comma- or space-separated.', type: 'string' as const, envVariable: 'SYNGRISI_PLUGIN_JWT_AUTH_REQUIRED_SCOPES', }, { key: 'issuerMatch', label: 'Issuer Match Mode', description: 'Strict requires exact issuer match. Host allows matching by hostname.', type: 'select' as const, defaultValue: 'strict', envVariable: 'SYNGRISI_PLUGIN_JWT_AUTH_ISSUER_MATCH', options: [ { value: 'strict', label: 'Strict' }, { value: 'host', label: 'Host' }, ], }, ]; const parseList = (value?: string | string[]): string[] => { if (!value) return []; if (Array.isArray(value)) { return value.map(v => String(v).trim()).filter(Boolean); } return value .split(/[\s,]+/) .map(v => v.trim()) .filter(Boolean); }; const extractHost = (issuer: string): string | null => { try { const parsed = new URL(issuer); return parsed.host; } catch { return null; } }; /** * Create JWT Auth Plugin */ export function createJwtAuthPlugin(initialConfig: Partial<JwtAuthConfig> = {}): SyngrisiPlugin { let config: JwtAuthConfig = { ...DEFAULT_CONFIG, ...initialConfig }; let jwks: jose.JWTVerifyGetKey | null = null; let jwksInitialized = false; return { manifest: { name: 'jwt-auth', version: '1.0.0', description: 'M2M authentication via JWT (OAuth2/OIDC)', author: 'Syngrisi Team', priority: 10, // High priority - runs before standard auth }, async onLoad(context: PluginContext): Promise<void> { const logger = context.logger; const logOpts = { scope: 'jwt-auth', msgType: 'PLUGIN' }; // Merge config from plugin context (DB settings have priority) config = { ...config, ...context.pluginConfig as Partial<JwtAuthConfig> }; // Register settings schema for UI await registerPluginSchema( 'jwt-auth', 'JWT Authentication', 'M2M authentication via JWT (OAuth2 Client Credentials)', SETTINGS_SCHEMA ); // Validate required config - throw errors to prevent silent failures if (!config.jwksUrl) { const errMsg = 'Missing required configuration for "jwt-auth" plugin. ' + 'SYNGRISI_PLUGIN_JWT_AUTH_JWKS_URL is required. ' + 'Set SYNGRISI_PLUGIN_JWT_AUTH_JWKS_URL environment variable or configure via Admin UI.'; console.error(errMsg); throw new Error(errMsg); } if (!config.issuer) { const errMsg = 'Missing required configuration for "jwt-auth" plugin. ' + 'SYNGRISI_PLUGIN_JWT_AUTH_ISSUER is required. ' + 'Set SYNGRISI_PLUGIN_JWT_AUTH_ISSUER environment variable or configure via Admin UI.'; console.error(errMsg); throw new Error(errMsg); } try { new URL(config.jwksUrl); } catch { const errMsg = 'Invalid JWKS URL'; console.error(errMsg); throw new Error(errMsg); } // Initialize JWKS client try { jwks = jose.createRemoteJWKSet(new URL(config.jwksUrl), { cacheMaxAge: config.jwksCacheTtl, }); jwksInitialized = true; logger.info(`JWT Auth plugin loaded. JWKS: ${config.jwksUrl}`, logOpts); } catch (error) { const errMsg = `JWT Auth: Failed to initialize JWKS client: ${error}`; console.error(errMsg); throw new Error(errMsg); } }, async onUnload(): Promise<void> { jwks = null; jwksInitialized = false; }, hooks: { 'auth:validate': async (req, res, context): Promise<AuthResult | null> => { const logger = context.logger; const logOpts = { scope: 'jwt-auth', msgType: 'AUTH' }; // Skip if not properly initialized if (!jwksInitialized || !jwks) { return null; } // Get token from header const headerName = config.headerName.toLowerCase(); const authHeader = req.headers[headerName]; if (!authHeader || typeof authHeader !== 'string') { return null; } // Extract token (remove prefix) let token = authHeader; if (config.headerPrefix) { const prefix = config.headerPrefix; if (token.startsWith(prefix)) { token = token.slice(prefix.length); } else if (token.toLowerCase().startsWith(prefix.toLowerCase())) { token = token.slice(prefix.length); } } token = token.trim(); if (!token) { return { authenticated: false, error: 'Missing token in authorization header', }; } try { const expectedIssuers = parseList(config.issuer); const audiences = parseList(config.audience); const requiredScopes = parseList(config.requiredScopes); // Validate JWT signature and claims const verifyOptions: jose.JWTVerifyOptions = {}; if (config.issuerMatch !== 'host') { verifyOptions.issuer = expectedIssuers.length > 1 ? expectedIssuers : expectedIssuers[0]; } if (audiences.length > 0) { verifyOptions.audience = audiences.length > 1 ? audiences : audiences[0]; } const { payload } = await jose.jwtVerify(token, jwks, verifyOptions); if (config.issuerMatch === 'host') { const tokenIssuer = payload.iss as string | undefined; if (!tokenIssuer) { return { authenticated: false, error: 'Token is missing issuer claim', }; } const tokenHost = extractHost(tokenIssuer); const expectedHosts = expectedIssuers .map(issuer => extractHost(issuer) ?? issuer) .filter(Boolean); const matchesHost = expectedHosts.some(host => tokenHost ? tokenHost === host : tokenIssuer === host); if (!matchesHost) { return { authenticated: false, error: 'Token issuer does not match expected host', }; } } const clientId = (payload.sub as string | undefined) || (payload.client_id as string | undefined) || (payload.cid as string | undefined); if (!clientId) { logger.warn('JWT Auth: missing client identifier in token (sub/client_id/cid)', logOpts); return { authenticated: false, error: 'Token is missing client identifier', }; } const scp = (payload as Record<string, unknown>).scp; const scope = (payload as Record<string, unknown>).scope; let scopes: string[] = []; if (Array.isArray(scp)) { scopes = scp.map(value => String(value)); } else if (typeof scp === 'string') { scopes = scp.split(' '); } else if (Array.isArray(scope)) { scopes = scope.map(value => String(value)); } else if (typeof scope === 'string') { scopes = scope.split(' '); } scopes = scopes.map(value => value.trim()).filter(Boolean); if (requiredScopes.length > 0) { const scopeSet = new Set(scopes.map(scope => String(scope).trim()).filter(Boolean)); const missing = requiredScopes.filter(scope => !scopeSet.has(scope)); if (missing.length > 0) { return { authenticated: false, error: `Token missing required scopes: ${missing.join(', ')}`, }; } } logger.info(`JWT Auth: validated token for client ${clientId}`, logOpts); const { User } = context.models; const username = `jwt-service:${clientId}`; // Find or create service user (depending on autoProvisionUsers setting) let serviceUser = await User.findOne({ username }); if (!serviceUser) { if (config.autoProvisionUsers) { // Auto-provision new service user serviceUser = await User.create({ username, firstName: 'JWT', lastName: `Service (${clientId.substring(0, 8)}...)`, role: config.serviceUserRole, authSource: 'jwt', }); logger.info(`Created service user: ${username}`, logOpts); } else { // Auto-provision disabled - reject authentication logger.warn(`JWT Auth: User not found and auto-provision is disabled: ${username}`, logOpts); return { authenticated: false, error: `Service user not found: ${clientId}. Auto-provisioning is disabled.`, }; } } return { authenticated: true, user: serviceUser, metadata: { clientId, scopes, authMethod: 'jwt', }, }; } catch (error) { if (error instanceof jose.errors.JWTExpired) { logger.warn(`JWT Auth: token expired`, logOpts); return { authenticated: false, error: 'Token expired', }; } if (error instanceof jose.errors.JWTClaimValidationFailed) { logger.warn(`JWT Auth: claim validation failed: ${error.message}`, logOpts); return { authenticated: false, error: `Token validation failed: ${error.message}`, }; } logger.error(`JWT Auth: validation error: ${error}`, logOpts); return { authenticated: false, error: 'Token validation failed', }; } }, }, }; } // Default export for plugin loader export default createJwtAuthPlugin;