UNPKG

alepha

Version:

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

477 lines (412 loc) 13.8 kB
import { $inject, Alepha, AlephaError, createPrimitive, KIND, Primitive, } from "alepha"; import { DateTimeProvider, type Duration, type DurationLike, } from "alepha/datetime"; import { $logger } from "alepha/logger"; import type { ServerRequest } from "alepha/server"; import type { JSONWebKeySet, JWTPayload } from "jose"; import { currentTenantAtom } from "../atoms/currentTenantAtom.ts"; import { SecurityError } from "../errors/SecurityError.ts"; import type { IssuerResolver } from "../interfaces/IssuerResolver.ts"; import { JwtProvider, type SigningConfig } from "../providers/JwtProvider.ts"; import { SecurityProvider } from "../providers/SecurityProvider.ts"; import type { Role } from "../schemas/roleSchema.ts"; import type { UserAccount } from "../schemas/userAccountInfoSchema.ts"; /** * Create a new issuer. * * An issuer is responsible for creating and verifying JWT tokens. * It can be internal (with a secret) or external (with a JWKS). */ export const $issuer = (options: IssuerPrimitiveOptions): IssuerPrimitive => { return createPrimitive(IssuerPrimitive, options); }; // --------------------------------------------------------------------------------------------------------------------- export type IssuerPrimitiveOptions = { /** * Define the issuer name. * If not provided, it will use the property key. */ name?: string; /** * Short description about the issuer. */ description?: string; /** * All roles available in the issuer. Role is a string (role name) or a Role object (embedded role). */ roles?: Array<string | Role>; /** * Issuer settings. */ settings?: IssuerSettings; /** * Parse the JWT payload to create a user account info. */ profile?: (jwtPayload: Record<string, any>) => UserAccount; /** * Custom resolvers (in addition to default JWT resolver). */ resolvers?: IssuerResolver[]; /** * Asymmetric signing config. When set, this issuer's tokens are signed with * the given alg + kid and its public keys are published via JWKS. When * omitted, the HS256 `secret` path is used (default, backward compatible). */ signing?: SigningConfig; } & (IssuerInternal | IssuerExternal); export interface IssuerSettings { accessToken?: { /** * Lifetime of the access token. * @default 15 minutes */ expiration?: DurationLike; }; refreshToken?: { /** * Lifetime of the refresh token. * @default 30 days */ expiration?: DurationLike; /** * Idle invalidation is enforced by session-backed realms via * `realmAuthSettings.refreshToken.expirationIdle` (in `alepha/api/users`). * Token-only refresh (no `onRefreshSession`) does not support idle * invalidation — it has no stateful row to track `lastUsedAt`. */ }; onCreateSession?: ( user: UserAccount, config: { expiresIn: number; /** * OAuth client id, when the session is being created for an OAuth * 2.1 authorization-code grant. Lets the session store record which * MCP client / app the session belongs to. */ clientId?: string; }, ) => Promise<{ refreshToken: string; sessionId?: string; }>; onRefreshSession?: (refreshToken: string) => Promise<{ user: UserAccount; expiresIn: number; sessionId?: string; }>; onDeleteSession?: (refreshToken: string) => Promise<void>; } export type IssuerInternal = { /** * Internal secret to sign JWT tokens and verify them. */ secret: string; }; export interface IssuerExternal { /** * URL to the JWKS (JSON Web Key Set) to verify JWT tokens from external providers. */ jwks: (() => string) | JSONWebKeySet; } // --------------------------------------------------------------------------------------------------------------------- export class IssuerPrimitive extends Primitive<IssuerPrimitiveOptions> { protected readonly alepha = $inject(Alepha); protected readonly securityProvider = $inject(SecurityProvider); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected readonly jwt = $inject(JwtProvider); protected readonly log = $logger(); public get name(): string { return this.options.name || this.config.propertyKey; } public get accessTokenExpiration(): Duration { return this.dateTimeProvider.duration( this.options.settings?.accessToken?.expiration ?? [15, "minutes"], ); } public get refreshTokenExpiration(): Duration { return this.dateTimeProvider.duration( this.options.settings?.refreshToken?.expiration ?? [30, "days"], ); } protected onInit() { const roles = this.options.roles?.map((it) => { if (typeof it === "string") { const role = this.getRoles().find((role) => role.name === it); if (!role) { throw new SecurityError(`Role '${it}' not found`); } return role; } return it; }) ?? []; this.securityProvider.createRealm({ name: this.name, profile: this.options.profile, secret: "jwks" in this.options ? this.options.jwks : this.options.secret, signing: this.options.signing, roles, resolvers: [], }); // Register custom resolvers first (they usually have lower priority) for (const resolver of this.options.resolvers ?? []) { this.registerResolver(resolver); } // Register default JWT resolver (priority 100) this.registerResolver(this.createJwtResolver()); } /** * Creates the default JWT resolver. */ protected createJwtResolver(): IssuerResolver { return { priority: 100, onRequest: async (req: ServerRequest) => { const auth = req.headers.authorization; if (!auth?.startsWith("Bearer ")) { return null; } const token = auth.slice(7); // Check if it looks like a JWT (has dots) if (!token.includes(".")) { return null; } // Parse and validate JWT const { result } = await this.jwt.parse(token, this.name); // Extract user info from JWT payload return this.securityProvider.createUserFromPayload( result.payload, this.name, ); }, }; } /** * Register a resolver to this issuer. * Resolvers are sorted by priority (lower = first). */ public registerResolver(resolver: IssuerResolver): void { this.securityProvider.registerResolver(resolver, this.name); } /** * Get all roles in the issuer. */ public getRoles(): Role[] { return this.securityProvider.getRoles(this.name); } /** * Set all roles in the issuer. */ public async setRoles(roles: Role[]): Promise<void> { await this.securityProvider.updateRealm(this.name, roles); } /** * Get a role by name, throws an error if not found. */ public getRoleByName(name: string): Role { const role = this.getRoles().find((it) => it.name === name); if (!role) { throw new SecurityError(`Role '${name}' not found`); } return role; } public async parseToken(token: string): Promise<JWTPayload> { const { result } = await this.jwt.parse(token, this.name); return result.payload; } /** * Create a token for the subject. */ public async createToken( user: UserAccount, refreshToken?: { sid?: string; refresh_token?: string; refresh_token_expires_in?: number; }, context?: { /** * OAuth client id to tag a freshly created session with. Only used * on the `onCreateSession` path (no `refreshToken` passed). */ clientId?: string; }, ): Promise<AccessTokenResponse> { let sid: string | undefined = refreshToken?.sid; let refresh_token: string | undefined = refreshToken?.refresh_token; let refresh_token_expires_in: number | undefined = refreshToken?.refresh_token_expires_in; const iat = this.dateTimeProvider.now().unix(); const exp = iat + this.accessTokenExpiration.asSeconds(); if (!refreshToken) { const create = this.options.settings?.onCreateSession; if (create) { // ----------------------------------------------------------------------------------------------------------------- // managed by the application const expiresIn = this.refreshTokenExpiration.asSeconds(); const { refreshToken, sessionId } = await create(user, { expiresIn, clientId: context?.clientId, }); refresh_token = refreshToken; refresh_token_expires_in = expiresIn; sid = sessionId; } else { // ----------------------------------------------------------------------------------------------------------------- // token based const payload = { sub: user.id, exp: iat + this.refreshTokenExpiration.asSeconds(), iat, aud: this.name, }; this.log.trace("Creating refresh token", payload); sid = crypto.randomUUID(); refresh_token_expires_in = this.refreshTokenExpiration.asSeconds(); refresh_token = await this.jwt.create(payload, this.name, { header: { typ: "refresh", }, }); } } this.log.trace("Creating access token", { sub: user.id, exp, iat, aud: this.name, }); // Bind the token to the tenant the request is acting in. The default JWT // resolver compares this claim against `currentTenantAtom` on every // request, so a token minted on `b14.club.alepha.dev` is rejected on // `viska.club.alepha.dev` even when the user belongs to both. const tenant = this.alepha.store.get(currentTenantAtom)?.id; // Resilient display name: compose from first/last when the caller didn't // provide one (credentials users register with first+last but no `name`), // and carry the OIDC given/family claims so consumers can re-derive it — // otherwise such users surface as "Anonymous User". const composedName = user.name ?? ([user.firstName, user.lastName] .filter((s): s is string => !!s?.trim()) .join(" ") .trim() || undefined); const access_token = await this.jwt.create( { // jwt sub: user.id, exp, iat, aud: this.name, sid, // session id, if available // oidc name: composedName, given_name: user.firstName, family_name: user.lastName, email: user.email, preferred_username: user.username, picture: user.picture, // our claims organization: user.organization, roles: user.roles, tenant, }, this.name, ); const response: AccessTokenResponse = { access_token, token_type: "Bearer", expires_in: this.accessTokenExpiration.asSeconds(), issued_at: iat, refresh_token, refresh_token_expires_in, }; return response; } public async refreshToken( refreshToken: string, accessToken?: string, ): Promise<{ tokens: AccessTokenResponse; user: UserAccount; }> { // ----------------------------------------------------------------------------------------------------------------- // session based if (this.options.settings?.onRefreshSession) { // get user and expiration from the session const { user, expiresIn, sessionId } = await this.options.settings.onRefreshSession(refreshToken); // then, create a new access token const tokens = await this.createToken(user, { sid: sessionId, refresh_token: refreshToken, refresh_token_expires_in: expiresIn, }); return { user, tokens }; } // ----------------------------------------------------------------------------------------------------------------- // token based if (!accessToken) { throw new AlephaError("An access token is required for refreshing"); } // Extract user from an expired token. // WARNING: Roles from the expired token are reused without re-validation. // If roles were revoked, the new access token will still contain old roles // until the refresh token expires. Use session-based refresh (onRefreshSession) // to re-fetch current roles from the database on each refresh. const user = await this.securityProvider.createUserFromToken(accessToken, { realm: this.name, verify: { currentDate: new Date(0), // don't verify expiration, it's expected to be expired... }, }); // check if the refresh token is valid + match access token user const { result: { payload }, } = await this.jwt.parse(refreshToken, this.name, { typ: "refresh", audience: this.name, subject: user.id, }); const iat = this.dateTimeProvider.now().unix(); const expiresIn = payload.exp ? payload.exp - iat : this.refreshTokenExpiration.asSeconds(); return { user, tokens: await this.createToken(user, { sid: payload.sid, refresh_token: refreshToken, refresh_token_expires_in: expiresIn, }), }; } } $issuer[KIND] = IssuerPrimitive; // --------------------------------------------------------------------------------------------------------------------- export interface CreateTokenOptions { sub: string; roles?: string[]; email?: string; } export interface AccessTokenResponse { access_token: string; token_type: string; expires_in?: number; issued_at: number; refresh_token?: string; refresh_token_expires_in?: number; scope?: string; }