diff --git a/.env.example b/.env.example index 2e2fc0a..6d61ded 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,15 @@ DATABASE_URL="postgres://auth:auth_secret@localhost:5433/auth_service" ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=changeme123! -# CORS — allowed origins (comma-separated) +# CORS — allowed origins (comma-separated, static seed — also auto-populated from app URLs at runtime) CORS_ORIGINS=http://localhost:5173,http://localhost:3000 +# OAuth resource server audiences (RFC 8707, comma-separated). +# Seed value — also auto-populated from app.url at runtime. No restart needed +# when adding applications via the admin UI. +# Example: https://mcp-central.example.com,https://api.example.com +OAUTH_VALID_AUDIENCES= + # Email (for verification and password reset) SMTP_HOST= SMTP_PORT=587 diff --git a/docker-compose.yml b/docker-compose.yml index 6998c53..f7cbd98 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ services: ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} CORS_ORIGINS: ${CORS_ORIGINS:-} + OAUTH_VALID_AUDIENCES: ${OAUTH_VALID_AUDIENCES:-} # Branding (displayed in UI + used as TOTP issuer) APP_NAME: ${APP_NAME:-CIRCLE Auth} diff --git a/frontend/src/components/applications/ApplicationFormModal.vue b/frontend/src/components/applications/ApplicationFormModal.vue index 6f82091..cf43e38 100644 --- a/frontend/src/components/applications/ApplicationFormModal.vue +++ b/frontend/src/components/applications/ApplicationFormModal.vue @@ -27,7 +27,7 @@ const services = ref(null); const isEdit = computed(() => !!props.application); -const SCOPES = ['openid', 'profile', 'email', 'offline_access', 'roles', 'permissions', 'features', 'org']; +const SCOPES = ['openid', 'profile', 'email', 'phone', 'offline_access', 'roles', 'permissions', 'features', 'org']; const ALL_PROVIDERS = ['google', 'github', 'linkedin', 'microsoft', 'apple'] as const; type ProviderKey = (typeof ALL_PROVIDERS)[number]; diff --git a/frontend/src/views/ApplicationDetailView.vue b/frontend/src/views/ApplicationDetailView.vue index eacd4ac..1ffc08e 100644 --- a/frontend/src/views/ApplicationDetailView.vue +++ b/frontend/src/views/ApplicationDetailView.vue @@ -387,18 +387,133 @@ const tabs = [ { key: 'financial', label: t('appDetail.financial') }, ] as const; -const codeExampleJS = computed(() => { +const activeCodeTab = ref<'typescript' | 'python' | 'ioserver'>('typescript'); + +// Auth-service URL = origin of this SPA (same server) +const authServiceUrl = computed(() => window.location.origin); + +const codeExampleTS = computed(() => { + if (!app.value) return ''; + const redirectUri = app.value.redirectUris[0] ?? 'https://your-app.com/callback'; + const audience = app.value.url ?? 'https://your-app.com'; + const scope = app.value.allowedScopes.join(' '); + return `import * as oauth from 'oauth4webapi'; + +const issuer = new URL('${authServiceUrl.value}'); +const as = await oauth.discoveryRequest(issuer).then(r => oauth.processDiscoveryResponse(issuer, r)); + +const client: oauth.Client = { client_id: '${app.value.slug}' }; + +// 1. Build authorization URL (PKCE + RFC 8707 resource) +const codeVerifier = oauth.generateRandomCodeVerifier(); +const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier); + +const authUrl = new URL(as.authorization_endpoint!); +authUrl.searchParams.set('client_id', '${app.value.slug}'); +authUrl.searchParams.set('response_type', 'code'); +authUrl.searchParams.set('redirect_uri', '${redirectUri}'); +authUrl.searchParams.set('scope', '${scope}'); +authUrl.searchParams.set('resource', '${audience}'); // RFC 8707 +authUrl.searchParams.set('code_challenge', codeChallenge); +authUrl.searchParams.set('code_challenge_method', 'S256'); +authUrl.searchParams.set('state', oauth.generateRandomState()); + +window.location.href = authUrl.toString(); + +// 2. Exchange code for tokens (in your /callback handler) +const currentUrl = new URL(window.location.href); +const params = oauth.validateAuthResponse(as, client, currentUrl, expectedState); +const response = await oauth.authorizationCodeGrantRequest( + as, client, oauth.None(), params, + '${redirectUri}', codeVerifier, + { additionalParameters: new URLSearchParams({ resource: '${audience}' }) }, +); +const tokens = await oauth.processAuthorizationCodeResponse(as, client, response); +console.log(tokens.access_token);`; +}); + +const codeExamplePython = computed(() => { if (!app.value) return ''; const redirectUri = app.value.redirectUris[0] ?? 'https://your-app.com/callback'; - return `const params = new URLSearchParams({ - response_type: 'code', - client_id: '${app.value.slug}', - redirect_uri: '${redirectUri}', - scope: '${app.value.allowedScopes.join(' ')}', - code_challenge_method: 'S256', - code_challenge: await generateCodeChallenge(codeVerifier), + const audience = app.value.url ?? 'https://your-app.com'; + const scope = app.value.allowedScopes.join(' '); + return `from authlib.integrations.requests_client import OAuth2Session +import secrets, hashlib, base64 + +client_id = '${app.value.slug}' +redirect_uri = '${redirectUri}' +scope = '${scope}' +resource = '${audience}' # RFC 8707 + +# PKCE helpers +verifier = secrets.token_urlsafe(64) +challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest() +).rstrip(b'=').decode() + +client = OAuth2Session( + client_id, scope=scope, redirect_uri=redirect_uri, + code_challenge_method='S256', +) + +# 1. Redirect user to authorization URL +authorization_endpoint = '${authServiceUrl.value}/api/auth/oauth2/authorize' +uri, state = client.create_authorization_url( + authorization_endpoint, + code_verifier=verifier, + resource=resource, +) +print('Redirect to:', uri) + +# 2. Exchange code for tokens (after callback) +token_endpoint = '${authServiceUrl.value}/api/auth/oauth2/token' +tokens = client.fetch_token( + token_endpoint, + authorization_response=callback_url, + code_verifier=verifier, + resource=resource, +) +print(tokens['access_token'])`; }); -window.location.href = \`/api/auth/authorize?\${params}\`;`; + +const codeExampleIOServer = computed(() => { + if (!app.value) return ''; + const audience = app.value.url ?? 'https://your-app.com'; + return `# .env +AUTH_SERVICE_URL=${authServiceUrl.value} +AUTH_SERVICE_APP_SLUG=${app.value.slug} +AUTH_SERVICE_AUDIENCE=${audience} # JWT aud claim = resource URL + +# --- src/index.ts --- +import { + OidcConfigManager, + OidcHttpMiddleware, + OidcSocketMiddleware, +} from 'ioserver-oidc'; + +// Register the OIDC config manager (reads env vars above) +server.addManager({ name: 'oidcConfig', manager: OidcConfigManager }); + +// Protect HTTP routes +server.addController({ + name: 'api', + controller: ApiController, + middlewares: [OidcHttpMiddleware], + prefix: '/api', +}); + +// Protect Socket.IO namespaces +server.addService({ + name: 'events', + service: EventService, + middlewares: [OidcSocketMiddleware], +}); + +// Inside a controller/service handler: +// request.sub → OIDC subject (stable user ID) +// request.roles → string[] (from \'roles\' scope) +// request.permissions → string[] (from \'permissions\' scope) +// request.features → object (from \'features\' scope)`; }); const PROVIDERS = ['google', 'github', 'linkedin', 'microsoft', 'apple'] as const; @@ -652,6 +767,8 @@ const financialKpis = computed(() => {
+ +
@@ -663,6 +780,23 @@ const financialKpis = computed(() => {
+ +
+ + No application URL configured — the resource parameter (RFC 8707) is required to receive JWT access tokens. Set the URL in the application settings. +
+ +

{{ t('appDetail.redirectUris') }}

@@ -677,14 +811,65 @@ const financialKpis = computed(() => {
+
-
+

{{ t('appDetail.codeSnippet') }}

-
{{ codeExampleJS }}
+
+ +
+
{{ activeCodeTab === 'typescript' ? codeExampleTS : activeCodeTab === 'python' ? codeExamplePython : codeExampleIOServer }}
+

Uses oauth4webapinpm i oauth4webapi

+

Uses authlibpip install authlib requests

+

Uses ioserver-oidcnpm i ioserver-oidc — no secret storage needed on the app side

+
+ + +
+

Scopes & Claims

+
+ + + + + + + + + + + + + + + +
ScopeTypeClaims returned
+ {{ row.scope }} + + {{ row.type }} + {{ row.claims }}
+

* company is a proprietary claim inside the standard profile scope.

+
+

{{ t('appDetail.socialProviders') }}

diff --git a/src/auth.ts b/src/auth.ts index 829426a..8bd7548 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -9,6 +9,7 @@ import * as authSchema from "./db/auth-schema.js"; import { applications, userApplications } from "./db/schema.js"; import { and, eq } from "drizzle-orm"; import { config } from "./config.js"; +import { trustedOrigins, validAudiences } from "./runtime-config.js"; import { getUserClaims, userHasAppAccessBySlug, @@ -40,8 +41,9 @@ export const auth = betterAuth({ oauthAuthServerConfig: true, openidConfig: true, }, - // Let BetterAuth trust all configured CORS origins - trustedOrigins: config.cors.origins, + // Live mutable list — seeded from env + DB app URLs at startup, + // updated on application create/update/delete without restart. + trustedOrigins: trustedOrigins, // Enable cross-subdomain cookies when SESSION_DOMAIN is configured (e.g. "example.com") ...(config.session.domain ? { @@ -153,17 +155,17 @@ export const auth = betterAuth({ oauthProvider({ loginPage: "/login", consentPage: "/oauth2/consent", - // Valid resource server audiences for JWT access tokens (RFC 8707). - // When empty, BetterAuth defaults to the base URL as the audience. - // Clients must include `resource=` in auth/token requests to - // receive a JWT; without it they receive an opaque token instead. - ...(config.oauthProvider.validAudiences.length > 0 - ? { validAudiences: config.oauthProvider.validAudiences } - : {}), + // Live mutable list of valid resource server audiences (RFC 8707). + // Seeded from OAUTH_VALID_AUDIENCES env + all app.url values at startup. + // Updated in-place on application CRUD — no restart needed. + // @better-auth/oauth-provider spreads this into a new Set per request, + // so mutations are reflected immediately. + validAudiences: validAudiences, scopes: [ "openid", "profile", "email", + "phone", "offline_access", "roles", "permissions", @@ -284,11 +286,14 @@ export const auth = betterAuth({ } return getUserClaims(user.id, clientId, scopes, { email: (user as Record).email as string | null | undefined, + emailVerified: (user as Record).emailVerified as boolean | null | undefined, name: (user as Record).name as string | null | undefined, company: (user as Record).company as string | null | undefined, + image: (user as Record).image as string | null | undefined, + phone: (user as Record).phone as string | null | undefined, + updatedAt: (user as Record).updatedAt as Date | null | undefined, }); }, - // Inject claims into the OAuth2 access token JWT (verified by ioserver-oidc). // `customIdTokenClaims` only affects the ID token; this callback is what // puts roles/permissions/features/email/name/org_id in the Bearer JWT that // the resource server (MCP-Central, CyPlate, etc.) receives and verifies. @@ -302,8 +307,12 @@ export const auth = betterAuth({ ?.clientId as string | undefined; const claims = await getUserClaims(user.id, clientId, scopes, { email: (user as Record).email as string | null | undefined, + emailVerified: (user as Record).emailVerified as boolean | null | undefined, name: (user as Record).name as string | null | undefined, company: (user as Record).company as string | null | undefined, + image: (user as Record).image as string | null | undefined, + phone: (user as Record).phone as string | null | undefined, + updatedAt: (user as Record).updatedAt as Date | null | undefined, }); // Inject org_id when the client requested the "org" scope and a // reference (activeOrganizationId) was captured during the postLogin flow. @@ -320,8 +329,12 @@ export const auth = betterAuth({ | undefined; return getUserClaims(user.id, clientId, scopes, { email: (user as Record).email as string | null | undefined, + emailVerified: (user as Record).emailVerified as boolean | null | undefined, name: (user as Record).name as string | null | undefined, company: (user as Record).company as string | null | undefined, + image: (user as Record).image as string | null | undefined, + phone: (user as Record).phone as string | null | undefined, + updatedAt: (user as Record).updatedAt as Date | null | undefined, }); }, // When the "org" scope is requested: after login, determine whether we need diff --git a/src/index.ts b/src/index.ts index f52f251..7b897ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,11 @@ import { config } from "./config.js"; import { auth } from "./auth.js"; import { bootstrap } from "./bootstrap.js"; import { runMigrations } from "./migrate.js"; +import { + corsOrigins, + addAudience, + addCorsOrigin, +} from "./runtime-config.js"; import { healthRoutes } from "./routes/health.js"; import { applicationRoutes } from "./routes/admin/applications.js"; import { rolesRoutes } from "./routes/admin/roles.js"; @@ -31,7 +36,7 @@ import { ApiError } from "./errors.js"; import { renderAuthPage } from "./services/templates.js"; import { db } from "./db/index.js"; import { applications } from "./db/schema.js"; -import { eq } from "drizzle-orm"; +import { eq, isNotNull } from "drizzle-orm"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -45,8 +50,14 @@ const fastify = Fastify({ }); // ── CORS ────────────────────────────────────────────────────────────────────── +// corsOrigins is seeded at startup (see start()) from CORS_ORIGINS env and +// all application URLs in the DB, then kept in sync on app CRUD operations. await fastify.register(cors, { - origin: config.cors.origins, + origin: (origin, callback) => { + // Allow requests with no Origin header (server-to-server, curl, etc.) + if (!origin) return callback(null, true); + callback(null, corsOrigins.has(origin)); + }, credentials: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], }); @@ -117,11 +128,17 @@ if (existsSync(frontendDist)) { // fall through to the Vue SPA index.html when no template is found. const authPageRoutes: Array<{ path: string; - page: "login" | "register" | "verify-email"; + page: "login" | "register" | "verify-email" | "two-factor"; }> = [ { path: "/login", page: "login" }, { path: "/register", page: "register" }, { path: "/verify-email", page: "verify-email" }, + // Standalone MFA challenge page. Normally the login template drives the + // TOTP prompt inline (via the `twoFactorRedirect` response from + // /api/auth/sign-in/email), but this route is needed when the user + // reloads mid-flow, bookmarks the MFA step, or integrators prefer a + // dedicated page. + { path: "/two-factor", page: "two-factor" }, ] as const; for (const { path, page } of authPageRoutes) { @@ -311,7 +328,7 @@ const betterAuthHandler = toNodeHandler(auth); fastify.addHook("onRequest", (req, reply, done) => { if (req.url?.startsWith("/api/auth/")) { const origin = req.headers.origin; - if (origin && (config.cors.origins as string[]).includes(origin)) { + if (origin && corsOrigins.has(origin)) { reply.raw.setHeader("Access-Control-Allow-Origin", origin); reply.raw.setHeader("Access-Control-Allow-Credentials", "true"); reply.raw.setHeader( @@ -448,6 +465,23 @@ async function start(): Promise { await runMigrations(); } await bootstrap(); + + // ── Seed runtime-config from env vars (static seed, backward-compat) ───── + for (const o of config.cors.origins) addCorsOrigin(o); + for (const aud of config.oauthProvider.validAudiences) addAudience(aud); + + // ── Seed runtime-config from DB — all app URLs ───────────────────── + // Pulls every application that has a URL configured so the server starts + // with the correct audiences/origins without any env variable restart. + const appRows = await db + .select({ url: applications.url }) + .from(applications) + .where(isNotNull(applications.url)); + for (const { url } of appRows) { + addAudience(url); + addCorsOrigin(url); + } + await fastify.listen({ port: config.port, host: config.host }); fastify.log.info(`auth-service listening on ${config.host}:${config.port}`); } catch (err) { diff --git a/src/routes/admin/applications.ts b/src/routes/admin/applications.ts index 2a7e67f..5a2641e 100644 --- a/src/routes/admin/applications.ts +++ b/src/routes/admin/applications.ts @@ -22,6 +22,12 @@ import { and, eq } from "drizzle-orm"; import { ERR } from "../../errors.js"; import { auth } from "../../auth.js"; import { randomBytes, createHash } from "node:crypto"; +import { + addAudience, + removeAudience, + addCorsOrigin, + removeCorsOrigin, +} from "../../runtime-config.js"; /** Hash a plaintext client secret using SHA-256 base64url (matches BetterAuth's defaultHasher). */ function hashClientSecret(secret: string): string { @@ -251,6 +257,13 @@ export async function applicationRoutes( // Secret shown once — not persisted in plaintext. Public clients have no secret. if (rawSecret) response.clientSecret = rawSecret; + // Register the app URL as a valid OAuth audience and CORS origin immediately. + // This takes effect on the next request — no server restart required. + if (data.url) { + addAudience(data.url); + addCorsOrigin(data.url); + } + await reply.status(201).send(response); }); @@ -271,6 +284,13 @@ export async function applicationRoutes( if (!parsed.success) throw ERR.APP_001("Invalid data", parsed.error.flatten()); + // Fetch the current URL before applying the update so we can diff it. + const [before] = await db + .select({ url: applications.url }) + .from(applications) + .where(eq(applications.id, req.params.id)) + .limit(1); + const [app] = await db .update(applications) .set({ ...parsed.data, updatedAt: new Date() }) @@ -278,6 +298,20 @@ export async function applicationRoutes( .returning(); if (!app) throw ERR.APP_002(); + // Sync runtime-config when the URL field changes. + if (parsed.data.url !== undefined && parsed.data.url !== before?.url) { + // Remove old audience/origin if it was set + if (before?.url) { + removeAudience(before.url); + removeCorsOrigin(before.url); + } + // Add new audience/origin if one was provided + if (parsed.data.url) { + addAudience(parsed.data.url); + addCorsOrigin(parsed.data.url); + } + } + // Sync relevant fields to the BetterAuth oauthClient table const oauthUpdate: Partial = {}; if (parsed.data.name !== undefined) oauthUpdate.name = parsed.data.name; @@ -304,12 +338,18 @@ export async function applicationRoutes( const [deleted] = await db .delete(applications) .where(eq(applications.id, req.params.id)) - .returning({ id: applications.id, slug: applications.slug }); + .returning({ id: applications.id, slug: applications.slug, url: applications.url }); if (!deleted) throw ERR.APP_002(); // Remove the oauthClient row (cascades to tokens/consents in BetterAuth tables) await db.delete(oauthClient).where(eq(oauthClient.clientId, deleted.slug)); + // Remove the URL from runtime-config. + if (deleted.url) { + removeAudience(deleted.url); + removeCorsOrigin(deleted.url); + } + await reply.status(204).send(); }); diff --git a/src/runtime-config.ts b/src/runtime-config.ts new file mode 100644 index 0000000..89d2f17 --- /dev/null +++ b/src/runtime-config.ts @@ -0,0 +1,83 @@ +/** + * Mutable runtime state for dynamic audience and CORS management. + * + * Seeded at startup from: + * 1. OAUTH_VALID_AUDIENCES / CORS_ORIGINS env vars (backward compat) + * 2. applications.url column in the DB + * + * Updated on every application create / update / delete — no server restart + * needed when registering a new application via the admin UI. + * + * Why mutable arrays work: + * - @better-auth/oauth-provider spreads `validAudiences` into a new Set on + * every token request (`new Set([...opts.validAudiences])`), so mutations + * are picked up immediately. + * - BetterAuth reads `trustedOrigins` on every request for CSRF checks. + * - Fastify CORS uses an `origin` function that closes over `corsOrigins`. + */ + +/** Live list of valid OAuth resource server audiences (RFC 8707). + * Passed by reference to @better-auth/oauth-provider's `validAudiences`. */ +export const validAudiences: string[] = []; + +/** Live list of trusted origins for BetterAuth CSRF checks. + * Passed by reference to betterAuth({ trustedOrigins }). */ +export const trustedOrigins: string[] = []; + +/** Live set of allowed CORS origins. + * Used by the Fastify CORS origin function and the manual /api/auth/* header + * injection (which bypasses the CORS plugin via reply.hijack()). */ +export const corsOrigins = new Set(); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Add a resource URL to validAudiences (deduplicated). No-op for empty/invalid values. */ +export function addAudience(url: string | null | undefined): void { + if (!url) return; + if (!validAudiences.includes(url)) { + validAudiences.push(url); + } +} + +/** Remove a resource URL from validAudiences. */ +export function removeAudience(url: string | null | undefined): void { + if (!url) return; + const idx = validAudiences.indexOf(url); + if (idx !== -1) validAudiences.splice(idx, 1); +} + +/** + * Add an origin derived from `rawUrl` to trustedOrigins and corsOrigins. + * Accepts a full URL (e.g. "https://app.example.com/callback") and extracts + * the origin ("https://app.example.com"). No-op for invalid URLs. + */ +export function addCorsOrigin(rawUrl: string | null | undefined): void { + const origin = toOrigin(rawUrl); + if (!origin) return; + if (!trustedOrigins.includes(origin)) { + trustedOrigins.push(origin); + } + corsOrigins.add(origin); +} + +/** + * Remove an origin from trustedOrigins and corsOrigins. + * Call only when no other registered application shares the same origin. + */ +export function removeCorsOrigin(rawUrl: string | null | undefined): void { + const origin = toOrigin(rawUrl); + if (!origin) return; + const idx = trustedOrigins.indexOf(origin); + if (idx !== -1) trustedOrigins.splice(idx, 1); + corsOrigins.delete(origin); +} + +/** Extract the URL origin, returning null for invalid input. */ +function toOrigin(rawUrl: string | null | undefined): string | null { + if (!rawUrl) return null; + try { + return new URL(rawUrl).origin; + } catch { + return null; + } +} diff --git a/src/services/claims.ts b/src/services/claims.ts index a282ad4..fa48ee5 100644 --- a/src/services/claims.ts +++ b/src/services/claims.ts @@ -15,9 +15,18 @@ interface UserClaims { roles?: string[]; permissions?: string[]; features?: Record; - email?: string; + // Standard OIDC profile scope claims name?: string; - company?: string; + picture?: string; + updated_at?: number; + company?: string; // Proprietary claim within profile scope + // Standard OIDC email scope claims + email?: string; + email_verified?: boolean; + // Standard OIDC phone scope claim + phone_number?: string; + // features scope extras + plan?: string; // Human-readable plan name } /** @@ -28,15 +37,34 @@ export async function getUserClaims( userId: string, applicationSlug: string | undefined, scopes: string[], - profile?: { email?: string | null; name?: string | null; company?: string | null }, + profile?: { + email?: string | null; + emailVerified?: boolean | null; + name?: string | null; + company?: string | null; + image?: string | null; + phone?: string | null; + updatedAt?: Date | null; + }, ): Promise { const claims: UserClaims = {}; // Profile claims come directly from the token — no DB lookup needed if (profile) { - if (scopes.includes("email") && profile.email) claims.email = profile.email; - if (scopes.includes("profile") && profile.name) claims.name = profile.name; - if (scopes.includes("profile") && profile.company) claims.company = profile.company; + if (scopes.includes("email")) { + if (profile.email) claims.email = profile.email; + if (profile.emailVerified !== null && profile.emailVerified !== undefined) + claims.email_verified = profile.emailVerified; + } + if (scopes.includes("profile")) { + if (profile.name) claims.name = profile.name; + if (profile.company) claims.company = profile.company; + if (profile.image) claims.picture = profile.image; + if (profile.updatedAt) claims.updated_at = Math.floor(profile.updatedAt.getTime() / 1000); + } + if (scopes.includes("phone") && profile.phone) { + claims.phone_number = profile.phone; + } } if (!applicationSlug) return claims; @@ -111,6 +139,7 @@ export async function getUserClaims( const [sub] = await db .select({ features: subscriptionPlans.features, + planName: subscriptionPlans.name, expiresAt: userSubscriptions.expiresAt, isActive: userSubscriptions.isActive, }) @@ -134,6 +163,8 @@ export async function getUserClaims( claims.features = expired ? {} : ((sub.features as Record) ?? {}); + // Always include the plan name so clients can gate on tier without parsing features JSON + if (!expired) claims.plan = sub.planName; } else { claims.features = {}; } diff --git a/src/services/templates.ts b/src/services/templates.ts index e2b9e71..030c611 100644 --- a/src/services/templates.ts +++ b/src/services/templates.ts @@ -23,7 +23,12 @@ const BUILTIN_TEMPLATES_DIR = join( "default", ); -type PageName = "login" | "register" | "verify-email" | "select-org"; +type PageName = + | "login" + | "register" + | "verify-email" + | "select-org" + | "two-factor"; export interface TemplateVars { actionUrl: string; diff --git a/templates/default/login.html b/templates/default/login.html index 4f6a0e4..15a1c4d 100644 --- a/templates/default/login.html +++ b/templates/default/login.html @@ -62,6 +62,7 @@ background: var(--primary); color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 8px 20px rgba(37,99,235,0.35); margin-bottom: 0.25rem; + transition: transform .4s cubic-bezier(.4,0,.2,1); } .auth-icon svg { width: 1.375rem; height: 1.375rem; } h1 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; } @@ -71,7 +72,24 @@ border-radius: var(--radius-xl); padding: 1.5rem; width: 100%; box-shadow: var(--shadow-lg); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + overflow: hidden; } + + /* ── Slide-in viewport ─────────────────────────────────────────────── */ + .slider-viewport { overflow: hidden; width: 100%; } + .slider { + display: flex; width: 200%; + transition: transform .45s cubic-bezier(.4,0,.2,1); + } + .slider > .step { flex: 0 0 50%; min-width: 0; } + /* A small inner padding so focus rings aren't clipped by overflow:hidden */ + .slider > .step > .step-inner { padding: 2px; } + .slider.step-mfa { transform: translateX(-50%); } + @media (prefers-reduced-motion: reduce) { + .slider { transition: none; } + .auth-icon { transition: none; } + } + .form-group { margin-bottom: 1rem; } label { display: block; font-size: 0.8125rem; font-weight: 500; @@ -100,6 +118,11 @@ .btn-primary { background: var(--primary); color: #fff; } .btn-primary:hover:not(:disabled) { background: var(--primary-hover); box-shadow: 0 4px 12px rgba(37,99,235,0.3); } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + .btn-ghost { + background: transparent; color: var(--text-muted); + border: 1px solid var(--border); margin-top: .5rem; + } + .btn-ghost:hover:not(:disabled) { color: var(--text); border-color: var(--border-focus); } .error-box { background: var(--danger-bg); color: var(--danger); border: 1px solid var(--danger-border); border-radius: var(--radius-sm); @@ -125,39 +148,141 @@ .btn-social:hover:not(:disabled) { border-color: var(--border-focus); } .btn-social:disabled { opacity: .5; cursor: not-allowed; } .btn-social svg { flex-shrink: 0; } + + /* ── OTP (6 digits split 3+3) ───────────────────────────────────────── */ + .otp-group { + display: flex; align-items: center; justify-content: center; + gap: .5rem; margin: .25rem 0 .25rem; + } + .otp-group .otp-sep { + width: 1rem; height: 1px; background: var(--border); + flex-shrink: 0; + } + .otp-block { display: flex; gap: .4rem; } + .otp-input { + width: 2.5rem; height: 3rem; + text-align: center; + font-size: 1.25rem; font-weight: 600; + font-variant-numeric: tabular-nums; + padding: 0; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface-inner); color: var(--text); + outline: none; transition: border-color .15s, box-shadow .15s, transform .12s; + } + .otp-input:focus { border-color: var(--border-focus); box-shadow: 0 0 0 3px var(--primary-glow); } + .otp-input.is-filled { border-color: var(--primary); } + .otp-group.is-error .otp-input { + border-color: var(--danger-border); + animation: otp-shake .35s cubic-bezier(.36,.07,.19,.97); + } + @keyframes otp-shake { + 10%,90% { transform: translateX(-1px); } + 20%,80% { transform: translateX(2px); } + 30%,50%,70% { transform: translateX(-4px); } + 40%,60% { transform: translateX(4px); } + } + .mfa-mode-switch { + display: flex; justify-content: space-between; align-items: center; + margin-top: .875rem; font-size: .8125rem; + } + .mfa-mode-switch button { + background: none; border: none; padding: 0; cursor: pointer; + font: inherit; color: var(--text-muted); + } + .mfa-mode-switch button:hover { color: var(--text); } + .mfa-mode-switch button.primary { color: var(--primary); } + .mfa-mode-switch button.primary:hover { color: var(--primary-hover); } + .backup-input { + width: 100%; text-align: center; letter-spacing: .1em; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 1rem; + }
-
{{ERROR_MESSAGE}}
-
- - -
- - -
-
-
- - Forgot password? + +
+
+ +
+
+ + + +
+ + +
+
+
+ + Forgot password? +
+ +
+ + +
+
+
+ + + -
- - -
+

Welcome back

+ +