UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

525 lines (460 loc) 14.2 kB
import { $inject, AlephaError, type Async, createPrimitive, KIND, Primitive, } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { type AccessTokenResponse, type IssuerPrimitive, SecurityError, SecurityProvider, type UserAccount, } from "alepha/security"; import { allowInsecureRequests, Configuration, discovery, refreshTokenGrant, } from "openid-client"; import type { OAuth2Profile } from "../providers/ServerAuthProvider.ts"; import type { Tokens } from "../schemas/tokensSchema.ts"; /** * Creates an authentication provider primitive for handling user login flows. * * Supports multiple authentication strategies: credentials (username/password), OAuth2, * and OIDC (OpenID Connect). Handles token management, user profile retrieval, and * integration with both external identity providers (Auth0, Keycloak) and internal realms. * * **Authentication Types**: Credentials, OAuth2 (Google, GitHub), OIDC, External providers * * @example * ```ts * class AuthProviders { * // Internal credentials-based auth * credentials = $auth({ * realm: this.userRealm, * credentials: { * account: async ({ username, password }) => { * return await this.validateUser(username, password); * } * } * }); * * // External OIDC provider * keycloak = $auth({ * oidc: { * issuer: "https://auth.example.com", * clientId: "my-app", * clientSecret: "secret", * redirectUri: "/auth/callback" * } * }); * } * ``` */ export const $auth = (options: AuthPrimitiveOptions): AuthPrimitive => { return createPrimitive(AuthPrimitive, options); }; // --------------------------------------------------------------------------------------------------------------------- export type AuthPrimitiveOptions = { /** * Name of the identity provider. * If not provided, it will be derived from the property key. */ name?: string; /** * If true, auth provider will be skipped. */ disabled?: boolean; } & (AuthExternal | AuthInternal); /** * When you let an external service handle authentication. (e.g. Keycloak, Auth0, etc.) */ export type AuthExternal = { /** * Only OIDC is supported for external authentication. */ oidc: OidcOptions; /** * For anonymous access, this will expect a service account access token. * * ```ts * class App { * anonymous = $serviceAccount(...); * auth = $auth({ * // ... config ... * fallback: this.anonymous, * }) * } * ``` */ fallback?: () => Async<AccessToken>; }; /** * When using your own authentication system, e.g. using a database to store user accounts. * This is usually used with a custom login form. * * This relies on the `issuer`, which is used to create/verify the access token. */ export type AuthInternal = { issuer: IssuerPrimitive; } & ( | { /** * The common username/password authentication. * * - It uses the OAuth2 Client Credentials flow to obtain an access token. * * This is usually used with a custom login form on your website or mobile app. */ credentials: CredentialsOptions; } | { /** * OAuth2 authentication. Delegates authentication to an OAuth2 provider. (e.g. Google, GitHub, etc.) * * - It uses the OAuth2 Authorization Code flow to obtain an access token and user information. * * This is usually used with a login button that redirects to the OAuth2 provider. */ oauth: OAuth2Options; } | { /** * Like OAuth2, but uses OIDC (OpenID Connect) for authentication and user information retrieval. * OIDC is an identity layer on top of OAuth2, providing user authentication and profile information. * * - It uses the OAuth2 Authorization Code flow to obtain an access token and user information. * - PCKE (Proof Key for Code Exchange) is recommended for security. * * This is usually used with a login button that redirects to the OIDC provider. */ oidc: OidcOptions; } ); export type CredentialsOptions = { account: CredentialsFn; }; export type CredentialsFn = ( credentials: Credentials, ) => Async<UserAccount | undefined>; export interface Credentials { username: string; password: string; } export interface OidcOptions { /** * URL of the OIDC issuer. */ issuer: string; /** * Client ID for the OIDC client. */ clientId: string; /** * Client secret for the OIDC client. * Optional if PKCE (Proof Key for Code Exchange) is used. */ clientSecret?: string; /** * Redirect URI for the OIDC client. * This is where the user will be redirected after authentication. */ redirectUri?: string; /** * For external auth providers only. * Take the ID token instead of the access token for validation. */ useIdToken?: boolean; /** * URI to redirect the user after logout. */ logoutUri?: string; /** * Optional scope for the OIDC client. * @default "openid profile email". */ scope?: string; account?: LinkAccountFn; /** * OAuth2 response mode. * Apple requires "form_post" which sends the authorization code via POST body * instead of URL query parameters. */ responseMode?: "query" | "fragment" | "form_post"; /** * Additional parameters to include in the authorization URL. * Useful for provider-specific parameters. */ authorizationParameters?: Record<string, string>; } export interface LinkAccountOptions { access_token: string; user: OAuth2Profile; id_token?: string; expires_in?: number; scope?: string; } export type LinkAccountFn = (tokens: LinkAccountOptions) => Async<UserAccount>; export interface OAuth2Options { /** * URL of the OAuth2 authorization endpoint. */ clientId: string; /** * Client secret for the OAuth2 client. */ clientSecret: string; /** * URL of the OAuth2 authorization endpoint. */ authorization: string; /** * URL of the OAuth2 token endpoint. */ token: string; /** * Function to retrieve user profile information from the OAuth2 tokens. */ userinfo: (tokens: Tokens) => Async<OAuth2Profile>; account?: LinkAccountFn; /** * URL of the OAuth2 authorization endpoint. */ redirectUri?: string; /** * URL of the OAuth2 authorization endpoint. */ scope?: string; } // --------------------------------------------------------------------------------------------------------------------- export class AuthPrimitive extends Primitive<AuthPrimitiveOptions> { protected readonly securityProvider = $inject(SecurityProvider); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected oauthConfig?: Configuration; protected oauthInitializer?: () => Promise<Configuration>; public get oauth(): Configuration | undefined { return this.oauthConfig; } /** * Get the OAuth2/OIDC configuration, initializing lazily if needed (serverless mode). */ public async getOAuth(): Promise<Configuration | undefined> { if (this.oauthConfig) { return this.oauthConfig; } if (this.oauthInitializer) { this.oauthConfig = await this.oauthInitializer(); this.oauthInitializer = undefined; return this.oauthConfig; } return undefined; } public get name() { return this.options.name ?? this.config.propertyKey; } public get issuer(): IssuerPrimitive | undefined { if ("issuer" in this.options) { return this.options.issuer; } return undefined; } public get jwks_uri(): string { const jwks = this.oauth?.serverMetadata().jwks_uri; if (!jwks) { throw new AlephaError("No JWKS URI available for the auth provider"); } return jwks; } public get scope(): string | undefined { if ("oauth" in this.options) { return this.options.oauth.scope; } if ("oidc" in this.options) { return this.options.oidc.scope || "openid profile email"; } throw new AlephaError( "No OAuth2 or OIDC configuration available for the auth provider", ); } public get redirect_uri() { if ("oauth" in this.options) { return this.options.oauth.redirectUri; } if ("oidc" in this.options) { return this.options.oidc.redirectUri; } throw new AlephaError( "No OAuth2 or OIDC configuration available for the auth provider", ); } /** * Refreshes the access token using the refresh token. * Can be used on oauth2, oidc or credentials auth providers. */ public async refresh( refreshToken: string, accessToken?: string, ): Promise<AccessTokenResponse> { if ("issuer" in this.options) { return this.options.issuer .refreshToken(refreshToken, accessToken) .then((it) => it.tokens) .catch((error) => { throw new SecurityError( "Failed to refresh access token using the refresh token (issuer)", { cause: error, }, ); }); } const oauth = await this.getOAuth(); if (oauth) { try { return { ...(await refreshTokenGrant(oauth, refreshToken)), issued_at: this.dateTimeProvider.now().unix(), }; } catch (error) { throw new SecurityError( "Failed to refresh access token using the refresh token (oauth2)", { cause: error, }, ); } } throw new AlephaError( "No issuer or OAuth2 configuration available for refreshing the access token", ); } /** * Extracts user information from the access token. * This is used to create a user account from the access token. * * `externalProfile` carries extra profile fields that cannot be derived from the * ID token or userinfo endpoint — e.g. Apple's `user` form field that is only * delivered once, on first authorization. ID token / userinfo fields take * precedence; externalProfile only fills gaps. */ public async user( tokens: Tokens, externalProfile?: Record<string, unknown>, ): Promise<UserAccount> { try { if ("oauth" in this.options) { const profile = { ...externalProfile, ...(await this.options.oauth.userinfo(tokens)), } as OAuth2Profile; if (this.options.oauth.account) { return this.options.oauth.account({ ...tokens, user: profile, }); } return this.securityProvider.createUserFromPayload(profile); } if ("oidc" in this.options) { const payload = { ...externalProfile, ...this.getUserFromIdToken(tokens.id_token || ""), } as OAuth2Profile; if (this.options.oidc.account) { return this.options.oidc.account({ ...tokens, user: payload, }); } return this.securityProvider.createUserFromPayload(payload); } } catch (error) { throw new SecurityError( "Failed to extract user from identity provider tokens", { cause: error, }, ); } throw new AlephaError( "This authentication does not support user extraction from tokens", ); } // Security note: No JWT signature verification here is intentional and safe. // The id_token is received via authorizationCodeGrant() which fetches it over a // back-channel TLS connection directly from the IdP's token endpoint. TLS authenticates // the channel. openid-client/oauth4webapi validates claims (issuer, audience, nonce, // expiry) during the grant. Per OIDC spec, cryptographic signature verification is // not required for back-channel token responses — only for implicit/hybrid flows. // See: openid-client index.d.ts enableNonRepudiationChecks() docs. protected getUserFromIdToken(idToken: string): OAuth2Profile { try { return JSON.parse( Buffer.from(idToken.split(".")[1], "base64").toString("utf8"), ) as OAuth2Profile; } catch (error) { throw new AlephaError("Failed to parse ID Token payload", { cause: error, }); } } public async prepare() { if ("oidc" in this.options) { const { oidc } = this.options; const discoverOidc = async () => { const execute: Array<(config: Configuration) => void> = []; execute.push(allowInsecureRequests); return discovery( new URL(oidc.issuer), oidc.clientId, { client_secret: oidc.clientSecret, }, undefined, { execute, }, ); }; // OIDC discovery is always lazy (resolved on first auth-flow use via the // `oauthConfiguration` getter). It must never be a hard boot dependency: // a relying party has to boot even when its IdP is briefly unreachable // (cross-service start order, deploys), and deferring also avoids the // serverless cold-start fetch. this.oauthInitializer = discoverOidc; } if ("oauth" in this.options) { const { oauth } = this.options; this.oauthConfig = new Configuration( { authorization_endpoint: oauth.authorization, token_endpoint: oauth.token, issuer: oauth.authorization, // use authorization URL as a pseudo-issuer? // we don't need all of these endpoints jwks_uri: undefined, end_session_endpoint: undefined, }, oauth.clientId, { client_secret: oauth.clientSecret, }, ); } } } $auth[KIND] = AuthPrimitive; // --------------------------------------------------------------------------------------------------------------------- export type AccessToken = string | { token: () => Async<string> }; export interface WithLinkFn { link?: (name: string) => (opts: LinkAccountOptions) => Async<UserAccount>; } export interface WithLoginFn { login?: ( provider: string, ) => (creds: Credentials) => Async<UserAccount | undefined>; }