From 469f5f877f59a225f4efb72f4597d0c53eb6a4f4 Mon Sep 17 00:00:00 2001 From: Tynan Purdy Date: Sun, 14 Jun 2026 16:15:17 -0400 Subject: [PATCH] update atproto auth scope to `atproto account:email` instead of `transition:generic` --- gateway/package-lock.json | 12 +- gateway/src/index.ts | 522 +++++++++++++++++---------- gateway/src/routes/oidc/authorize.ts | 5 +- gateway/src/routes/oidc/userinfo.ts | 46 +-- gateway/src/services/database.ts | 27 +- gateway/src/services/oauth.ts | 90 +++-- gateway/src/types/index.ts | 15 +- 7 files changed, 455 insertions(+), 262 deletions(-) diff --git a/gateway/package-lock.json b/gateway/package-lock.json index b8f81bf..7f2fcd5 100644 --- a/gateway/package-lock.json +++ b/gateway/package-lock.json @@ -1,12 +1,12 @@ { "name": "atauth-gateway", - "version": "2.2.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "atauth-gateway", - "version": "2.2.0", + "version": "2.4.0", "license": "MIT", "dependencies": { "@atproto/oauth-client-node": "^0.3.0", @@ -1868,6 +1868,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2266,6 +2267,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3006,6 +3008,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4366,6 +4369,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5182,6 +5186,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -5259,6 +5264,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5362,6 +5368,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5437,6 +5444,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/gateway/src/index.ts b/gateway/src/index.ts index c08aafe..adeff9a 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -6,136 +6,163 @@ * Now with OpenID Connect (OIDC) provider support. */ -import express from 'express'; -import cors from 'cors'; -import helmet from 'helmet'; -import path from 'path'; -import fs from 'fs'; -import crypto from 'crypto'; - -import { DatabaseService } from './services/database.js'; -import { OAuthService } from './services/oauth.js'; -import { OIDCService } from './services/oidc/index.js'; -import { PasskeyService } from './services/passkey.js'; -import { MFAService } from './services/mfa.js'; -import { EmailService } from './services/email.js'; -import { createAuthRoutes } from './routes/auth.js'; -import { createTokenRoutes } from './routes/token.js'; -import { createAdminRoutes } from './routes/admin.js'; -import { createSessionRoutes } from './routes/session.js'; -import { createOIDCRouter } from './routes/oidc/index.js'; -import { createPasskeyRouter } from './routes/passkey.js'; -import { createMFARouter } from './routes/mfa.js'; -import { createEmailRouter } from './routes/email.js'; -import { createProxyAuthRoutes } from './routes/proxy-auth.js'; -import { createUserProfileRoutes } from './routes/user-profile.js'; -import { createPdsProxyRoutes } from './routes/pds-proxy.js'; -import { authRateLimit, apiRateLimit, adminRateLimit } from './middleware/rateLimit.js'; -import { HttpError } from './utils/errors.js'; -import type { WebhookConfig } from './utils/webhook.js'; +import express from "express"; +import cors from "cors"; +import helmet from "helmet"; +import path from "path"; +import fs from "fs"; +import crypto from "crypto"; + +import { DatabaseService } from "./services/database.js"; +import { OAuthService } from "./services/oauth.js"; +import { OIDCService } from "./services/oidc/index.js"; +import { PasskeyService } from "./services/passkey.js"; +import { MFAService } from "./services/mfa.js"; +import { EmailService } from "./services/email.js"; +import { createAuthRoutes } from "./routes/auth.js"; +import { createTokenRoutes } from "./routes/token.js"; +import { createAdminRoutes } from "./routes/admin.js"; +import { createSessionRoutes } from "./routes/session.js"; +import { createOIDCRouter } from "./routes/oidc/index.js"; +import { createPasskeyRouter } from "./routes/passkey.js"; +import { createMFARouter } from "./routes/mfa.js"; +import { createEmailRouter } from "./routes/email.js"; +import { createProxyAuthRoutes } from "./routes/proxy-auth.js"; +import { createUserProfileRoutes } from "./routes/user-profile.js"; +import { createPdsProxyRoutes } from "./routes/pds-proxy.js"; +import { + authRateLimit, + apiRateLimit, + adminRateLimit, +} from "./middleware/rateLimit.js"; +import { HttpError } from "./utils/errors.js"; +import type { WebhookConfig } from "./utils/webhook.js"; // Configuration from environment const config = { - port: parseInt(process.env.PORT || '3100', 10), - host: process.env.HOST || '0.0.0.0', + port: parseInt(process.env.PORT || "3100", 10), + host: process.env.HOST || "0.0.0.0", // OAuth client configuration (for AT Protocol) - clientId: process.env.OAUTH_CLIENT_ID || 'https://auth.example.com/client-metadata.json', - redirectUri: process.env.OAUTH_REDIRECT_URI || 'https://auth.example.com/auth/callback', + clientId: + process.env.OAUTH_CLIENT_ID || + "https://auth.example.com/client-metadata.json", + redirectUri: + process.env.OAUTH_REDIRECT_URI || "https://auth.example.com/auth/callback", // Admin token for app registration adminToken: process.env.ADMIN_TOKEN, // Database path - dbPath: process.env.DB_PATH || path.join(process.cwd(), 'data', 'gateway.db'), + dbPath: process.env.DB_PATH || path.join(process.cwd(), "data", "gateway.db"), // CORS origins - corsOrigins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'], + corsOrigins: process.env.CORS_ORIGINS?.split(",") || [ + "http://localhost:3000", + ], // OIDC configuration oidc: { - enabled: process.env.OIDC_ENABLED === 'true', - issuer: process.env.OIDC_ISSUER || 'https://auth.example.com', + enabled: process.env.OIDC_ENABLED === "true", + issuer: process.env.OIDC_ISSUER || "https://auth.example.com", keySecret: process.env.OIDC_KEY_SECRET, - keyAlgorithm: (process.env.OIDC_KEY_ALGORITHM || 'ES256') as 'ES256' | 'RS256', + keyAlgorithm: (process.env.OIDC_KEY_ALGORITHM || "ES256") as + | "ES256" + | "RS256", }, // Passkey/WebAuthn configuration passkey: { - enabled: process.env.PASSKEY_ENABLED !== 'false', // Enabled by default - rpName: process.env.WEBAUTHN_RP_NAME || 'ATAuth', - rpID: process.env.WEBAUTHN_RP_ID || 'localhost', - origin: process.env.WEBAUTHN_ORIGIN || 'http://localhost:3100', + enabled: process.env.PASSKEY_ENABLED !== "false", // Enabled by default + rpName: process.env.WEBAUTHN_RP_NAME || "ATAuth", + rpID: process.env.WEBAUTHN_RP_ID || "localhost", + origin: process.env.WEBAUTHN_ORIGIN || "http://localhost:3100", }, // MFA/TOTP configuration mfa: { - enabled: process.env.MFA_ENABLED !== 'false', // Enabled by default - issuer: process.env.MFA_TOTP_ISSUER || 'ATAuth', + enabled: process.env.MFA_ENABLED !== "false", // Enabled by default + issuer: process.env.MFA_TOTP_ISSUER || "ATAuth", // 32-byte hex encryption key for TOTP secrets (64 hex characters) encryptionKey: process.env.MFA_ENCRYPTION_KEY, - backupCodesCount: parseInt(process.env.MFA_BACKUP_CODES_COUNT || '10', 10), + backupCodesCount: parseInt(process.env.MFA_BACKUP_CODES_COUNT || "10", 10), }, // Email configuration email: { - enabled: process.env.EMAIL_ENABLED === 'true', // Disabled by default - provider: (process.env.EMAIL_PROVIDER || 'mock') as 'smtp' | 'resend' | 'sendgrid' | 'mailgun' | 'mock', - from: process.env.EMAIL_FROM || 'ATAuth ', + enabled: process.env.EMAIL_ENABLED === "true", // Disabled by default + provider: (process.env.EMAIL_PROVIDER || "mock") as + | "smtp" + | "resend" + | "sendgrid" + | "mailgun" + | "mock", + from: process.env.EMAIL_FROM || "ATAuth ", smtp: { - host: process.env.SMTP_HOST || 'localhost', - port: parseInt(process.env.SMTP_PORT || '587', 10), - secure: process.env.SMTP_SECURE === 'true', + host: process.env.SMTP_HOST || "localhost", + port: parseInt(process.env.SMTP_PORT || "587", 10), + secure: process.env.SMTP_SECURE === "true", user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, apiKey: process.env.EMAIL_API_KEY, - codeExpiry: parseInt(process.env.EMAIL_CODE_EXPIRY || '900', 10), // 15 minutes + codeExpiry: parseInt(process.env.EMAIL_CODE_EXPIRY || "900", 10), // 15 minutes }, // Webhook / login notification configuration webhook: { - enabled: process.env.WEBHOOK_ENABLED === 'true', - matrixHomeserverUrl: process.env.WEBHOOK_MATRIX_HOMESERVER_URL || '', - matrixAccessToken: process.env.WEBHOOK_MATRIX_ACCESS_TOKEN || '', - matrixRoomId: process.env.WEBHOOK_MATRIX_ROOM_ID || '', - loginNotifyClients: (process.env.WEBHOOK_LOGIN_NOTIFY_CLIENTS || '').split(',').filter(Boolean), + enabled: process.env.WEBHOOK_ENABLED === "true", + matrixHomeserverUrl: process.env.WEBHOOK_MATRIX_HOMESERVER_URL || "", + matrixAccessToken: process.env.WEBHOOK_MATRIX_ACCESS_TOKEN || "", + matrixRoomId: process.env.WEBHOOK_MATRIX_ROOM_ID || "", + loginNotifyClients: (process.env.WEBHOOK_LOGIN_NOTIFY_CLIENTS || "") + .split(",") + .filter(Boolean), } satisfies WebhookConfig, // Forward-auth proxy configuration forwardAuth: { - enabled: process.env.FORWARD_AUTH_ENABLED === 'true', + enabled: process.env.FORWARD_AUTH_ENABLED === "true", sessionSecret: process.env.FORWARD_AUTH_SESSION_SECRET, - sessionTtl: parseInt(process.env.FORWARD_AUTH_SESSION_TTL || '604800', 10), // 7 days - proxyCookieTtl: parseInt(process.env.FORWARD_AUTH_PROXY_COOKIE_TTL || '86400', 10), // 24h + sessionTtl: parseInt(process.env.FORWARD_AUTH_SESSION_TTL || "604800", 10), // 7 days + proxyCookieTtl: parseInt( + process.env.FORWARD_AUTH_PROXY_COOKIE_TTL || "86400", + 10, + ), // 24h }, }; async function main(): Promise { - console.log('Starting ATAuth Gateway...'); + console.log("Starting ATAuth Gateway..."); // Validate CORS configuration - if (config.corsOrigins.includes('*')) { - console.error('CORS_ORIGINS cannot include "*" — credentials mode requires explicit origins'); + if (config.corsOrigins.includes("*")) { + console.error( + 'CORS_ORIGINS cannot include "*" — credentials mode requires explicit origins', + ); process.exit(1); } // Validate required secrets when features are enabled const missing: string[] = []; if (config.oidc.enabled && !config.oidc.keySecret) { - missing.push('OIDC_KEY_SECRET is required when OIDC_ENABLED=true'); + missing.push("OIDC_KEY_SECRET is required when OIDC_ENABLED=true"); } if (config.mfa.enabled && !config.mfa.encryptionKey) { - missing.push('MFA_ENCRYPTION_KEY is required when MFA is enabled (set MFA_ENABLED=false to disable)'); + missing.push( + "MFA_ENCRYPTION_KEY is required when MFA is enabled (set MFA_ENABLED=false to disable)", + ); } if (config.forwardAuth.enabled && !config.forwardAuth.sessionSecret) { - missing.push('FORWARD_AUTH_SESSION_SECRET is required when FORWARD_AUTH_ENABLED=true'); + missing.push( + "FORWARD_AUTH_SESSION_SECRET is required when FORWARD_AUTH_ENABLED=true", + ); } if (config.adminToken && config.adminToken.length < 32) { - missing.push('ADMIN_TOKEN must be at least 32 characters'); + missing.push("ADMIN_TOKEN must be at least 32 characters"); } if (missing.length > 0) { - console.error('Configuration error:'); + console.error("Configuration error:"); for (const msg of missing) console.error(` - ${msg}`); process.exit(1); } @@ -161,9 +188,9 @@ async function main(): Promise { try { await oauth.initialize(additionalRedirectUris); - console.log('OAuth client initialized'); + console.log("OAuth client initialized"); } catch (error) { - console.error('Failed to initialize OAuth client:', error); + console.error("Failed to initialize OAuth client:", error); } // Initialize OIDC service if enabled @@ -176,9 +203,9 @@ async function main(): Promise { }); try { await oidcService.initialize(config.oidc.keyAlgorithm); - console.log('OIDC service initialized'); + console.log("OIDC service initialized"); } catch (error) { - console.error('Failed to initialize OIDC service:', error); + console.error("Failed to initialize OIDC service:", error); } } @@ -190,7 +217,7 @@ async function main(): Promise { rpID: config.passkey.rpID, origin: config.passkey.origin, }); - console.log('Passkey service initialized'); + console.log("Passkey service initialized"); } // Initialize MFA service @@ -201,7 +228,7 @@ async function main(): Promise { encryptionKey: config.mfa.encryptionKey!, backupCodesCount: config.mfa.backupCodesCount, }); - console.log('MFA service initialized'); + console.log("MFA service initialized"); } // Initialize Email service @@ -214,41 +241,53 @@ async function main(): Promise { from: config.email.from, codeExpiry: config.email.codeExpiry, }); - console.log('Email service initialized'); + console.log("Email service initialized"); } // Create Express app const app = express(); // Trust the first proxy (k8s ingress / cloudflared) for correct client IP in rate limiting - app.set('trust proxy', 1); + app.set("trust proxy", 1); // Middleware // Generate a per-request nonce for inline scripts (CSP script-src) app.use((_req, res, next) => { - res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); + res.locals.cspNonce = crypto.randomBytes(16).toString("base64"); next(); }); - app.use(helmet({ - crossOriginResourcePolicy: { policy: 'cross-origin' }, - contentSecurityPolicy: { - directives: { - ...helmet.contentSecurityPolicy.getDefaultDirectives(), - // Restrict form-action to self + CORS origins (which include PDS hosts) - 'form-action': ["'self'", ...config.corsOrigins], - // Allow inline scripts with per-request nonce (for login page) - 'script-src': ["'self'", (_req: any, res: any) => `'nonce-${res.locals.cspNonce}'`], + app.use( + helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + contentSecurityPolicy: { + directives: { + ...helmet.contentSecurityPolicy.getDefaultDirectives(), + // Restrict form-action to self + CORS origins (which include PDS hosts) + "form-action": ["'self'", ...config.corsOrigins], + // Allow inline scripts with per-request nonce (for login page) + "script-src": [ + "'self'", + (_req: any, res: any) => `'nonce-${res.locals.cspNonce}'`, + ], + }, }, - }, - })); - app.use(cors({ - origin: config.corsOrigins, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Session-Id'], - })); - app.use(express.json({ limit: '16kb' })); - app.use(express.urlencoded({ extended: true, limit: '16kb' })); + }), + ); + app.use( + cors({ + origin: config.corsOrigins, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "X-Requested-With", + "X-Session-Id", + ], + }), + ); + app.use(express.json({ limit: "16kb" })); + app.use(express.urlencoded({ extended: true, limit: "16kb" })); // Request logging app.use((req, _res, next) => { @@ -257,22 +296,22 @@ async function main(): Promise { }); // Health check - app.get('/health', (_req, res) => { + app.get("/health", (_req, res) => { res.json({ - status: 'ok', - service: 'atauth-gateway', + status: "ok", + service: "atauth-gateway", timestamp: new Date().toISOString(), }); }); // Version endpoint - const pkgPath = path.join(process.cwd(), 'package.json'); - const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version; - app.get('/version', (_req, res) => { + const pkgPath = path.join(process.cwd(), "package.json"); + const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version; + app.get("/version", (_req, res) => { res.json({ - service: 'atauth-gateway', + service: "atauth-gateway", version: pkgVersion, - commit: process.env.BUILD_COMMIT || 'unknown', + commit: process.env.BUILD_COMMIT || "unknown", }); }); @@ -280,60 +319,109 @@ async function main(): Promise { // /auth/verify is called by nginx auth_request on every subrequest from a single // pod IP, so per-IP rate limiting would block legitimate traffic. if (config.forwardAuth.enabled) { - const proxyRouter = createProxyAuthRoutes(db, oauth, { ...config.forwardAuth, sessionSecret: config.forwardAuth.sessionSecret! }, config.oidc.issuer, passkeyService); + const proxyRouter = createProxyAuthRoutes( + db, + oauth, + { + ...config.forwardAuth, + sessionSecret: config.forwardAuth.sessionSecret!, + }, + config.oidc.issuer, + passkeyService, + ); // Mount entire proxy router at /auth -- no rate limit on /auth/verify - app.use('/auth', proxyRouter); - console.log('Forward-auth proxy enabled'); + app.use("/auth", proxyRouter); + console.log("Forward-auth proxy enabled"); } // Routes with rate limiting - app.use('/auth', authRateLimit, createAuthRoutes(db, oauth)); - app.use('/token', apiRateLimit, createTokenRoutes(db)); - app.use('/admin', adminRateLimit, createAdminRoutes(db, config.adminToken, oidcService, passkeyService, mfaService, config.forwardAuth.enabled ? config.forwardAuth.sessionSecret : undefined)); - app.use('/session', apiRateLimit, createSessionRoutes(db)); + app.use("/auth", authRateLimit, createAuthRoutes(db, oauth)); + app.use("/token", apiRateLimit, createTokenRoutes(db)); + app.use( + "/admin", + adminRateLimit, + createAdminRoutes( + db, + config.adminToken, + oidcService, + passkeyService, + mfaService, + config.forwardAuth.enabled ? config.forwardAuth.sessionSecret : undefined, + ), + ); + app.use("/session", apiRateLimit, createSessionRoutes(db)); // OIDC routes (if enabled) if (oidcService) { - const { wellKnownRouter, oauthRouter } = createOIDCRouter(db, oidcService, oauth, passkeyService, config.webhook); - app.use('/.well-known', wellKnownRouter); - app.use('/oauth', authRateLimit, oauthRouter); - console.log('OIDC routes enabled'); + const { wellKnownRouter, oauthRouter } = createOIDCRouter( + db, + oidcService, + oauth, + passkeyService, + config.webhook, + ); + app.use("/.well-known", wellKnownRouter); + app.use("/oauth", authRateLimit, oauthRouter); + console.log("OIDC routes enabled"); // PDS write proxy (requires OIDC for token validation) - app.use('/api/pds', apiRateLimit, createPdsProxyRoutes(oauth, oidcService)); - console.log('PDS proxy routes enabled'); + app.use("/api/pds", apiRateLimit, createPdsProxyRoutes(oauth, oidcService)); + console.log("PDS proxy routes enabled"); } // Passkey routes (if enabled) if (passkeyService) { - const passkeySessionSecret = config.forwardAuth.enabled ? config.forwardAuth.sessionSecret : undefined; - app.use('/auth/passkey', authRateLimit, createPasskeyRouter(db, passkeyService, oidcService, passkeySessionSecret)); - console.log('Passkey routes enabled'); + const passkeySessionSecret = config.forwardAuth.enabled + ? config.forwardAuth.sessionSecret + : undefined; + app.use( + "/auth/passkey", + authRateLimit, + createPasskeyRouter( + db, + passkeyService, + oidcService, + passkeySessionSecret, + ), + ); + console.log("Passkey routes enabled"); } // MFA routes (if enabled) if (mfaService) { - app.use('/auth/mfa', authRateLimit, createMFARouter(db, mfaService, passkeyService, oidcService)); - console.log('MFA routes enabled'); + app.use( + "/auth/mfa", + authRateLimit, + createMFARouter(db, mfaService, passkeyService, oidcService), + ); + console.log("MFA routes enabled"); } // Email routes (if enabled) if (emailService) { - app.use('/auth/email', authRateLimit, createEmailRouter(db, emailService, oidcService)); - console.log('Email routes enabled'); + app.use( + "/auth/email", + authRateLimit, + createEmailRouter(db, emailService, oidcService), + ); + console.log("Email routes enabled"); } // Forward-auth proxy routes were mounted earlier (before /auth rate limiter) // User profile routes (forward-auth session authenticated) if (config.forwardAuth.enabled) { - const profileRouter = createUserProfileRoutes(db, passkeyService, config.forwardAuth.sessionSecret!); - app.use('/auth/profile', authRateLimit, profileRouter); - console.log('User profile routes enabled'); + const profileRouter = createUserProfileRoutes( + db, + passkeyService, + config.forwardAuth.sessionSecret!, + ); + app.use("/auth/profile", authRateLimit, profileRouter); + console.log("User profile routes enabled"); } // OAuth client metadata (for AT Protocol discovery) - app.get('/client-metadata.json', (_req, res) => { + app.get("/client-metadata.json", (_req, res) => { // Build redirect URIs array - include both legacy and OIDC callback paths const redirectUris = [config.redirectUri]; // Add OIDC callback if OIDC is enabled @@ -347,87 +435,119 @@ async function main(): Promise { res.json({ client_id: config.clientId, - client_name: 'ATAuth Gateway', - client_uri: config.clientId.replace('/client-metadata.json', ''), + client_name: "ATAuth Gateway", + client_uri: config.clientId.replace("/client-metadata.json", ""), redirect_uris: redirectUris, - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - scope: 'atproto transition:generic', - application_type: 'web', - token_endpoint_auth_method: 'none', + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: "atproto account:email", + application_type: "web", + token_endpoint_auth_method: "none", dpop_bound_access_tokens: true, }); }); // Error handler - Express 5 automatically forwards async errors here - app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - // Handle known HTTP errors (thrown by route handlers) - if (err instanceof HttpError) { - return res.status(err.statusCode).json({ - error: err.code, - message: err.message, - }); - } + app.use( + ( + err: Error, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + // Handle known HTTP errors (thrown by route handlers) + if (err instanceof HttpError) { + return res.status(err.statusCode).json({ + error: err.code, + message: err.message, + }); + } - // Handle AT Protocol identity resolution failures (invalid/unresolvable handles) - if (err.constructor?.name === 'OAuthResolverError') { - const cause = (err as any).cause; - const handleMatch = err.message.match(/identity:\s*(.+)/); - const handle = handleMatch ? handleMatch[1] : 'unknown'; - let userMessage = `Could not resolve handle "${handle}". `; - - if (cause?.message?.includes('does not resolve to a DID')) { - userMessage += 'This handle has no _atproto DNS TXT record. Ensure the handle is configured on a PDS with proper DNS records.'; - } else if (cause?.message?.includes('not found')) { - userMessage += 'The handle was not found. Check that the handle is spelled correctly and the PDS is reachable.'; - } else { - userMessage += 'The AT Protocol identity could not be verified. Check that the handle exists and the PDS is online.'; + // Handle AT Protocol identity resolution failures (invalid/unresolvable handles) + if (err.constructor?.name === "OAuthResolverError") { + const cause = (err as any).cause; + const handleMatch = err.message.match(/identity:\s*(.+)/); + const handle = handleMatch ? handleMatch[1] : "unknown"; + let userMessage = `Could not resolve handle "${handle}". `; + + if (cause?.message?.includes("does not resolve to a DID")) { + userMessage += + "This handle has no _atproto DNS TXT record. Ensure the handle is configured on a PDS with proper DNS records."; + } else if (cause?.message?.includes("not found")) { + userMessage += + "The handle was not found. Check that the handle is spelled correctly and the PDS is reachable."; + } else { + userMessage += + "The AT Protocol identity could not be verified. Check that the handle exists and the PDS is online."; + } + + console.error( + `Identity resolution failed for "${handle}":`, + cause?.message || err.message, + ); + return res.status(400).json({ + error: "handle_resolution_failed", + message: userMessage, + }); } - console.error(`Identity resolution failed for "${handle}":`, cause?.message || err.message); - return res.status(400).json({ - error: 'handle_resolution_failed', - message: userMessage, + // Log unexpected errors + console.error("Unhandled error:", err); + res.status(500).json({ + error: "internal_error", + message: + process.env.NODE_ENV === "development" + ? err.message + : "Internal server error", }); - } - - // Log unexpected errors - console.error('Unhandled error:', err); - res.status(500).json({ - error: 'internal_error', - message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error', - }); - }); + }, + ); // Graceful shutdown const shutdown = () => { - console.log('Shutting down...'); + console.log("Shutting down..."); db.close(); process.exit(0); }; - process.on('SIGTERM', shutdown); - process.on('SIGINT', shutdown); + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); // Periodic cleanup (every hour) - setInterval(() => { - const statesDeleted = db.cleanupOldOAuthStates(); - const sessionsDeleted = db.cleanupExpiredSessions(); - const authCodesDeleted = db.cleanupExpiredAuthorizationCodes(); - const refreshTokensDeleted = db.cleanupExpiredRefreshTokens(); - const emailCodesDeleted = db.cleanupExpiredEmailVerificationCodes(); - const proxySessionsDeleted = db.cleanupExpiredProxySessions(); - const proxyAuthRequestsDeleted = db.cleanupExpiredProxyAuthRequests(); - const auditLogsDeleted = db.cleanupOldAuditLogs(); - const revokedTokensDeleted = db.cleanupExpiredRevokedTokens(); - const passkeyChallengesDeleted = db.cleanupExpiredPasskeyChallenges(); - const oauthSessionsDeleted = db.cleanupExpiredOAuthSessions(); - - const total = statesDeleted + sessionsDeleted + authCodesDeleted + refreshTokensDeleted + emailCodesDeleted + proxySessionsDeleted + proxyAuthRequestsDeleted + auditLogsDeleted + revokedTokensDeleted + passkeyChallengesDeleted + oauthSessionsDeleted; - if (total > 0) { - console.log(`Cleanup: ${statesDeleted} OAuth states, ${sessionsDeleted} sessions, ${authCodesDeleted} auth codes, ${refreshTokensDeleted} refresh tokens, ${emailCodesDeleted} email codes, ${proxySessionsDeleted} proxy sessions, ${proxyAuthRequestsDeleted} proxy auth requests, ${revokedTokensDeleted} revoked tokens, ${passkeyChallengesDeleted} passkey challenges, ${oauthSessionsDeleted} oauth sessions`); - } - }, 60 * 60 * 1000); + setInterval( + () => { + const statesDeleted = db.cleanupOldOAuthStates(); + const sessionsDeleted = db.cleanupExpiredSessions(); + const authCodesDeleted = db.cleanupExpiredAuthorizationCodes(); + const refreshTokensDeleted = db.cleanupExpiredRefreshTokens(); + const emailCodesDeleted = db.cleanupExpiredEmailVerificationCodes(); + const proxySessionsDeleted = db.cleanupExpiredProxySessions(); + const proxyAuthRequestsDeleted = db.cleanupExpiredProxyAuthRequests(); + const auditLogsDeleted = db.cleanupOldAuditLogs(); + const revokedTokensDeleted = db.cleanupExpiredRevokedTokens(); + const passkeyChallengesDeleted = db.cleanupExpiredPasskeyChallenges(); + const oauthSessionsDeleted = db.cleanupExpiredOAuthSessions(); + + const total = + statesDeleted + + sessionsDeleted + + authCodesDeleted + + refreshTokensDeleted + + emailCodesDeleted + + proxySessionsDeleted + + proxyAuthRequestsDeleted + + auditLogsDeleted + + revokedTokensDeleted + + passkeyChallengesDeleted + + oauthSessionsDeleted; + if (total > 0) { + console.log( + `Cleanup: ${statesDeleted} OAuth states, ${sessionsDeleted} sessions, ${authCodesDeleted} auth codes, ${refreshTokensDeleted} refresh tokens, ${emailCodesDeleted} email codes, ${proxySessionsDeleted} proxy sessions, ${proxyAuthRequestsDeleted} proxy auth requests, ${revokedTokensDeleted} revoked tokens, ${passkeyChallengesDeleted} passkey challenges, ${oauthSessionsDeleted} oauth sessions`, + ); + } + }, + 60 * 60 * 1000, + ); // Start server app.listen(config.port, config.host, () => { @@ -435,41 +555,47 @@ async function main(): Promise { console.log(`OAuth Client ID: ${config.clientId}`); console.log(`OAuth Redirect URI: ${config.redirectUri}`); if (config.adminToken) { - console.log('Admin endpoints enabled'); + console.log("Admin endpoints enabled"); } else { - console.log('Admin endpoints disabled (set ADMIN_TOKEN to enable)'); + console.log("Admin endpoints disabled (set ADMIN_TOKEN to enable)"); } if (config.oidc.enabled) { console.log(`OIDC enabled - Issuer: ${config.oidc.issuer}`); - console.log(`OIDC Discovery: ${config.oidc.issuer}/.well-known/openid-configuration`); + console.log( + `OIDC Discovery: ${config.oidc.issuer}/.well-known/openid-configuration`, + ); } else { - console.log('OIDC disabled (set OIDC_ENABLED=true to enable)'); + console.log("OIDC disabled (set OIDC_ENABLED=true to enable)"); } if (config.passkey.enabled) { console.log(`Passkey enabled - RP ID: ${config.passkey.rpID}`); } else { - console.log('Passkey disabled (set PASSKEY_ENABLED=true to enable)'); + console.log("Passkey disabled (set PASSKEY_ENABLED=true to enable)"); } if (config.mfa.enabled) { console.log(`MFA enabled - Issuer: ${config.mfa.issuer}`); } else { - console.log('MFA disabled (set MFA_ENABLED=true to enable)'); + console.log("MFA disabled (set MFA_ENABLED=true to enable)"); } if (config.email.enabled) { console.log(`Email enabled - Provider: ${config.email.provider}`); } else { - console.log('Email disabled (set EMAIL_ENABLED=true to enable)'); + console.log("Email disabled (set EMAIL_ENABLED=true to enable)"); } if (config.forwardAuth.enabled) { - console.log(`Forward-auth proxy enabled - Session TTL: ${config.forwardAuth.sessionTtl}s`); + console.log( + `Forward-auth proxy enabled - Session TTL: ${config.forwardAuth.sessionTtl}s`, + ); } else { - console.log('Forward-auth proxy disabled (set FORWARD_AUTH_ENABLED=true to enable)'); + console.log( + "Forward-auth proxy disabled (set FORWARD_AUTH_ENABLED=true to enable)", + ); } }); } main().catch((error) => { - console.error('Fatal error:', error); + console.error("Fatal error:", error); process.exit(1); }); diff --git a/gateway/src/routes/oidc/authorize.ts b/gateway/src/routes/oidc/authorize.ts index e2694b6..dc830f7 100644 --- a/gateway/src/routes/oidc/authorize.ts +++ b/gateway/src/routes/oidc/authorize.ts @@ -709,7 +709,7 @@ export function createAuthorizeRouter( } // Get the user's DID and handle from the AT Protocol session - const { did, handle } = callbackResult; + const { did, handle, email } = callbackResult; console.log(`[OIDC Callback] Authenticated user: ${handle} (${did})`); // Get the OIDC authorization code we saved earlier (stored in code_verifier field) @@ -733,6 +733,9 @@ export function createAuthorizeRouter( // Update with user info db.updateAuthorizationCodeUser(oidcAuthCode, did, handle); + // Cache PDS-provided email for userinfo endpoint + db.upsertOIDCUserEmail(did, authData.client_id, handle, email); + // Check client access rules const oidcClient = db.getOIDCClient(authData.client_id); if (oidcClient?.require_access_check) { diff --git a/gateway/src/routes/oidc/userinfo.ts b/gateway/src/routes/oidc/userinfo.ts index 07a1daf..11d3cc9 100644 --- a/gateway/src/routes/oidc/userinfo.ts +++ b/gateway/src/routes/oidc/userinfo.ts @@ -4,26 +4,29 @@ * Handles /oauth/userinfo - returns claims about the authenticated user */ -import { Router, Request, Response } from 'express'; -import type { DatabaseService } from '../../services/database.js'; -import type { OIDCService } from '../../services/oidc/index.js'; -import { buildUserInfo, parseScopes } from '../../services/oidc/claims.js'; +import { Router, Request, Response } from "express"; +import type { DatabaseService } from "../../services/database.js"; +import type { OIDCService } from "../../services/oidc/index.js"; +import { buildUserInfo, parseScopes } from "../../services/oidc/claims.js"; -export function createUserInfoRouter(db: DatabaseService, oidcService: OIDCService): Router { +export function createUserInfoRouter( + db: DatabaseService, + oidcService: OIDCService, +): Router { const router = Router(); /** * GET/POST /oauth/userinfo * Returns claims about the authenticated user */ - router.all('/userinfo', async (req: Request, res: Response) => { + router.all("/userinfo", async (req: Request, res: Response) => { try { // Extract access token from Authorization header const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { + if (!authHeader || !authHeader.startsWith("Bearer ")) { res.status(401).json({ - error: 'invalid_token', - error_description: 'Missing or invalid Authorization header', + error: "invalid_token", + error_description: "Missing or invalid Authorization header", }); return; } @@ -34,8 +37,8 @@ export function createUserInfoRouter(db: DatabaseService, oidcService: OIDCServi const claims = oidcService.tokenService.verifyAccessToken(accessToken); if (!claims) { res.status(401).json({ - error: 'invalid_token', - error_description: 'Invalid or expired access token', + error: "invalid_token", + error_description: "Invalid or expired access token", }); return; } @@ -44,7 +47,7 @@ export function createUserInfoRouter(db: DatabaseService, oidcService: OIDCServi const scopes = parseScopes(claims.scope); // Resolve the user's handle from mappings or AT Protocol API - let handle = ''; + let handle = ""; // Try to get handle from sessions or mappings first const mapping = db.getUserMapping(claims.sub, claims.client_id); @@ -54,7 +57,7 @@ export function createUserInfoRouter(db: DatabaseService, oidcService: OIDCServi // Resolve DID to handle via AT Protocol API try { const response = await fetch( - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(claims.sub)}` + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(claims.sub)}`, ); if (response.ok) { const data = (await response.json()) as { handle: string }; @@ -72,26 +75,25 @@ export function createUserInfoRouter(db: DatabaseService, oidcService: OIDCServi did: claims.sub, handle, }, - scopes + scopes, ); // If we have email scope, try to get verified email - if (scopes.includes('email')) { - const emails = db.getUserEmails(claims.sub); - const primaryEmail = emails.find((e) => e.is_primary && e.verified); - if (primaryEmail) { + if (scopes.includes("email")) { + const mapping = db.getUserMapping(claims.sub, claims.client_id); + if (mapping?.email) { const extendedInfo = userInfo as unknown as Record; - extendedInfo.email = primaryEmail.email; + extendedInfo.email = mapping.email; extendedInfo.email_verified = true; } } res.json(userInfo); } catch (error) { - console.error('[OIDC UserInfo] Error:', error); + console.error("[OIDC UserInfo] Error:", error); res.status(500).json({ - error: 'server_error', - error_description: 'Internal server error', + error: "server_error", + error_description: "Internal server error", }); } }); diff --git a/gateway/src/services/database.ts b/gateway/src/services/database.ts index 585e365..64f7ef6 100644 --- a/gateway/src/services/database.ts +++ b/gateway/src/services/database.ts @@ -167,6 +167,13 @@ export class DatabaseService { // Column already exists } + // Add email column to user_mappings if it doesn't exist + try { + this.db.exec('ALTER TABLE user_mappings ADD COLUMN email TEXT'); + } catch { + // Column already exists + } + // Create refresh tokens table this.db.exec(` CREATE TABLE IF NOT EXISTS refresh_tokens ( @@ -456,13 +463,25 @@ export class DatabaseService { setUserMapping(mapping: Omit): void { const stmt = this.db.prepare(` - INSERT INTO user_mappings (did, app_id, user_id, handle) - VALUES (?, ?, ?, ?) + INSERT INTO user_mappings (did, app_id, user_id, handle, email) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(did, app_id) DO UPDATE SET user_id = excluded.user_id, - handle = excluded.handle + handle = excluded.handle, + email = COALESCE(excluded.email, email) + `); + stmt.run(mapping.did, mapping.app_id, mapping.user_id, mapping.handle, mapping.email ?? null); + } + + upsertOIDCUserEmail(did: string, appId: string, handle: string, email?: string): void { + const stmt = this.db.prepare(` + INSERT INTO user_mappings (did, app_id, user_id, handle, email) + VALUES (?, ?, 0, ?, ?) + ON CONFLICT(did, app_id) DO UPDATE SET + handle = excluded.handle, + email = COALESCE(excluded.email, email) `); - stmt.run(mapping.did, mapping.app_id, mapping.user_id, mapping.handle); + stmt.run(did, appId, handle, email ?? null); } // Session methods diff --git a/gateway/src/services/oauth.ts b/gateway/src/services/oauth.ts index 2eef4ec..a1f57ee 100644 --- a/gateway/src/services/oauth.ts +++ b/gateway/src/services/oauth.ts @@ -4,9 +4,13 @@ * Handles AT Protocol OAuth flow using @atproto/oauth-client-node */ -import { NodeOAuthClient, NodeSavedState, NodeSavedSession } from '@atproto/oauth-client-node'; -import { DatabaseService } from './database.js'; -import type { OAuthState } from '../types/index.js'; +import { + NodeOAuthClient, + NodeSavedState, + NodeSavedSession, +} from "@atproto/oauth-client-node"; +import { DatabaseService } from "./database.js"; +import type { OAuthState } from "../types/index.js"; // OAuth stores backed by SQLite for persistence across restarts @@ -16,6 +20,7 @@ let pendingStateKey: string | null = null; export interface OAuthResult { did: string; handle: string; + email?: string; accessJwt?: string; refreshJwt?: string; } @@ -34,11 +39,14 @@ export class OAuthService { async initialize(additionalRedirectUris?: string[]): Promise { // Derive proxy callback from the primary redirect URI's base - const baseUrl = this.redirectUri.replace(/\/[^/]*$/, ''); + const baseUrl = this.redirectUri.replace(/\/[^/]*$/, ""); const proxyCallbackUri = `${baseUrl}/proxy/callback`; // Collect all redirect URIs: primary, proxy, and any additional (e.g. OIDC callback) - const redirectUris: [string, ...string[]] = [this.redirectUri, proxyCallbackUri]; + const redirectUris: [string, ...string[]] = [ + this.redirectUri, + proxyCallbackUri, + ]; if (additionalRedirectUris) { for (const uri of additionalRedirectUris) { if (!redirectUris.includes(uri)) { @@ -50,24 +58,29 @@ export class OAuthService { this.client = new NodeOAuthClient({ clientMetadata: { client_id: this.clientId, - client_name: 'ATAuth Gateway', + client_name: "ATAuth Gateway", client_uri: this.clientId, redirect_uris: redirectUris, - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - scope: 'atproto transition:generic', - application_type: 'web', - token_endpoint_auth_method: 'none', + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: "atproto account:email", + application_type: "web", + token_endpoint_auth_method: "none", dpop_bound_access_tokens: true, }, stateStore: { get: async (key: string): Promise => { const row = this.db.getOAuthSession(key); - return row ? JSON.parse(row.data) as NodeSavedState : undefined; + return row ? (JSON.parse(row.data) as NodeSavedState) : undefined; }, set: async (key: string, state: NodeSavedState): Promise => { const expiresAt = Math.floor(Date.now() / 1000) + 600; // 10 min - this.db.saveOAuthSession(key, JSON.stringify(state), 'state', expiresAt); + this.db.saveOAuthSession( + key, + JSON.stringify(state), + "state", + expiresAt, + ); pendingStateKey = key; }, del: async (key: string): Promise => { @@ -77,11 +90,16 @@ export class OAuthService { sessionStore: { get: async (key: string): Promise => { const row = this.db.getOAuthSession(key); - return row ? JSON.parse(row.data) as NodeSavedSession : undefined; + return row ? (JSON.parse(row.data) as NodeSavedSession) : undefined; }, set: async (key: string, session: NodeSavedSession): Promise => { const expiresAt = Math.floor(Date.now() / 1000) + 30 * 24 * 3600; // 30 days - this.db.saveOAuthSession(key, JSON.stringify(session), 'session', expiresAt); + this.db.saveOAuthSession( + key, + JSON.stringify(session), + "session", + expiresAt, + ); }, del: async (key: string): Promise => { this.db.deleteOAuthSession(key); @@ -97,22 +115,27 @@ export class OAuthService { * @param handle - The user's AT Protocol handle * @param customRedirect - Optional custom redirect URI */ - async generateAuthUrl(appId: string, handle: string, customRedirect?: string, appRedirectUri?: string): Promise<{ + async generateAuthUrl( + appId: string, + handle: string, + customRedirect?: string, + appRedirectUri?: string, + ): Promise<{ url: string; state: string; }> { if (!this.client) { - throw new Error('OAuth client not initialized'); + throw new Error("OAuth client not initialized"); } if (!handle) { - throw new Error('Handle is required for OAuth authorization'); + throw new Error("Handle is required for OAuth authorization"); } pendingStateKey = null; const authorizeOptions: Record = { - scope: 'atproto transition:generic', + scope: "atproto account:email", }; if (customRedirect) { authorizeOptions.redirect_uri = customRedirect; @@ -122,12 +145,12 @@ export class OAuthService { const state = pendingStateKey; if (!state) { - throw new Error('OAuth library did not store state'); + throw new Error("OAuth library did not store state"); } const oauthState: OAuthState = { state, - code_verifier: '', + code_verifier: "", app_id: appId, redirect_uri: appRedirectUri || customRedirect || this.redirectUri, created_at: Math.floor(Date.now() / 1000), @@ -145,9 +168,12 @@ export class OAuthService { * @param params - URL search params from the callback * @param redirectUri - The redirect_uri used during authorization (must match) */ - async handleCallback(params: URLSearchParams, redirectUri?: string): Promise { + async handleCallback( + params: URLSearchParams, + redirectUri?: string, + ): Promise { if (!this.client) { - throw new Error('OAuth client not initialized'); + throw new Error("OAuth client not initialized"); } const callbackOptions: Record = {}; @@ -169,16 +195,24 @@ export class OAuthService { // Fall back to DID } - return { did, handle }; + const email = (session as unknown as Record).email as + | string + | undefined; + + return { did, handle, email }; } /** * Restore a PDS session for a user and make an authenticated xRPC call. * Returns the response from the user's PDS. */ - async proxyPdsRequest(did: string, pathname: string, init?: RequestInit): Promise { + async proxyPdsRequest( + did: string, + pathname: string, + init?: RequestInit, + ): Promise { if (!this.client) { - throw new Error('OAuth client not initialized'); + throw new Error("OAuth client not initialized"); } const session = await this.client.restore(did); @@ -195,7 +229,7 @@ export class OAuthService { private async resolveDidToHandle(did: string): Promise { try { const response = await fetch( - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, ); if (!response.ok) return null; const data = (await response.json()) as { handle: string }; @@ -208,7 +242,7 @@ export class OAuthService { async resolveHandle(handle: string): Promise { try { const response = await fetch( - `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` + `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, ); if (!response.ok) return null; const data = (await response.json()) as { did: string }; diff --git a/gateway/src/types/index.ts b/gateway/src/types/index.ts index e109b93..e662399 100644 --- a/gateway/src/types/index.ts +++ b/gateway/src/types/index.ts @@ -5,11 +5,11 @@ */ // Re-export all types from sub-modules -export * from './oidc.js'; -export * from './passkey.js'; -export * from './mfa.js'; -export * from './email.js'; -export * from './proxy.js'; +export * from "./oidc.js"; +export * from "./passkey.js"; +export * from "./mfa.js"; +export * from "./email.js"; +export * from "./proxy.js"; export interface GatewayTokenPayload { /** AT Protocol DID (e.g., "did:plc:xyz...") */ @@ -62,12 +62,13 @@ export interface UserMapping { did: string; app_id: string; user_id: number; + email?: string; handle: string; linked_at: Date; } /** Session connection state for conflict detection */ -export type SessionConnectionState = 'pending' | 'connected' | 'disconnected'; +export type SessionConnectionState = "pending" | "connected" | "disconnected"; /** Extended session info with connection tracking */ export interface ActiveSession extends AppSession { @@ -77,7 +78,7 @@ export interface ActiveSession extends AppSession { } /** Session conflict resolution options */ -export type SessionResolution = 'transfer' | 'cancel' | 'close_others'; +export type SessionResolution = "transfer" | "cancel" | "close_others"; /** Session conflict information returned to client */ export interface SessionConflict { -- knotmirror 0.0.1