/** @ts-self="@oak/oak" */ import type { Middleware, State } from '@oak/oak'; import type { GenericOakMiddlewareErrorHandler } from './oak-middlewares.js'; import { decodeGoogleIdToken as defaultDecodeGoogleIdToken, type GoogleIdTokenPayloadType, } from '../auth/decodeGoogleIdToken.js'; import { parseEmailAddress } from '../auth/email-utils.js'; export class GoogleMiddlewareAuthError extends Error { constructor( message: string, public readonly code: string, public readonly idToken: string, ) { super(message); this.name = this.constructor.name; } toJson() { return { name: this.name, message: this.message, code: this.code, idToken: this.idToken, }; } } export class MissingAuthorizationTokenError extends GoogleMiddlewareAuthError { constructor() { super('Missing authorization token', 'MISSING_AUTHORIZATION_TOKEN', ''); } } export class EmailNotPresentError extends GoogleMiddlewareAuthError { constructor( idToken: string, public readonly idTokenPayload: GoogleIdTokenPayloadType, ) { super('Email not present in token payload', 'EMAIL_NOT_PRESENT', idToken); } override toJson() { return { ...super.toJson(), idTokenPayload: this.idTokenPayload, }; } } export class DomainNotAuthorizedError extends GoogleMiddlewareAuthError { constructor( idToken: string, public readonly idTokenPayload: GoogleIdTokenPayloadType, public readonly allowedDomains: readonly string[], ) { const domain = idTokenPayload.hd; super( `Domain not authorized: ${domain}. Allowed domains: ${allowedDomains.join(', ')}`, 'DOMAIN_NOT_AUTHORIZED', idToken, ); } override toJson() { return { ...super.toJson(), domain: this.idTokenPayload.hd, idTokenPayload: this.idTokenPayload, }; } } export class EmailNotAuthorizedError extends GoogleMiddlewareAuthError { constructor( idToken: string, public readonly idTokenPayload: GoogleIdTokenPayloadType, public readonly allowedEmails: string[], ) { const email = idTokenPayload.email; super( `Email not authorized: ${email}. Allowed emails: ${allowedEmails.join(', ')}`, 'EMAIL_NOT_AUTHORIZED', idToken, ); } override toJson() { return { ...super.toJson(), email: this.idTokenPayload.email, idTokenPayload: this.idTokenPayload, }; } } export interface BuildGoogleAuthMiddlewareOptions { /** * The allowed domains to authenticate. * If not provided, the email must end with '@karpatkey.com' by default. */ allowedDomains?: string[]; /** * The allowed emails to authenticate. * If not provided, the email must end with '@karpatkey.com' by default. */ allowedEmails?: string[]; /** * The Google client ID to use for authentication. */ googleClientId: string; /** * The error handler to use for the middleware. You must provide this function. */ errorHandler: GenericOakMiddlewareErrorHandler; /** * The function to use to decode the Google ID token. * If not provided, the default function will be used. */ decodeGoogleIdToken?: typeof defaultDecodeGoogleIdToken; } type AuthState = { /** * @deprecated Use googleUser instead. */ user: GoogleIdTokenPayloadType; /** * The Google ID token payload. */ googleUser: GoogleIdTokenPayloadType; }; /** * Returns an Oak middleware function that verifies the Google ID token. * You can configure it to only allow certain emails via the `allowedEmails` option. * * If `allowedEmails` is provided, the authenticated email must exactly match one of the strings. * Otherwise, the email must end with '@karpatkey.com' by default. * * When `requireGoogleAuth` is disabled, the middleware will simply call next(). */ export function buildGoogleAuthMiddleware(options: BuildGoogleAuthMiddlewareOptions) { const { googleClientId, decodeGoogleIdToken } = options; const allowedDomains = createAllowedDomains({ allowedDomains: options.allowedDomains, }); const allowedEmails = createAllowedEmails({ allowedEmails: options.allowedEmails, }); if (!googleClientId || googleClientId.trim().length === 0) { throw new Error('a googleClientId is required to use the Google Auth middleware'); } const authMiddleware: Middleware = async (ctx, next) => { const authHeader = ctx.request.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { const error = new MissingAuthorizationTokenError(); return options.errorHandler({ ctx, next, error, }); } const idToken = authHeader.split(' ')[1]; const idTokenPayload = await (decodeGoogleIdToken ?? defaultDecodeGoogleIdToken)(googleClientId, idToken); if (!idTokenPayload.email) { return options.errorHandler({ ctx, next, error: new EmailNotPresentError(idToken, idTokenPayload), }); } const parsedUserEmail = parseEmailAddress(idTokenPayload.email); const isDomainAllowed = allowedDomains.length > 0 && allowedDomains.includes(parsedUserEmail.domain); const isEmailAllowed = allowedEmails.length > 0 && allowedEmails.includes(parsedUserEmail.originalValue); // If there are any authorization rules, at least one must be met. const hasAuthorizationRules = allowedDomains.length > 0 || allowedEmails.length > 0; if (hasAuthorizationRules && !isDomainAllowed && !isEmailAllowed) { // If domain rules exist and the domain doesn't match, prioritize the domain error. if (allowedDomains.length > 0 && !isDomainAllowed) { return options.errorHandler({ ctx, next, error: new DomainNotAuthorizedError(idToken, idTokenPayload, allowedDomains), }); } // Otherwise, it must be an email mismatch. return options.errorHandler({ ctx, next, error: new EmailNotAuthorizedError(idToken, idTokenPayload, allowedEmails), }); } // Add the decoded payload to the context state // Ensure ctx.state is initialized if it doesn't exist ctx.state = ctx.state || {}; ctx.state.user = idTokenPayload; // Attach the full payload ctx.state.googleUser = idTokenPayload; // Attach the full payload // Call the next middleware await next(); }; return authMiddleware; } /** * Creates a set of allowed domains from the allowedDomains and allowedEmails options. * * @param params - The parameters to create the allowed domains from. * @returns A set of allowed domains. */ function createAllowedDomains(params: { allowedDomains?: string[] }): readonly string[] { const uniqueDomains = new Set(); const allowedDomains = params.allowedDomains || []; if (allowedDomains.length > 0) { for (const domain of allowedDomains) { uniqueDomains.add(domain.toLowerCase()); } } return Array.from(uniqueDomains); } function createAllowedEmails(params: { allowedEmails?: string[] }): string[] { const emails = new Set(); const allowedEmails = params.allowedEmails || []; if (allowedEmails.length > 0) { for (const email of allowedEmails) { const parsedEmail = parseEmailAddress(email); if (parsedEmail.isValid) { emails.add(parsedEmail.originalValue.toLowerCase()); } } } return Array.from(emails); }