/** * Configuration schema for @emdash-cms/auth */ import { z } from "zod"; import type { RoleName } from "./types.js"; /** Matches http(s) scheme at start of URL */ const HTTP_SCHEME_RE = /^https?:\/\//i; /** Validates that a URL string uses http or https scheme. Rejects javascript:/data: URI XSS vectors. */ const httpUrl = z .string() .url() .refine((url) => HTTP_SCHEME_RE.test(url), "URL must use http or https"); /** * OAuth provider configuration */ const oauthProviderSchema = z.object({ clientId: z.string(), clientSecret: z.string(), }); /** * Full auth configuration schema */ export const authConfigSchema = z.object({ /** * Secret key for encrypting tokens and session data. * Generate with: `emdash auth secret` */ secret: z.string().min(32, "Auth secret must be at least 32 characters"), /** * Passkey (WebAuthn) configuration */ passkeys: z .object({ /** * Relying party name shown to users during passkey registration */ rpName: z.string(), /** * Relying party ID (domain). Defaults to the hostname from baseUrl. */ rpId: z.string().optional(), }) .optional(), /** * Self-signup configuration */ selfSignup: z .object({ /** * Email domains allowed to self-register */ domains: z.array(z.string()), /** * Default role for self-registered users */ defaultRole: z.enum(["subscriber", "contributor", "author"] as const).default("contributor"), }) .optional(), /** * OAuth provider configurations (for "Login with X") */ oauth: z .object({ github: oauthProviderSchema.optional(), google: oauthProviderSchema.optional(), }) .optional(), /** * Configure EmDash as an OAuth provider */ provider: z .object({ enabled: z.boolean(), /** * Issuer URL for OIDC. Defaults to site URL. */ issuer: httpUrl.optional(), }) .optional(), /** * Enterprise SSO configuration */ sso: z .object({ enabled: z.boolean(), }) .optional(), /** * Session configuration */ session: z .object({ /** * Session max age in seconds. Default: 30 days */ maxAge: z.number().default(30 * 24 * 60 * 60), /** * Extend session on activity. Default: true */ sliding: z.boolean().default(true), }) .optional(), }); export type AuthConfig = z.infer; /** * Validated and resolved auth configuration */ export interface ResolvedAuthConfig { secret: string; baseUrl: string; siteName: string; passkeys: { rpName: string; rpId: string; origin: string; }; selfSignup?: { domains: string[]; defaultRole: RoleName; }; oauth?: { github?: { clientId: string; clientSecret: string; }; google?: { clientId: string; clientSecret: string; }; }; provider?: { enabled: boolean; issuer: string; }; sso?: { enabled: boolean; }; session: { maxAge: number; sliding: boolean; }; } const selfSignupRoleMap: Record<"subscriber" | "contributor" | "author", RoleName> = { subscriber: "SUBSCRIBER", contributor: "CONTRIBUTOR", author: "AUTHOR", }; /** * Resolve auth configuration with defaults */ export function resolveConfig( config: AuthConfig, baseUrl: string, siteName: string, ): ResolvedAuthConfig { const url = new URL(baseUrl); return { secret: config.secret, baseUrl, siteName, passkeys: { rpName: config.passkeys?.rpName ?? siteName, rpId: config.passkeys?.rpId ?? url.hostname, origin: url.origin, }, selfSignup: config.selfSignup ? { domains: config.selfSignup.domains.map((d) => d.toLowerCase()), defaultRole: selfSignupRoleMap[config.selfSignup.defaultRole], } : undefined, oauth: config.oauth, provider: config.provider ? { enabled: config.provider.enabled, issuer: config.provider.issuer ?? baseUrl, } : undefined, sso: config.sso, session: { maxAge: config.session?.maxAge ?? 30 * 24 * 60 * 60, sliding: config.session?.sliding ?? true, }, }; }