UNPKG

alepha

Version:

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

455 lines (400 loc) 13.3 kB
import { $context, AlephaError } from "alepha"; import { AlephaApiKeys, ApiKeyService } from "alepha/api/keys"; import { AlephaOAuth, OAuthClientService, oauthOptions, } from "alepha/api/oauth"; import { $parameter, AlephaApiParameters } from "alepha/api/parameters"; import { AlephaApiVerification } from "alepha/api/verifications"; import { mcpStreamableHttpOptions } from "alepha/mcp"; import type { Repository } from "alepha/orm"; import { $issuer, $permission, type IssuerPrimitive, type IssuerPrimitiveOptions, type IssuerResolver, SecurityProvider, type SigningConfig, } from "alepha/security"; import { $authApple, $authCredentials, $authFacebook, $authFranceConnect, $authGithub, $authGoogle, $authMicrosoft, type AuthPrimitive, type Credentials, type LinkAccountOptions, type WithLinkFn, type WithLoginFn, } from "alepha/server/auth"; import { type RealmAuthSettings, realmAuthSettingsAtom, } from "../atoms/realmAuthSettingsAtom.ts"; import { SessionAudits } from "../audits/SessionAudits.ts"; import { UserAudits } from "../audits/UserAudits.ts"; import { UserBuckets } from "../buckets/UserBuckets.ts"; import type { identities } from "../entities/identities.ts"; import type { sessions } from "../entities/sessions.ts"; import { DEFAULT_USER_REALM_NAME, type users } from "../entities/users.ts"; import { UserJobs } from "../jobs/UserJobs.ts"; import { UserNotifications } from "../notifications/UserNotifications.ts"; import { RealmProvider } from "../providers/RealmProvider.ts"; import { SessionService } from "../services/SessionService.ts"; export type RealmPrimitive = IssuerPrimitive & WithLinkFn & WithLoginFn; /** * Already configured realm for user management. * * Realm contains two roles: `admin` and `user`. * * - `admin`: Has full access to all resources and permissions. * - `user`: Has access to their own resources and permissions, but cannot access admin-level resources. * * Realm uses session management for handling user sessions. * * Environment Variables: * - `APP_SECRET`: Secret key for signing tokens (if not provided in options). */ export const $realm = (options: RealmOptions = {}): RealmPrimitive => { const { alepha } = $context(); const sessionService = alepha.inject(SessionService); const securityProvider = alepha.inject(SecurityProvider); const realmProvider = alepha.inject(RealmProvider); const name = options.issuer?.name ?? DEFAULT_USER_REALM_NAME; options.settings ??= {}; // Merge features with defaults const features: RealmFeatures = { jobs: false, notifications: false, apiKeys: false, oauth: false, parameters: false, avatars: false, audits: false, ...options.features, }; // When notifications are disabled, force verification-dependent settings to false // These features require sending codes via email/SMS which won't work without notifications if (!features.notifications) { options.settings.verifyEmailRequired = false; options.settings.verifyPhoneRequired = false; options.settings.resetPasswordAllowed = false; } const realmRegistration = realmProvider.register(name, options); // ------------------------------------------------------------------------------------------------------------------- // Enable features based on configuration // Each feature registers its wrapper service which internally uses the module primitives if (features.avatars) { alepha.with(UserBuckets); } if (features.audits) { alepha.with(UserAudits); alepha.with(SessionAudits); } if (features.jobs) { alepha.with(UserJobs); } if (features.notifications) { alepha.with(UserNotifications); alepha.with(AlephaApiVerification); } // ------------------------------------------------------------------------------------------------------------------- // Collect custom resolvers that will be registered during $issuer.onInit() // This ensures they are registered AFTER the realm is created (not on the default test realm) const customResolvers: IssuerResolver[] = [ ...(options.issuer?.resolvers ?? []), ]; // Enable API key authentication - must be added to customResolvers before $issuer() call if (features.apiKeys) { alepha.with(AlephaApiKeys); const apiKeyService = alepha.inject(ApiKeyService); customResolvers.push(apiKeyService.createResolver()); } const realm: RealmPrimitive = $issuer({ ...options.issuer, name, secret: options.secret ?? securityProvider.secretKey, signing: options.signing, resolvers: customResolvers, roles: options.issuer?.roles ?? [ { name: "admin", permissions: [ { name: "*", }, ], }, { name: "user", default: true, permissions: [ { name: "*", ownership: true, exclude: ["admin:*"], }, ], }, ], settings: { accessToken: { expiration: [15, "minutes"], }, refreshToken: { expiration: [30, "days"], }, onCreateSession: async (user, config) => { return sessionService.createSession( user, config.expiresIn, undefined, config.clientId, ); }, onRefreshSession: async (refreshToken) => { return sessionService.refreshSession(refreshToken); }, onDeleteSession: async (refreshToken) => { await sessionService.deleteSession(refreshToken); }, ...options.issuer?.settings, }, }); $permission({ name: "admin:access", }); // ------------------------------------------------------------------------------------------------------------------- // Enable the OAuth 2.1 authorization server. // The OAuth module is realm-agnostic: it mints access tokens through an // issuer handed to it via `registerIssuer`. Here we register the realm's own // issuer plus a loader that maps a `users` row to a `UserAccount`. if (features.oauth) { alepha.with(AlephaOAuth); const oauthService = alepha.inject(OAuthClientService); // Point the OAuth controller at this realm so its endpoints mint tokens // through the issuer we register below. Merge with the current value so a // caller-configured `resource` path is preserved. const currentOAuthOptions = alepha.get(oauthOptions) ?? oauthOptions.options.default; alepha.set(oauthOptions, { ...currentOAuthOptions, realm: name }); const loadUser = async (userId: string) => { const user = await sessionService.users(name).findOne({ where: { id: { eq: userId }, realm: name }, }); if (!user) { throw new AlephaError(`User '${userId}' not found in realm '${name}'`); } const composedName = [user.firstName, user.lastName] .filter((s): s is string => !!s?.trim()) .join(" ") .trim() || undefined; return { id: user.id, roles: user.roles, name: composedName, firstName: user.firstName, lastName: user.lastName, email: user.email, emailVerified: user.emailVerified, username: user.username, picture: user.picture, organization: user.organizationId ?? undefined, realm: name, }; }; oauthService.registerIssuer(name, realm, loadUser); // Tell the MCP Streamable HTTP transport to challenge unauthenticated // requests with an RFC 9728 401 (`WWW-Authenticate`), so MCP clients // can discover this OAuth server. Decoupled: the transport never // imports the OAuth module — it just reads its own options atom. // Harmless when no MCP transport is mounted; the atom is simply set. const currentMcpOptions = alepha.get(mcpStreamableHttpOptions) ?? mcpStreamableHttpOptions.options.default; alepha.set(mcpStreamableHttpOptions, { ...currentMcpOptions, requireAuth: true, }); } realm.link = (name: string) => { return (ctx: LinkAccountOptions) => sessionService.link(name, ctx.user, realm.name); }; realm.login = (name: string) => { return async (credentials: Credentials) => { const user = await sessionService.login( name, credentials.username, credentials.password, realm.name, ); // Compose display name from first+last for OIDC `name` claim. // Without this, credentials-registered users appear as "Anonymous User". const composedName = [user.firstName, user.lastName] .filter((s): s is string => !!s?.trim()) .join(" ") .trim() || undefined; return { ...user, name: composedName }; }; }; const identities = options.identities ?? { credentials: true, }; if (identities) { const auth: Record<string, AuthPrimitive> = {}; if (identities.credentials) { auth.credentials = $authCredentials(realm); } else { // if credentials auth is disabled, disable registration as well realmRegistration.settings.registrationAllowed = false; } if (identities.google) { auth.google = $authGoogle(realm); } if (identities.github) { auth.github = $authGithub(realm); } if (identities.apple) { auth.apple = $authApple(realm); } if (identities.facebook) { auth.facebook = $authFacebook(realm); } if (identities.microsoft) { auth.microsoft = $authMicrosoft(realm); } if (identities.franceconnect) { auth.franceconnect = $authFranceConnect(realm); } alepha.with(() => auth); } if (features.parameters) { alepha.with(AlephaApiParameters); const settingsParam = $parameter({ name: `api.realms.settings.${name}`, description: `Authentication and registration settings for realm "${name}"`, schema: realmAuthSettingsAtom.schema, default: realmRegistration.settings, }); realmRegistration.settingsParameter = settingsParam; alepha.with(() => ({ [`realmSettings_${name}`]: settingsParam })); } return realm; }; // --------------------------------------------------------------------------------------------------------------------- export interface RealmFeatures { /** * Will enable Job module. * * - Enable session purge functionality for cleaning up expired sessions. * * @default false */ jobs?: boolean; /** * Enable notification system for password reset, verification emails, etc. * * @default false */ notifications?: boolean; /** * Enable API key authentication for programmatic access. * * When enabled, users can create API keys to access protected endpoints * without using JWT tokens. API keys are useful for: * - Programmatic access (CLI tools, scripts) * - Long-lived authentication tokens * - Third-party integrations (MCP servers) * * API keys can be passed via: * - Query parameter: `?api_key=ak_xxx` * - Bearer header: `Authorization: Bearer ak_xxx` * * @default false */ apiKeys?: boolean; /** * Enable the OAuth 2.1 authorization server. * * Exposes RFC 9728 / RFC 8414 metadata, RFC 7591 dynamic client * registration, and PKCE authorize/token endpoints so MCP clients * (e.g. Claude) can connect to a protected `/mcp` endpoint without an * API key in the query string. * * @default false */ oauth?: boolean; /** * Enable runtime configuration management. * * Allows configuring realm settings at runtime with versioning and scheduled activation. * * @default false */ parameters?: boolean; /** * Enable avatar uploads for user profiles. * * @default false */ avatars?: boolean; /** * Enable audit trail for compliance and event logging. * * @default false */ audits?: boolean; } // --------------------------------------------------------------------------------------------------------------------- export interface RealmOptions { /** * Secret key for signing tokens. * * If not provided, the secret from the SecurityProvider will be used (usually from the APP_SECRET environment variable). */ secret?: string; /** * Asymmetric signing config for this realm's tokens (OIDC provider mode). * When set, issued tokens are signed asymmetrically and the public keys are * JWKS-publishable. Default is HS256 via `secret`. */ signing?: SigningConfig; /** * Issuer configuration options. * * It's already pre-configured for user management with admin and user roles. */ issuer?: Partial<IssuerPrimitiveOptions>; /** * Override entities. */ entities?: { users?: Repository<typeof users.schema>; identities?: Repository<typeof identities.schema>; sessions?: Repository<typeof sessions.schema>; }; settings?: Partial<RealmAuthSettings>; identities?: { credentials?: true; google?: true; github?: true; apple?: true; facebook?: true; microsoft?: true; franceconnect?: true; }; /** * Enable or disable realm features. * * Features control which modules are loaded with the realm. */ features?: Partial<RealmFeatures>; }