From 8edfa8ea5a47c34a497c27d4f2f3e8ea70b672eb Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 27 May 2026 10:32:39 -0400 Subject: [PATCH] refactor: remove web mode --- .env.example | 5 +- AGENTS.md | 9 +- README.md | 8 +- docs/architecture.md | 30 +- package-lock.json | 25 -- package.json | 2 - src/app.ts | 2 - src/controllers/authentication.ts | 24 +- src/controllers/internalSecurity.ts | 1 - src/controllers/magicLinks.ts | 4 +- src/controllers/oauth.ts | 4 - src/controllers/organizations.ts | 9 - src/controllers/otp.ts | 37 --- src/controllers/registration.ts | 43 +-- src/controllers/totp.ts | 3 - src/controllers/user.ts | 10 - src/controllers/webauthn.ts | 14 +- src/lib/bootstrapCookie.ts | 40 --- src/lib/cookie.ts | 67 ----- src/lib/defineRoute.ts | 8 +- src/middleware/attachAuthMiddleware.ts | 24 +- src/middleware/verifyCookieAuth.ts | 229 -------------- src/models/sessions.ts | 6 +- src/openapi/document.ts | 10 - src/schemas/authEvent.types.ts | 3 - src/schemas/generic.responses.ts | 1 + src/server.ts | 1 - src/services/bootstrapPromotionService.ts | 18 +- src/services/sessionIssuance.ts | 23 +- src/services/sessionService.ts | 8 +- tests/e2e/auth.happy.spec.ts | 156 ---------- tests/e2e/authFlow.spec.ts | 8 +- .../auth/cookieAuth.security.spec.ts | 282 ------------------ tests/integration/auth/cookieAuth.spec.ts | 170 ----------- .../authentication/authentication.spec.ts | 3 +- tests/integration/oauth/oauth.spec.ts | 1 - tests/integration/otp/otp.security.spec.ts | 14 +- tests/integration/totp/totp.spec.ts | 2 +- tests/setup/env.ts | 1 - tests/setup/mocks.ts | 5 - tests/unit/controllers/authentication.spec.ts | 11 +- tests/unit/controllers/otp.spec.ts | 41 +-- tests/unit/lib/cookie.spec.ts | 145 --------- tests/unit/lib/defineRoute.spec.ts | 53 +--- .../middleware/attachAuthMiddleware.spec.ts | 29 +- tests/unit/openapi/document.spec.ts | 18 +- .../bootstrapPromotionService.spec.ts | 29 +- .../unit/services/sessionIssueService.spec.ts | 236 ++++++--------- validateEnvs.sh | 5 +- 49 files changed, 242 insertions(+), 1635 deletions(-) delete mode 100644 src/lib/bootstrapCookie.ts delete mode 100644 src/lib/cookie.ts delete mode 100644 src/middleware/verifyCookieAuth.ts delete mode 100644 tests/e2e/auth.happy.spec.ts delete mode 100644 tests/integration/auth/cookieAuth.security.spec.ts delete mode 100644 tests/integration/auth/cookieAuth.spec.ts delete mode 100644 tests/unit/lib/cookie.spec.ts diff --git a/.env.example b/.env.example index d6facd4..7c381b5 100644 --- a/.env.example +++ b/.env.example @@ -14,9 +14,6 @@ APP_ORIGINS=http://localhost:3000 ISSUER=http://localhost:5312 -# "web" for website to auth server, "server" for api server to auth server auth -AUTH_MODE=server - # Roles assigned to every new user DEFAULT_ROLES=user,betaUser # Roles that are allowed in the system @@ -47,7 +44,7 @@ RATE_LIMIT=100 DELAY_AFTER=50 # SERVICE TOKENS -# Required when AUTH_MODE=server. +# Required for trusted server adapters and internal bearer validation. API_SERVICE_TOKEN=32-byte-hex-string # Optional dedicated secret for indexed refresh-token lookup fingerprints. # If unset, the server falls back to API_SERVICE_TOKEN, and in development only diff --git a/AGENTS.md b/AGENTS.md index 6c48c64..1c60687 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,10 +43,11 @@ There are three token states worth keeping straight: - Access token: signed JWT used for authenticated application access. - Refresh token: opaque random token stored hashed in the `sessions` table. -Auth behavior depends on `AUTH_MODE`: +The API exposes a single bearer/JSON auth contract: -- `web`: access/refresh/ephemeral tokens are primarily stored in cookies. -- `server`: endpoints expect bearer tokens more often and return token payloads in JSON. +- Ephemeral, access, and refresh tokens are returned in JSON response payloads. +- Protected routes expect bearer credentials from a trusted server adapter or backend. +- The API does not set or read browser auth cookies. Auth middleware is chosen centrally by [src/middleware/attachAuthMiddleware.ts](/Users/brandoncorbett/git/seamless-auth-api/src/middleware/attachAuthMiddleware.ts). @@ -74,7 +75,7 @@ Direct provider wiring currently lives in [src/config/directMessaging.ts](/Users - When adding or updating routes, use `schemas`, not `schema`, so request parsing and OpenAPI generation both work. - If a route requires auth, prefer the `auth` option in `createRouter` definitions. That keeps middleware wiring and OpenAPI security metadata aligned. - If a route also needs admin checks or rate limiting, combine `auth` with extra `middleware`. -- Be careful around cookie names and auth mode branching. Several flows have separate `web` and `server` response shapes. +- Be careful around token response shapes and bearer auth. Browser-cookie auth mode has been removed. - Preserve existing local worktree changes unless the user explicitly asks you to clean them up. ## Before You Finish A Change diff --git a/README.md b/README.md index f9a246c..f907e32 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ This repository does **not** assume any specific cloud provider, billing system, ## Why Seamless Auth API - Passwordless-first design (no passwords to steal) -- Modern session handling using secure, HTTP-only cookies +- Bearer/JSON auth API with opaque refresh tokens and signed access tokens - WebAuthn / passkeys support - Optional WebAuthn PRF support for products that need browser-local key material - Token and JWKS support for service-to-service auth @@ -155,7 +155,7 @@ The browser/client flow is: 4. The provider redirects back to your `redirectUri` with `code` and `state`. 5. The client posts `{ code, state }` to `POST /oauth/:providerId/callback`. 6. Seamless Auth validates state, exchanges the code, fetches userinfo, links or creates the local - user, and issues the normal SeamlessAuth access/refresh session. + user, and issues the normal SeamlessAuth access/refresh JSON payload. Example direct API start request: @@ -316,7 +316,7 @@ You should receive a healthy response. For production deployments: - Use HTTPS -- Configure secure cookies +- Use a trusted server adapter or backend when exposing auth flows to browsers - Rotate signing keys - Back up your database - Monitor authentication failures @@ -332,7 +332,7 @@ Authentication infrastructure is security-sensitive. For production deployments: - Use HTTPS end-to-end -- Enable secure cookies (`Secure`, correct `SameSite`) +- Keep access and refresh tokens out of browser-readable storage - Restrict CORS origins - Rotate signing keys and secrets regularly - Enable database backups and test restores diff --git a/docs/architecture.md b/docs/architecture.md index 1c88776..09154ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,6 @@ Global behavior is configured in [src/app.ts](/Users/brandoncorbett/git/seamless - `helmet` - JSON body parsing - CORS -- cookie parsing - request logging - rate limiting and slow-down outside test mode - development-only OpenAPI and Swagger UI @@ -56,7 +55,7 @@ Behavior: - finds or creates the user - issues an ephemeral token - optionally sends or returns phone OTP delivery info -- stores bootstrap token in a cookie when present +- stores bootstrap invite token hashes in challenge context when present Registration does not itself create the long-lived session. It prepares the user for OTP, magic link, or WebAuthn completion. @@ -115,28 +114,22 @@ Key concepts: - refresh tokens are opaque random values stored only as bcrypt hashes - access tokens are signed JWTs using the JWKS-managed signing key -- cookie auth can silently refresh sessions +- protected API routes require bearer authentication from a trusted server adapter - session reuse detection revokes the replacement chain When debugging auth bugs, inspect both the token code and the `sessions` table behavior. The middleware validates both JWT claims and backing session state. -## 4. Auth Modes +## 4. Auth Contract -`AUTH_MODE` changes the public contract of several endpoints. +The API exposes a single bearer/JSON auth contract. -### `web` +- login and registration continuation endpoints return ephemeral tokens in JSON +- session completion returns access and refresh tokens in JSON +- protected routes accept `Authorization: Bearer ...` +- refresh endpoints expect the raw opaque refresh token as bearer credentials -- tokens are primarily communicated via cookies -- cookie middleware is the normal auth path -- session issuance writes `seamless_access`, `seamless_refresh`, and `seamless_ephemeral` - -### `server` - -- more endpoints return token material in JSON -- bearer validation is used more heavily -- refresh endpoints expect bearer credentials rather than browser cookies - -When modifying a controller that returns auth state, verify both branches. Many regressions in this codebase would only show up in one mode. +Browser-facing integrations should use a trusted server adapter that can translate application +sessions into SeamlessAuth bearer calls. The API no longer sets or reads browser auth cookies. ## 5. Config And Secrets @@ -234,9 +227,8 @@ Practical guidance: - Some routes declare auth through `middleware: [attachAuthMiddleware(...)]` instead of the `auth` field. That works at runtime, but OpenAPI security metadata is only added by the `auth` field today. - `defineRoute` expects `schemas`, plural. Using `schema` silently skips request parsing and docs wiring. -- Cookie names in runtime code are `seamless_access`, `seamless_refresh`, and `seamless_ephemeral`. Confirm docs and tests against those exact names. - `system_config` can mask env changes after first bootstrap because the DB value becomes authoritative. -- Silent refresh and refresh-token matching depend on scanning active sessions and comparing bcrypt hashes, so session-heavy scenarios are worth extra care. +- Refresh-token matching depends on indexed lookup fingerprints with a legacy bcrypt fallback, so session-heavy scenarios are worth extra care. ## 10. Suggested Workflow For Agents diff --git a/package-lock.json b/package-lock.json index aa6fb43..260fad9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "@types/bcrypt": "^6.0.0", "base64url": "^3.0.1", "bcrypt-ts": "^7.1.0", - "cookie-parser": "^1.4.7", "cors": "^2.8.5", "express": "^4.19.2", "express-rate-limit": "^7.5.0", @@ -41,7 +40,6 @@ "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", "@eslint/js": "^9.25.1", - "@types/cookie-parser": "^1.4.8", "@types/cors": "^2.8.17", "@types/express": "^4.17.24", "@types/jsonwebtoken": "^9.0.6", @@ -3427,16 +3425,6 @@ "@types/node": "*" } }, - "node_modules/@types/cookie-parser": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", - "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/express": "*" - } - }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -5009,19 +4997,6 @@ "node": ">= 0.6" } }, - "node_modules/cookie-parser": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", - "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", - "license": "MIT", - "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.6" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/package.json b/package.json index 309459e..8c9f59e 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "@types/bcrypt": "^6.0.0", "base64url": "^3.0.1", "bcrypt-ts": "^7.1.0", - "cookie-parser": "^1.4.7", "cors": "^2.8.5", "express": "^4.19.2", "express-rate-limit": "^7.5.0", @@ -69,7 +68,6 @@ "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", "@eslint/js": "^9.25.1", - "@types/cookie-parser": "^1.4.8", "@types/cors": "^2.8.17", "@types/express": "^4.17.24", "@types/jsonwebtoken": "^9.0.6", diff --git a/src/app.ts b/src/app.ts index ef7fc7e..fdd3bd6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,7 +4,6 @@ * See LICENSE file in the project root for full license information */ -import cookieParser from 'cookie-parser'; import cors, { CorsOptions } from 'cors'; import express, { NextFunction, Request, Response } from 'express'; import helmet from 'helmet'; @@ -84,7 +83,6 @@ if (process.env.NODE_ENV !== 'test') { app.use(express.json()); app.use(cors(corsOptions)); -app.use(cookieParser()); app.use(logRoute); diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index 129ef14..ac8c8b7 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -7,7 +7,6 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; -import { clearAuthCookies, setAuthCookies } from '../lib/cookie.js'; import { createRefreshTokenLookup, generateRefreshToken, @@ -37,7 +36,6 @@ import { } from '../utils/utils.js'; const logger = getLogger('authentication'); -const AUTH_MODE = process.env.AUTH_MODE; export const login = async (req: Request, res: Response) => { // For the initial login step, user either passes in an email or a phone number @@ -181,12 +179,6 @@ export const login = async (req: Request, res: Response) => { metadata: {}, }); - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { ephemeralToken: token }); - res.status(200).json({ message: 'Success', loginMethods }); - return; - } - const { access_token_ttl } = await getSystemConfig(); return res.status(200).json({ message: 'Success', @@ -234,8 +226,6 @@ export const logout = async (req: Request, res: Response) => { } catch (error) { logger.error(`Error during logout: ${error}`); await AuthEventService.log({ userId: authUser.id, type: 'logout_failed', req }); - } finally { - clearAuthCookies(res); } return res.json({ message: 'Success' }); @@ -246,14 +236,10 @@ export const refreshSession = async (req: Request, res: Response) => { let refreshToken: string | null = null; - if (AUTH_MODE === 'server' && req.headers.authorization?.startsWith('Bearer ')) { + if (req.headers.authorization?.startsWith('Bearer ')) { refreshToken = req.headers.authorization.slice('Bearer '.length); } - if (!refreshToken && AUTH_MODE === 'web') { - refreshToken = req.cookies?.seamless_refresh ?? null; - } - if (!refreshToken) { logger.error('Refresh token provided is not of expected type for auth server configurations'); await AuthEventService.refreshTokenFailed(req, { reason: 'Missing refresh token' }); @@ -342,7 +328,7 @@ export const refreshSession = async (req: Request, res: Response) => { const newSession = await Session.create({ userId: user.id, infraId: session.infraId, - mode: session.mode, + mode: 'server', organizationId: session.organizationId, refreshTokenHash: newRefreshTokenHash, refreshTokenLookup: newRefreshTokenLookup, @@ -363,12 +349,6 @@ export const refreshSession = async (req: Request, res: Response) => { ); await AuthEventService.log({ userId: user.id, type: 'refresh_token_success', req }); - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { accessToken: token, refreshToken: newRefreshToken }); - res.status(200).json({ message: 'Success' }); - return; - } - const { access_token_ttl, refresh_token_ttl } = await getSystemConfig(); return res.status(200).json({ message: 'Success', diff --git a/src/controllers/internalSecurity.ts b/src/controllers/internalSecurity.ts index 4549e9b..7a7e37e 100644 --- a/src/controllers/internalSecurity.ts +++ b/src/controllers/internalSecurity.ts @@ -20,7 +20,6 @@ export const getSecurityAnomalies = async (_req: Request, res: Response) => { try { const FAILURE_TYPES = [ 'login_failed', - 'cookie_token_failed', 'bearer_token_failed', 'jwks_failed', 'mfa_otp_failed', diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index 84c900b..9716121 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -25,7 +25,6 @@ import { hashDeviceFingerprint, hashSha256 } from '../utils/utils.js'; const logger = getLogger('magic-links'); const TTL_MINUTES = 15; -const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; async function rejectDisabledMagicLink(req: Request, res: Response, userId?: string | null) { const policy = await getLoginPolicy(); @@ -264,12 +263,11 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) { }, req, res, - authMode: AUTH_MODE, - clearBootstrap: true, }); user.update({ lastLogin: new Date(), + challengeContext: null, }); return; diff --git a/src/controllers/oauth.ts b/src/controllers/oauth.ts index 72a5de1..0996c12 100644 --- a/src/controllers/oauth.ts +++ b/src/controllers/oauth.ts @@ -22,8 +22,6 @@ import { } from '../services/oauthService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; -const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; - function allowedReturnTo(value: string | undefined, origins: string[]) { if (!value) return undefined; return origins.some((origin) => value.startsWith(origin)) ? value : undefined; @@ -137,8 +135,6 @@ export async function finishOAuthLogin(req: Request, res: Response) { }, req, res, - authMode: AUTH_MODE, - clearExistingCookies: true, }); } catch { await AuthEventService.log({ diff --git a/src/controllers/organizations.ts b/src/controllers/organizations.ts index 594e61e..7159ccd 100644 --- a/src/controllers/organizations.ts +++ b/src/controllers/organizations.ts @@ -7,7 +7,6 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; -import { setAuthCookies } from '../lib/cookie.js'; import { signAccessToken } from '../lib/token.js'; import { OrganizationMembership } from '../models/organizationMemberships.js'; import { Organization } from '../models/organizations.js'; @@ -145,14 +144,6 @@ export async function switchOrganization(req: Request, res: Response) { await session.update({ organizationId: organization.id }); const token = await signAccessToken(session.id, user.id, user.roles, organization.id); - if (process.env.AUTH_MODE === 'web') { - await setAuthCookies(res, { accessToken: token }); - return res.json({ - message: 'Success', - organization: serializeOrganization(organization, membership), - }); - } - const { access_token_ttl } = await getSystemConfig(); return res.json({ message: 'Success', diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index a1f8864..c2732f3 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -6,7 +6,6 @@ import { Request, Response } from 'express'; -import { setAuthCookies } from '../lib/cookie.js'; import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { signEphemeralToken } from '../lib/token.js'; import { AuthEventService } from '../services/authEventService.js'; @@ -27,7 +26,6 @@ import { import { isValidEmail, isValidPhoneNumber, normalizePhoneNumber } from '../utils/utils.js'; const logger = getLogger('otp'); -const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; async function rejectDisabledLoginMethod( method: LoginMethod, @@ -109,22 +107,6 @@ export const sendPhoneOTP = async (req: Request, res: Response) => { const token = await signEphemeralToken(user.id); - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { ephemeralToken: token }); - return res.status(200).json({ - message: 'success', - ...(useExternalDelivery - ? { - delivery: { - kind: 'otp_sms', - to: normalizedPhone, - token: generatedToken, - }, - } - : {}), - }); - } - return res.status(200).json({ message: 'success', token, @@ -203,22 +185,6 @@ export const sendEmailOTP = async (req: Request, res: Response) => { const token = await signEphemeralToken(user.id); - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { ephemeralToken: token }); - return res.status(200).json({ - message: 'success', - ...(useExternalDelivery - ? { - delivery: { - kind: 'otp_email', - to: email, - token: generatedToken, - }, - } - : {}), - }); - } - return res.status(200).json({ message: 'success', token, @@ -403,7 +369,6 @@ export const verifyEmail = async (req: Request, res: Response) => { }, req, res, - authMode: AUTH_MODE, }); user.update({ @@ -488,7 +453,6 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { }, req, res, - authMode: AUTH_MODE, }); user.update({ @@ -592,7 +556,6 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { }, req, res, - authMode: AUTH_MODE, }); user.update({ diff --git a/src/controllers/registration.ts b/src/controllers/registration.ts index a9b5eec..886465e 100644 --- a/src/controllers/registration.ts +++ b/src/controllers/registration.ts @@ -7,31 +7,30 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; -import { setBootstrapCookie } from '../lib/bootstrapCookie.js'; -import { setAuthCookies } from '../lib/cookie.js'; import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { signEphemeralToken } from '../lib/token.js'; import { AuthEvent } from '../models/authEvents.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; +import { + BOOTSTRAP_INVITE_TOKEN_HASH_CONTEXT_KEY, + createBootstrapInviteTokenHash, +} from '../services/bootstrapPromotionService.js'; import getLogger from '../utils/logger.js'; import { generatePhoneOTP } from '../utils/otp.js'; import { isValidEmail, isValidPhoneNumber, normalizePhoneNumber } from '../utils/utils.js'; const logger = getLogger('registration'); -const AUTH_MODE = process.env.AUTH_MODE; export const register = async (req: Request, res: Response) => { const { email, phone, bootstrapToken } = req.body; const useExternalDelivery = await canReturnExternalDelivery(req); const normalizedEmail = email?.toLowerCase(); const normalizedPhone = typeof phone === 'string' ? normalizePhoneNumber(phone) : null; - - if (bootstrapToken && bootstrapToken.length > 10) { - setBootstrapCookie(res, bootstrapToken); - - logger.info('Bootstrap token stored in cookie for registration flow'); - } + const bootstrapInviteTokenHash = + typeof bootstrapToken === 'string' && bootstrapToken.length > 10 + ? createBootstrapInviteTokenHash(bootstrapToken) + : null; const systemConfig = await getSystemConfig(); logger.info(`Registering phone and email account`); @@ -98,6 +97,16 @@ export const register = async (req: Request, res: Response) => { token = await signEphemeralToken(user.id); + if (bootstrapInviteTokenHash) { + await user.update({ + challengeContext: { + ...(user.challengeContext ?? {}), + [BOOTSTRAP_INVITE_TOKEN_HASH_CONTEXT_KEY]: bootstrapInviteTokenHash, + }, + }); + logger.info('Bootstrap token hash stored for registration flow'); + } + phoneOtp = await generatePhoneOTP(user, { sendMessage: !useExternalDelivery, }); @@ -108,6 +117,13 @@ export const register = async (req: Request, res: Response) => { email: normalizedEmail, phone: normalizedPhone, roles: systemConfig.default_roles, + ...(bootstrapInviteTokenHash + ? { + challengeContext: { + [BOOTSTRAP_INVITE_TOKEN_HASH_CONTEXT_KEY]: bootstrapInviteTokenHash, + }, + } + : {}), }); await AuthEventService.log({ @@ -145,15 +161,6 @@ export const register = async (req: Request, res: Response) => { } : undefined; - if (AUTH_MODE === 'web') { - await setAuthCookies(res, { ephemeralToken: token }); - res.status(200).json({ - message: 'Success', - ...(delivery ? { delivery } : {}), - }); - return; - } - return res.status(200).json({ message: 'Success', sub: user.id, diff --git a/src/controllers/totp.ts b/src/controllers/totp.ts index dc2295d..d6633f3 100644 --- a/src/controllers/totp.ts +++ b/src/controllers/totp.ts @@ -21,7 +21,6 @@ import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; const logger = getLogger('totp'); -const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; function serializeDate(value: Date | null) { return value?.toISOString() ?? null; @@ -190,8 +189,6 @@ export const verifyTotpLogin = async (req: Request, res: Response) => { }, req, res, - authMode: AUTH_MODE, - clearExistingCookies: true, }); await user.update({ diff --git a/src/controllers/user.ts b/src/controllers/user.ts index 5ded4d1..49e8a7d 100644 --- a/src/controllers/user.ts +++ b/src/controllers/user.ts @@ -6,7 +6,6 @@ import { Request, Response } from 'express'; -import { clearAuthCookies } from '../lib/cookie.js'; import { AuthEvent } from '../models/authEvents.js'; import { Credential } from '../models/credentials.js'; import { User } from '../models/users.js'; @@ -71,7 +70,6 @@ export const getUser = async (req: Request, res: Response) => { req, metadata: { reason: 'Error occured' }, }); - clearAuthCookies(res); res.status(500).json({ message: 'Internal server error' }); return; } @@ -87,7 +85,6 @@ export const deleteUser = async (req: Request, res: Response) => { } logger.info(`${authUser.email} trigger the deletion of their account`); - clearAuthCookies(res); try { const user = await User.findOne({ @@ -136,13 +133,6 @@ export const deleteUser = async (req: Request, res: Response) => { } } catch (error) { logger.error(`Error occured deleting a user: ${error}`); - try { - clearAuthCookies(res); - return res.json({ message: 'Success' }); - } catch (error) { - logger.error(`Couldn't delete all cookies. ${error}`); - } - return res.status(500).json({ message: `Failed` }); } }; diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index 8fbc358..40d7b47 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -26,14 +26,15 @@ import { AuthEvent } from '../models/authEvents.js'; import { Credential } from '../models/credentials.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; -import { maybePromoteBootstrapAdmin } from '../services/bootstrapPromotionService.js'; +import { + getBootstrapInviteTokenHash, + maybePromoteBootstrapAdmin, +} from '../services/bootstrapPromotionService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; const logger = getLogger('webauthn'); -const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; - function getRegistrationChallengeContext(user: User) { const webauthnRegistration = user.challengeContext?.webauthnRegistration; @@ -127,6 +128,7 @@ const registerWebAuthn = async (req: Request, res: Response) => { await verifiedUser.update({ challenge: options.challenge, challengeContext: { + ...(verifiedUser.challengeContext ?? {}), webauthnRegistration: { prfRequested, requirePrf, @@ -253,6 +255,7 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { const { credential, credentialBackedUp, credentialDeviceType } = registrationInfo; const challengeContext = getRegistrationChallengeContext(user); + const bootstrapInviteTokenHash = getBootstrapInviteTokenHash(user); const prfCapable = getRegistrationPrfCapable(attestationResponse) || metadata.prfCapable === true; @@ -296,6 +299,7 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { user, req, completionMethod: 'webauthn_registration', + bootstrapInviteTokenHash, }); if (bootstrapResult.promoted) { @@ -319,8 +323,6 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { }, req, res, - authMode: AUTH_MODE, - clearBootstrap: true, }); user.update({ @@ -551,8 +553,6 @@ const verifyWebAuthn = async (req: Request, res: Response) => { }, req, res, - authMode: AUTH_MODE, - clearExistingCookies: true, }); user.update({ diff --git a/src/lib/bootstrapCookie.ts b/src/lib/bootstrapCookie.ts deleted file mode 100644 index 4c8411b..0000000 --- a/src/lib/bootstrapCookie.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright © 2026 Fells Code, LLC - * Licensed under the GNU Affero General Public License v3.0 - * See LICENSE file in the project root for full license information - */ - -import { Request, Response } from 'express'; - -import getLogger from '../utils/logger.js'; - -const COOKIE_NAME = 'seamless_bootstrap_token'; - -const logger = getLogger('bootstrapCookie'); - -export function setBootstrapCookie(res: Response, token: string) { - res.cookie(COOKIE_NAME, token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', - maxAge: 15 * 60 * 1000, - path: '/', - }); -} - -export function getBootstrapCookie(req: Request): string | null { - logger.debug( - `Checking for bootstrap cookie. Cookie value: ${req.cookies?.[COOKIE_NAME] ?? null}`, - ); - return req.cookies?.[COOKIE_NAME] ?? null; -} - -export function clearBootstrapCookie(res: Response) { - logger.debug(`Clearing bootstrap cookie.`); - res.clearCookie(COOKIE_NAME, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', - path: '/', - }); -} diff --git a/src/lib/cookie.ts b/src/lib/cookie.ts deleted file mode 100644 index 65b30ac..0000000 --- a/src/lib/cookie.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright © 2026 Fells Code, LLC - * Licensed under the GNU Affero General Public License v3.0 - * See LICENSE file in the project root for full license information - */ - -import { Response } from 'express'; - -import { getSystemConfig } from '../config/getSystemConfig.js'; -import { parseDurationToSeconds } from '../utils/utils.js'; - -export async function setAuthCookies( - res: Response, - cookie: { - accessToken?: string; - refreshToken?: string; - ephemeralToken?: string; - }, -) { - const { accessToken, refreshToken, ephemeralToken } = cookie; - - if (accessToken) { - const { access_token_ttl } = await getSystemConfig(); - - res.cookie('seamless_access', accessToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', - path: '/', - maxAge: parseDurationToSeconds(access_token_ttl || '15m') * 1000, - }); - } - - if (refreshToken) { - const { refresh_token_ttl } = await getSystemConfig(); - res.cookie('seamless_refresh', refreshToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', - maxAge: parseDurationToSeconds(refresh_token_ttl || '1h') * 1000, - }); - } - - if (ephemeralToken) { - res.cookie('seamless_ephemeral', ephemeralToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', - maxAge: 5 * 60 * 1000, - }); - } -} - -export function clearAuthCookies(res: Response) { - res.clearCookie('seamless_access', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - }); - res.clearCookie('seamless_refresh', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - }); - res.clearCookie('seamless_ephemeral', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - }); -} diff --git a/src/lib/defineRoute.ts b/src/lib/defineRoute.ts index e61f3e1..c9be4ce 100644 --- a/src/lib/defineRoute.ts +++ b/src/lib/defineRoute.ts @@ -13,7 +13,7 @@ import { getSecuritySchemeName, } from '../middleware/attachAuthMiddleware.js'; import { registry } from '../openapi/registry.js'; -import { CookieType } from '../services/sessionService.js'; +import { AuthTokenType } from '../services/sessionService.js'; import getLogger from '../utils/logger.js'; import { expressToOpenAPI } from './convertPath.js'; import { InferRequest, RouteSchemas } from './routeTypes.js'; @@ -31,7 +31,7 @@ interface DefineRouteOptions { description?: string; tags?: string[]; - auth?: CookieType | undefined; + auth?: AuthTokenType | undefined; schemas?: S; @@ -95,9 +95,9 @@ function buildResponses( } function resolveAuthType( - auth: CookieType | undefined, + auth: AuthTokenType | undefined, middleware: RequestHandler[] | undefined, -): CookieType | undefined { +): AuthTokenType | undefined { if (auth) { return auth; } diff --git a/src/middleware/attachAuthMiddleware.ts b/src/middleware/attachAuthMiddleware.ts index acd6344..f66e692 100644 --- a/src/middleware/attachAuthMiddleware.ts +++ b/src/middleware/attachAuthMiddleware.ts @@ -6,31 +6,21 @@ import { RequestHandler } from 'express'; -import { CookieType } from '../services/sessionService.js'; +import { AuthTokenType } from '../services/sessionService.js'; import { verifyBearerAuth } from './verifyBearerAuth.js'; -import { verifyCookieAuth } from './verifyCookieAuth.js'; export type AuthAwareRequestHandler = RequestHandler & { - seamlessAuthType?: CookieType; + seamlessAuthType?: AuthTokenType; }; -export function getSecuritySchemeName(cookieType: CookieType): string { - const mode = (process.env.AUTH_MODE || 'web').toLowerCase(); - - if (mode === 'server') { - return 'bearerAuth'; - } - - return cookieType === 'ephemeral' ? 'ephemeralCookieAuth' : 'accessCookieAuth'; +export function getSecuritySchemeName(_authType: AuthTokenType): string { + return 'bearerAuth'; } -export function attachAuthMiddleware(cookieType: CookieType = 'access') { - const mode = (process.env.AUTH_MODE || 'web').toLowerCase(); - const handler = ( - mode === 'server' ? verifyBearerAuth : verifyCookieAuth(cookieType) - ) as AuthAwareRequestHandler; +export function attachAuthMiddleware(authType: AuthTokenType = 'access') { + const handler = verifyBearerAuth as AuthAwareRequestHandler; - handler.seamlessAuthType = cookieType; + handler.seamlessAuthType = authType; return handler; } diff --git a/src/middleware/verifyCookieAuth.ts b/src/middleware/verifyCookieAuth.ts deleted file mode 100644 index 504181a..0000000 --- a/src/middleware/verifyCookieAuth.ts +++ /dev/null @@ -1,229 +0,0 @@ -/* - * Copyright © 2026 Fells Code, LLC - * Licensed under the GNU Affero General Public License v3.0 - * See LICENSE file in the project root for full license information - */ - -import { NextFunction, Request, Response } from 'express'; - -import { clearAuthCookies, setAuthCookies } from '../lib/cookie.js'; -import { - createRefreshTokenLookup, - generateRefreshToken, - hashRefreshToken, - signAccessToken, -} from '../lib/token.js'; -import { Session } from '../models/sessions.js'; -import { User } from '../models/users.js'; -import { AuthEventService } from '../services/authEventService.js'; -import { - CookieType, - findRefreshSessionByToken, - getUserFromSession, - hardRevokeSession, - revokeSessionChain, - validateAccessToken, - validateSessionRecord, - verifyJwtWithKid, -} from '../services/sessionService.js'; -import { AuthenticatedRequest } from '../types/types.js'; -import getLogger from '../utils/logger.js'; -import { computeSessionTimes } from '../utils/utils.js'; - -const logger = getLogger('verify-cookie'); - -export function verifyCookieAuth(cookieType: CookieType = 'access') { - return async (req: Request, res: Response, next: NextFunction) => { - try { - const cookies = req.cookies || {}; - - if (cookieType === 'ephemeral') { - const ephemeralCookie = cookies['seamless_ephemeral']; - - if (!ephemeralCookie) { - clearAuthCookies(res); - return res.status(401).json({ error: 'unauthorized' }); - } - - const payload = await verifyJwtWithKid(ephemeralCookie, cookieType); - if (!payload) { - clearAuthCookies(res); - return res.status(401).json({ error: 'unauthorized' }); - } - - const user = await User.findOne({ - where: { id: payload.sub, revoked: false }, - }); - - if (!user) { - clearAuthCookies(res); - return res.status(401).json({ error: 'unauthorized' }); - } - - (req as AuthenticatedRequest).user = user; - return next(); - } - - const accessCookie = cookies['seamless_access']; - - // Try validating existing access token first - if (accessCookie) { - logger.debug(`Validating access cookie`); - const accessCookie = cookies['seamless_access']; - - if (accessCookie) { - const tokenData = await validateAccessToken(accessCookie); - - if (tokenData) { - const session = await validateSessionRecord(tokenData.sessionId as string); - - if (session) { - const user = await getUserFromSession(session); - - if (user) { - (req as AuthenticatedRequest).user = user; - (req as AuthenticatedRequest).sessionId = session.id; - (req as AuthenticatedRequest).organizationId = tokenData.organizationId; - return next(); - } - } - } - } - } - - // Access token missing or invalid, try silent refresh - const refreshedUser = await performSilentRefresh(req, res); - - if (refreshedUser) { - (req as AuthenticatedRequest).user = refreshedUser; - return next(); - } - - // If we reach here, both access & refresh failed - //clearAuthCookies(res); - return res.status(401).json({ error: 'unauthorized' }); - } catch (err) { - logger.error('verifyCookieAuth error:', err); - clearAuthCookies(res); - return res.status(401).json({ error: 'unauthorized' }); - } - }; -} - -async function performSilentRefresh(req: Request, res: Response): Promise { - const cookies = req.cookies || {}; - const refreshToken = cookies['seamless_refresh']; - - if (!refreshToken) { - logger.debug('No refresh cookie present for silent refresh'); - return null; - } - - const now = new Date(); - logger.debug(`Validating refresh cookie`); - const { session, legacyFallbackCandidates, usedLegacyFallback } = await findRefreshSessionByToken( - refreshToken, - now, - ); - - if (!session) { - logger.warn( - `No matching session found for refresh token. legacyFallbackCandidates=${legacyFallbackCandidates}`, - ); - await AuthEventService.refreshTokenFailed(req, { - reason: 'No matching session found for refresh token', - legacyFallbackCandidates, - }); - return null; - } - - if (usedLegacyFallback) { - logger.info( - `Silent refresh matched a legacy session without refreshTokenLookup. sessionId=${session.id} fallbackCandidates=${legacyFallbackCandidates}`, - ); - } - - // Reuse detection - if (session.replacedBySessionId || session.revokedAt) { - logger.warn('Refresh token reuse detected'); - await revokeSessionChain(session); - await AuthEventService.log({ - userId: session.userId, - type: 'refresh_token_suspicious', - req, - metadata: { - reason: 'Refresh token reuse detected', - sessionId: session.id, - replacedBySessionId: session.replacedBySessionId, - revokedAt: session.revokedAt?.toISOString() ?? null, - }, - }); - return null; - } - - // Confirm user - const user = await User.findByPk(session.userId); - if (!user) { - logger.warn(`Mismatched users from a refresh token and session. Logging supicious activity.`); - AuthEventService.log({ - userId: session.userId, - type: 'refresh_token_suspicious', - req, - metadata: { reason: 'Refresh token user id did not match session user id.' }, - }); - await hardRevokeSession(session, 'user_not_found'); - return null; - } - - // Log refresh attempt - logger.info(`User token refreshed.`); - await AuthEventService.log({ - userId: user.id, - type: 'informational', - req, - metadata: { reason: 'Web silent refresh' }, - }); - - const { expiresAt, idleExpiresAt } = computeSessionTimes(now); - - const newRefreshToken = generateRefreshToken(); - const newRefreshTokenHash = await hashRefreshToken(newRefreshToken); - const newRefreshTokenLookup = createRefreshTokenLookup(newRefreshToken); - - const newSession = await Session.create({ - userId: user.id, - infraId: session.infraId, - mode: session.mode, - organizationId: session.organizationId, - refreshTokenHash: newRefreshTokenHash, - refreshTokenLookup: newRefreshTokenLookup, - userAgent: session.userAgent, - ipAddress: req.ip, - expiresAt, - idleExpiresAt, - }); - - session.replacedBySessionId = newSession.id; - session.lastUsedAt = now; - await session.save(); - - const accessToken = await signAccessToken( - newSession.id, - user.id, - user.roles, - session.organizationId, - ); - - await setAuthCookies(res, { - accessToken, - refreshToken: newRefreshToken, - }); - - await AuthEventService.log({ - userId: user.id, - type: 'refresh_token_success', - req, - }); - - return user; -} diff --git a/src/models/sessions.ts b/src/models/sessions.ts index fc82848..e6bbefe 100644 --- a/src/models/sessions.ts +++ b/src/models/sessions.ts @@ -11,7 +11,7 @@ export interface SessionAttributes { userId: string; infraId?: string | null; organizationId?: string | null; - mode: 'web' | 'server'; + mode: 'server'; refreshTokenHash: string; refreshTokenLookup?: string | null; userAgent?: string | null; @@ -49,7 +49,7 @@ export class Session declare userId: string; declare infraId: string | null; declare organizationId: string | null; - declare mode: 'web' | 'server'; + declare mode: 'server'; declare refreshTokenHash: string; declare refreshTokenLookup: string | null; declare userAgent: string | null; @@ -91,7 +91,7 @@ const initializeSessionModel = (sequelize: Sequelize) => { type: DataTypes.STRING, allowNull: false, validate: { - isIn: [['web', 'server']], + isIn: [['server']], }, }, refreshTokenHash: { diff --git a/src/openapi/document.ts b/src/openapi/document.ts index a104d60..dc43492 100644 --- a/src/openapi/document.ts +++ b/src/openapi/document.ts @@ -35,16 +35,6 @@ export function generateOpenApiDocument() { scheme: 'bearer', bearerFormat: 'JWT', }, - accessCookieAuth: { - type: 'apiKey', - in: 'cookie', - name: 'seamless_access', - }, - ephemeralCookieAuth: { - type: 'apiKey', - in: 'cookie', - name: 'seamless_ephemeral', - }, }, }; diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index 247baef..15a19fe 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -14,9 +14,6 @@ export const AUTH_EVENT_TYPES = [ 'bearer_token_suspicious', 'bootstrap_admin_check_skipped', 'bootstrap_admin_granted', - 'cookie_token_failed', - 'cookie_token_success', - 'cookie_token_suspicious', 'credentials_deleted', 'informational', 'internal_user_updated_by_owner', diff --git a/src/schemas/generic.responses.ts b/src/schemas/generic.responses.ts index 44ef52f..7153f99 100644 --- a/src/schemas/generic.responses.ts +++ b/src/schemas/generic.responses.ts @@ -33,6 +33,7 @@ export const AuthDeliverySchema = z.discriminatedUnion('kind', [ export const MessageSchema = z.object({ message: z.string(), + token: z.string().optional(), delivery: AuthDeliverySchema.optional(), }); diff --git a/src/server.ts b/src/server.ts index 1b6c96c..93995c7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,7 +28,6 @@ async function startServer() { app.listen(PORT as number, HOST, () => { logger.info(`Server online.`); - logger.info(`Running in ${process.env.AUTH_MODE} auth mode`); }); } catch (err) { logger.error('Failed to start server:', err); diff --git a/src/services/bootstrapPromotionService.ts b/src/services/bootstrapPromotionService.ts index 99b4e6e..ebadf77 100644 --- a/src/services/bootstrapPromotionService.ts +++ b/src/services/bootstrapPromotionService.ts @@ -8,7 +8,6 @@ import crypto from 'crypto'; import { Request } from 'express'; import { literal, Transaction } from 'sequelize'; -import { getBootstrapCookie } from '../lib/bootstrapCookie.js'; import { hasScopedRole } from '../lib/scopedRoles.js'; import { BootstrapInvite } from '../models/bootstrapInvites.js'; import { getSequelize } from '../models/index.js'; @@ -18,6 +17,8 @@ import { AuthEventService } from './authEventService.js'; type CompletionMethod = 'webauthn_registration' | 'magic_link_fallback' | 'email_otp' | 'phone_otp'; +export const BOOTSTRAP_INVITE_TOKEN_HASH_CONTEXT_KEY = 'bootstrapInviteTokenHash'; + type PromotionResult = | { promoted: true; reason: 'success' } | { @@ -43,6 +44,15 @@ function hashBootstrapToken(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); } +export function createBootstrapInviteTokenHash(token: string): string { + return hashBootstrapToken(token); +} + +export function getBootstrapInviteTokenHash(user: User): string | null { + const value = user.challengeContext?.[BOOTSTRAP_INVITE_TOKEN_HASH_CONTEXT_KEY]; + return typeof value === 'string' && value.length > 0 ? value : null; +} + function isBootstrapEnabled(): boolean { return process.env.SEAMLESS_BOOTSTRAP_ENABLED === 'true'; } @@ -62,6 +72,7 @@ export async function maybePromoteBootstrapAdmin(params: { user: User; req: Request; completionMethod: CompletionMethod; + bootstrapInviteTokenHash?: string | null; }): Promise { const { user, req, completionMethod } = params; logger.debug('checking for promotion'); @@ -86,13 +97,12 @@ export async function maybePromoteBootstrapAdmin(params: { return { promoted: false, reason: 'already_admin' }; } - const rawToken = getBootstrapCookie(req); - if (!rawToken) { + const tokenHash = params.bootstrapInviteTokenHash ?? getBootstrapInviteTokenHash(user); + if (!tokenHash) { logSkip('Missing token'); return { promoted: false, reason: 'missing_token' }; } - const tokenHash = hashBootstrapToken(rawToken); const now = new Date(); const invite = await BootstrapInvite.findOne({ diff --git a/src/services/sessionIssuance.ts b/src/services/sessionIssuance.ts index 8894190..32d9f28 100644 --- a/src/services/sessionIssuance.ts +++ b/src/services/sessionIssuance.ts @@ -7,8 +7,6 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; -import { clearBootstrapCookie } from '../lib/bootstrapCookie.js'; -import { clearAuthCookies, setAuthCookies } from '../lib/cookie.js'; import { createRefreshTokenLookup, generateRefreshToken, @@ -28,13 +26,10 @@ type IssueSessionParams = { }; req: Request; res: Response; - authMode: 'web' | 'server'; - clearBootstrap?: boolean; - clearExistingCookies?: boolean; }; export async function issueSessionAndRespond(params: IssueSessionParams): Promise { - const { user, req, res, authMode, clearBootstrap = false, clearExistingCookies = false } = params; + const { user, req, res } = params; const refreshToken = generateRefreshToken(); const refreshTokenHash = await hashRefreshToken(refreshToken); @@ -46,7 +41,7 @@ export async function issueSessionAndRespond(params: IssueSessionParams): Promis userId: user.id, infraId: process.env.APP_ID!, organizationId, - mode: authMode, + mode: 'server', refreshTokenHash, refreshTokenLookup, userAgent: req.get('user-agent'), @@ -62,20 +57,6 @@ export async function issueSessionAndRespond(params: IssueSessionParams): Promis throw new Error('Failed to issue session tokens'); } - if (clearExistingCookies) { - clearAuthCookies(res); - } - - if (clearBootstrap) { - clearBootstrapCookie(res); - } - - if (authMode === 'web') { - await setAuthCookies(res, { accessToken: token, refreshToken }); - res.status(200).json({ message: 'Success' }); - return; - } - const { access_token_ttl, refresh_token_ttl } = await getSystemConfig(); res.status(200).json({ diff --git a/src/services/sessionService.ts b/src/services/sessionService.ts index b2e7df7..2cb0b69 100644 --- a/src/services/sessionService.ts +++ b/src/services/sessionService.ts @@ -18,7 +18,7 @@ import { getPublicKeyByKid } from '../utils/signingKeyStore.js'; const logger = getLogger('sessionService'); -export type CookieType = 'ephemeral' | 'access'; +export type AuthTokenType = 'ephemeral' | 'access'; let cachedSecret: string | null = null; @@ -28,12 +28,6 @@ async function getInternalSecret() { return cachedSecret; } -export interface ValidateSessionInput { - type: 'cookie' | 'bearer'; - value: string; - cookieType?: CookieType; -} - const ISSUER = process.env.ISSUER!; export async function verifyJwtWithKid(token: string, expectedType?: 'access' | 'ephemeral') { diff --git a/tests/e2e/auth.happy.spec.ts b/tests/e2e/auth.happy.spec.ts deleted file mode 100644 index f43a01a..0000000 --- a/tests/e2e/auth.happy.spec.ts +++ /dev/null @@ -1,156 +0,0 @@ -import request from 'supertest'; -import { describe, it, expect, beforeAll, vi, afterAll } from 'vitest'; - -vi.unmock('../../src/models/authEvents.js'); -vi.unmock('../../src/models/sessions.js'); -vi.unmock('../../src/models/users.js'); -vi.unmock('../../src/models/systemConfig.js'); -vi.unmock('../../src/models/credentials.js'); -vi.unmock('../../src/models/magicLinks.js'); -vi.unmock('../../src/services/sessionService.js'); -vi.unmock('../../src/services/authEventService.js'); -vi.unmock('../../src/models'); -vi.unmock('../../src/services/messagingService.js'); -vi.unmock('../../src/lib/cookie.js'); -vi.unmock('../../src/lib/token.js'); -vi.unmock('../../src/middleware/attachAuthMiddleware.js'); -vi.unmock('../../src/middleware/verifyCookieAuth.js'); - -vi.unmock('../../src/config/getSystemConfig.js'); -vi.unmock('../../src/utils/utils.js'); -vi.unmock('../../src/utils/otp.js'); -vi.unmock('../../src/utils/token.js'); -vi.unmock('../../src/utils/cookie.js'); -vi.unmock('../../src/utils/secretStore.js'); - -vi.unmock('bcrypt-ts'); - -let app: any; -const shouldRunE2E = process.env.CI !== 'true' && process.env.TEST_DB === 'postgres'; - -beforeAll(async () => { - if (!shouldRunE2E) { - return; - } - - vi.stubEnv('NODE_ENV', 'test'); - vi.stubEnv('AUTH_MODE', 'web'); - - vi.stubEnv('DB_DIALECT', 'postgres'); - vi.stubEnv('DB_HOST', 'localhost'); - vi.stubEnv('DB_PORT', '5432'); - vi.stubEnv('DB_NAME', 'seamless_auth_test'); - vi.stubEnv('DB_USER', 'myuser'); - vi.stubEnv('DB_PASSWORD', 'mypassword'); - - vi.stubEnv('ISSUER', 'test-issuer'); - vi.stubEnv('APP_ID', 'test-app'); - - vi.stubEnv('JWKS_ACTIVE_KIDe', 'dev-main'); - vi.stubEnv('API_SERVICE_TOKEN', 'service-token'); - - vi.stubEnv('DEFAULT_ROLES', 'user'); - vi.stubEnv('AVAILABLE_ROLES', 'user,admin'); - vi.stubEnv('ACCESS_TOKEN_TTL', '15m'); - vi.stubEnv('REFRESH_TOKEN_TTL', '1h'); - vi.stubEnv('RATE_LIMIT', '100'); - vi.stubEnv('DELAY_AFTER', '50'); - vi.stubEnv('RPID', 'localhost'); - vi.stubEnv('ORIGINS', 'http://localhost'); - vi.stubEnv('APP_NAME', 'TestApp'); - - const { initializeModels } = await import('../../src/models'); - const models = await initializeModels(); - - await models.sequelize.sync({ force: true }); - - const { bootstrapSystemConfig } = await import('../../src/config/bootstrapSystemConfig'); - await bootstrapSystemConfig(); - - const { createApp } = await import('../../src/app'); - app = await createApp(); -}); - -afterAll(() => { - vi.unstubAllEnvs(); -}); - -(shouldRunE2E ? it : it.skip)('full auth lifecycle works', async () => { - const email = 'test@example.com'; - const phone = '+14155552671'; - - const registerRes = await request(app).post('/registration/register').send({ email, phone }); - - expect(registerRes.status).toBe(200); - - const cookies = registerRes.headers['set-cookie']; - expect(cookies).toBeDefined(); - - const otpRes = await request(app).get('/otp/generate-phone-otp').set('Cookie', cookies); - - expect(otpRes.status).toBe(200); - - const { User } = await import('../../src/models/users'); - - const user = await User.findOne({ where: { email } }); - - expect(user).toBeDefined(); - const otp = user?.phoneVerificationToken; - - expect(otp).toBeDefined(); - - const verifyRes = await request(app) - .post('/otp/verify-phone-otp') - .set('Cookie', cookies) - .send({ verificationToken: otp }); - - expect(verifyRes.status).toBe(200); - - const emailOtpRes = await request(app).get('/otp/generate-email-otp').set('Cookie', cookies); - - expect(emailOtpRes.status).toBe(200); - - await user?.reload(); - const emailOtp = user?.emailVerificationToken; - - expect(emailOtp).toBeDefined(); - - const emailVerifyRes = await request(app) - .post('/otp/verify-email-otp') - .set('Cookie', cookies) - .send({ verificationToken: emailOtp }); - - expect(emailVerifyRes.status).toBe(200); - - let authCookies = emailVerifyRes.headers['set-cookie']; - expect(authCookies).toBeDefined(); - - const meRes = await request(app).get('/users/me').set('Cookie', authCookies); - - const maybeNewCookies = meRes.headers['set-cookie']; - if (maybeNewCookies) { - authCookies = maybeNewCookies; - } - - expect(meRes.status).toBe(200); - expect(Array.isArray(meRes.body.user)).toBeDefined(); - - const brokenCookies = (authCookies as unknown as string[]).filter( - (c: string) => !c.includes('seamless_access'), - ); - - expect(brokenCookies.some((c) => c.includes('seamless_refresh'))).toBe(true); - - const refreshRes = await request(app).get('/users/me').set('Cookie', brokenCookies); - - expect(refreshRes.status).toBe(200); - - const refreshedCookies = refreshRes.headers['set-cookie']; - expect(refreshedCookies).toBeDefined(); - - authCookies = refreshedCookies; - - const logoutRes = await request(app).get('/logout').set('Cookie', authCookies); - - expect(logoutRes.status).toBe(200); -}); diff --git a/tests/e2e/authFlow.spec.ts b/tests/e2e/authFlow.spec.ts index a6af42c..669985e 100644 --- a/tests/e2e/authFlow.spec.ts +++ b/tests/e2e/authFlow.spec.ts @@ -74,7 +74,7 @@ describe('E2E Auth Flow', () => { const otpRes = await request(app) .get('/otp/generate-phone-otp') - .set('Cookie', [`seamless_ephemeral=ephemeral-token`]); + .set('Authorization', 'Bearer ephemeral-token'); expect(otpRes.status).toBe(200); expect(generatePhoneOTP).toHaveBeenCalled(); @@ -96,7 +96,7 @@ describe('E2E Auth Flow', () => { const verifyRes = await request(app) .post('/otp/verify-phone-otp') - .set('Cookie', [`seamless_ephemeral=ephemeral-token`]) + .set('Authorization', 'Bearer ephemeral-token') .send({ verificationToken: '123456' }); expect(verifyRes.status).toBe(200); @@ -108,7 +108,7 @@ describe('E2E Auth Flow', () => { (Session.findAll as any).mockResolvedValue([buildSession()]); const accessRes = await request(app) .get('/sessions') - .set('Cookie', [`seamless_access=access-token`]); + .set('Authorization', 'Bearer access-token'); expect(accessRes.status).toBe(200); @@ -127,7 +127,7 @@ describe('E2E Auth Flow', () => { const refreshRes = await request(app) .get('/sessions') - .set('Cookie', [`seamless_refresh=refresh-token`]); + .set('Authorization', 'Bearer access-token'); expect(refreshRes.status).toBe(200); }); diff --git a/tests/integration/auth/cookieAuth.security.spec.ts b/tests/integration/auth/cookieAuth.security.spec.ts deleted file mode 100644 index a101858..0000000 --- a/tests/integration/auth/cookieAuth.security.spec.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { verifyCookieAuth } from '../../../src/middleware/verifyCookieAuth.js'; -import { clearAuthCookies, setAuthCookies } from '../../../src/lib/cookie.js'; -import { Session } from '../../../src/models/sessions.js'; -import { User } from '../../../src/models/users.js'; -import { AuthEventService } from '../../../src/services/authEventService.js'; -import { - findRefreshSessionByToken, - getUserFromSession, - hardRevokeSession, - revokeSessionChain, - validateAccessToken, - validateSessionRecord, - verifyJwtWithKid, -} from '../../../src/services/sessionService.js'; -import { - createRefreshTokenLookup, - generateRefreshToken, - hashRefreshToken, - signAccessToken, -} from '../../../src/lib/token.js'; - -function mockReqRes(cookies: Record = {}) { - const req: any = { - cookies, - ip: '127.0.0.1', - headers: { 'user-agent': 'vitest' }, - get: vi.fn((name: string) => { - if (name.toLowerCase() === 'user-agent') return 'vitest'; - return undefined; - }), - }; - - const res: any = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - }; - - const next = vi.fn(); - - return { req, res, next }; -} - -function buildRefreshSession(overrides: Record = {}) { - return { - id: 'session-1', - userId: 'user-1', - infraId: 'app-1', - mode: 'web', - refreshTokenHash: 'hashed-refresh', - refreshTokenLookup: 'refresh-lookup', - userAgent: 'vitest', - ipAddress: '127.0.0.1', - replacedBySessionId: null, - revokedAt: null, - save: vi.fn(), - ...overrides, - }; -} - -beforeEach(() => { - vi.clearAllMocks(); - - (generateRefreshToken as any).mockReturnValue('new-refresh-token'); - (hashRefreshToken as any).mockResolvedValue('new-refresh-hash'); - (createRefreshTokenLookup as any).mockReturnValue('new-refresh-lookup'); - (signAccessToken as any).mockResolvedValue('new-access-token'); - - (Session.create as any).mockResolvedValue({ id: 'session-2' }); -}); - -describe('verifyCookieAuth security - ephemeral', () => { - it('returns 401 and clears cookies when ephemeral cookie is missing', async () => { - const middleware = verifyCookieAuth('ephemeral'); - const { req, res, next } = mockReqRes(); - - await middleware(req, res, next); - - expect(clearAuthCookies).toHaveBeenCalledWith(res); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); - - it('returns 401 when ephemeral jwt is invalid', async () => { - (verifyJwtWithKid as any).mockResolvedValue(null); - - const middleware = verifyCookieAuth('ephemeral'); - const { req, res, next } = mockReqRes({ - seamless_ephemeral: 'bad-token', - }); - - await middleware(req, res, next); - - expect(clearAuthCookies).toHaveBeenCalledWith(res); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); -}); - -describe('verifyCookieAuth security - access path', () => { - it('returns 401 when access token is valid structurally but session record is invalid', async () => { - (validateAccessToken as any).mockResolvedValue({ sessionId: 'session-1' }); - (validateSessionRecord as any).mockResolvedValue(null); - - const middleware = verifyCookieAuth('access'); - const { req, res, next } = mockReqRes({ - seamless_access: 'access-token', - }); - - await middleware(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); - }); - - it('returns 401 when access token session resolves but user lookup fails', async () => { - (validateAccessToken as any).mockResolvedValue({ sessionId: 'session-1' }); - (validateSessionRecord as any).mockResolvedValue({ id: 'session-1' }); - (getUserFromSession as any).mockResolvedValue(null); - - const middleware = verifyCookieAuth('access'); - const { req, res, next } = mockReqRes({ - seamless_access: 'access-token', - }); - - await middleware(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); - }); -}); - -describe('verifyCookieAuth security - silent refresh', () => { - it('returns 401 when refresh cookie is present but no matching session is found', async () => { - (validateAccessToken as any).mockResolvedValue(null); - (findRefreshSessionByToken as any).mockResolvedValue({ - session: null, - legacyFallbackCandidates: 0, - usedLegacyFallback: false, - }); - (AuthEventService.refreshTokenFailed as any).mockResolvedValue(undefined); - - const middleware = verifyCookieAuth('access'); - const { req, res, next } = mockReqRes({ - seamless_refresh: 'refresh-token', - }); - - await middleware(req, res, next); - - expect(AuthEventService.refreshTokenFailed).toHaveBeenCalledWith( - req, - expect.objectContaining({ - reason: 'No matching session found for refresh token', - legacyFallbackCandidates: 0, - }), - ); - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); - }); - - it('detects refresh token reuse when session was already replaced', async () => { - (validateAccessToken as any).mockResolvedValue(null); - - const reusedSession = buildRefreshSession({ - replacedBySessionId: 'session-2', - }); - - (findRefreshSessionByToken as any).mockResolvedValue({ - session: reusedSession, - legacyFallbackCandidates: 0, - usedLegacyFallback: false, - }); - (revokeSessionChain as any).mockResolvedValue(undefined); - (AuthEventService.log as any).mockResolvedValue(undefined); - - const middleware = verifyCookieAuth('access'); - const { req, res, next } = mockReqRes({ - seamless_refresh: 'refresh-token', - }); - - await middleware(req, res, next); - - expect(revokeSessionChain).toHaveBeenCalledWith(reusedSession); - expect(AuthEventService.log).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'user-1', - type: 'refresh_token_suspicious', - }), - ); - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); - }); - - it('detects refresh token reuse when session is already revoked', async () => { - (validateAccessToken as any).mockResolvedValue(null); - - const revokedSession = buildRefreshSession({ - revokedAt: new Date(), - }); - - (findRefreshSessionByToken as any).mockResolvedValue({ - session: revokedSession, - legacyFallbackCandidates: 0, - usedLegacyFallback: false, - }); - (revokeSessionChain as any).mockResolvedValue(undefined); - (AuthEventService.log as any).mockResolvedValue(undefined); - - const middleware = verifyCookieAuth('access'); - const { req, res, next } = mockReqRes({ - seamless_refresh: 'refresh-token', - }); - - await middleware(req, res, next); - - expect(revokeSessionChain).toHaveBeenCalledWith(revokedSession); - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); - }); - - it('hard-revokes when refresh session user no longer exists', async () => { - (validateAccessToken as any).mockResolvedValue(null); - - const session = buildRefreshSession(); - - (findRefreshSessionByToken as any).mockResolvedValue({ - session, - legacyFallbackCandidates: 0, - usedLegacyFallback: false, - }); - (User.findByPk as any).mockResolvedValue(null); - (hardRevokeSession as any).mockResolvedValue(undefined); - - const middleware = verifyCookieAuth('access'); - const { req, res, next } = mockReqRes({ - seamless_refresh: 'refresh-token', - }); - - await middleware(req, res, next); - - expect(hardRevokeSession).toHaveBeenCalledWith(session, 'user_not_found'); - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); - }); - - it('rotates session and sets fresh cookies on successful refresh', async () => { - (validateAccessToken as any).mockResolvedValue(null); - - const session = buildRefreshSession(); - - (findRefreshSessionByToken as any).mockResolvedValue({ - session, - legacyFallbackCandidates: 0, - usedLegacyFallback: false, - }); - (User.findByPk as any).mockResolvedValue({ id: 'user-1' }); - - const middleware = verifyCookieAuth('access'); - const { req, res, next } = mockReqRes({ - seamless_refresh: 'refresh-token', - }); - - await middleware(req, res, next); - - expect(Session.create).toHaveBeenCalled(); - expect(Session.create).toHaveBeenCalledWith( - expect.objectContaining({ refreshTokenLookup: 'new-refresh-lookup' }), - ); - expect(session.save).toHaveBeenCalled(); - expect(setAuthCookies).toHaveBeenCalledWith( - res, - expect.objectContaining({ - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - }), - ); - expect(next).toHaveBeenCalled(); - }); -}); diff --git a/tests/integration/auth/cookieAuth.spec.ts b/tests/integration/auth/cookieAuth.spec.ts deleted file mode 100644 index 4908004..0000000 --- a/tests/integration/auth/cookieAuth.spec.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { verifyCookieAuth } from '../../../src/middleware/verifyCookieAuth.js'; -import { clearAuthCookies } from '../../../src/lib/cookie.js'; - -import { - findRefreshSessionByToken, - validateAccessToken, - validateSessionRecord, - getUserFromSession, - verifyJwtWithKid, -} from '../../../src/services/sessionService.js'; - -vi.mock('../../../src/models/authEvents.js', () => ({ - AuthEvent: { - create: vi.fn(), - }, -})); - -import { User } from '../../../src/models/users.js'; -import { Session } from '../../../src/models/sessions.js'; -import { generateRefreshToken, hashRefreshToken, signAccessToken } from '../../../src/lib/token.js'; - -function mockReqRes(cookies: any = {}) { - const req: any = { - cookies, - ip: '127.0.0.1', - headers: {}, - }; - - const res: any = { - status: vi.fn().mockReturnThis(), - json: vi.fn(), - }; - - const next = vi.fn(); - - return { req, res, next }; -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe('verifyCookieAuth - ephemeral', () => { - it('rejects missing cookie', async () => { - const middleware = verifyCookieAuth('ephemeral'); - const { req, res, next } = mockReqRes(); - - await middleware(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - }); - - it('accepts valid ephemeral token', async () => { - (verifyJwtWithKid as any).mockResolvedValue({ sub: 'user-1' }); - - (User.findOne as any).mockResolvedValue({ - id: 'user-1', - revoked: false, - }); - - const middleware = verifyCookieAuth('ephemeral'); - - const { req, res, next } = mockReqRes({ - seamless_ephemeral: 'token', - }); - - await middleware(req, res, next); - - expect(req.user).toBeDefined(); - expect(next).toHaveBeenCalled(); - }); - - it('rejects invalid ephemeral token with 401', async () => { - (verifyJwtWithKid as any).mockResolvedValue(null); - - const middleware = verifyCookieAuth('ephemeral'); - - const { req, res, next } = mockReqRes({ - seamless_ephemeral: 'bad-token', - }); - - await middleware(req, res, next); - - expect(clearAuthCookies).toHaveBeenCalledWith(res); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); -}); - -describe('verifyCookieAuth - access token', () => { - it('uses valid access token', async () => { - (validateAccessToken as any).mockResolvedValue({ - sessionId: 'session-1', - }); - - (validateSessionRecord as any).mockResolvedValue({ - id: 'session-1', - }); - - (getUserFromSession as any).mockResolvedValue({ - id: 'user-1', - }); - - const middleware = verifyCookieAuth('access'); - - const { req, res, next } = mockReqRes({ - seamless_access: 'access-token', - }); - - await middleware(req, res, next); - - expect(req.user).toBeDefined(); - expect(next).toHaveBeenCalled(); - }); - - it('returns 401 when no cookies', async () => { - const middleware = verifyCookieAuth('access'); - - const { req, res, next } = mockReqRes(); - - await middleware(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - }); -}); - -describe('verifyCookieAuth - silent refresh', () => { - it('refreshes session when access token invalid', async () => { - (validateAccessToken as any).mockResolvedValue(null); - (findRefreshSessionByToken as any).mockResolvedValue({ - session: { - id: 'session-1', - refreshTokenHash: 'hash', - userId: 'user-1', - infraId: 'app', - mode: 'web', - userAgent: 'agent', - replacedBySessionId: null, - revokedAt: null, - save: vi.fn(), - }, - legacyFallbackCandidates: 0, - usedLegacyFallback: false, - }); - - (User.findByPk as any).mockResolvedValue({ - id: 'user-1', - }); - - (generateRefreshToken as any).mockReturnValue('refresh-token'); - (hashRefreshToken as any).mockResolvedValue('hashed-refresh'); - (signAccessToken as any).mockResolvedValue('access-token'); - - (Session.create as any).mockResolvedValue({ - id: 'new-session', - }); - - const middleware = verifyCookieAuth('access'); - - const { req, res, next } = mockReqRes({ - seamless_refresh: 'refresh-token', - }); - - await middleware(req, res, next); - - expect(next).toHaveBeenCalled(); - }); -}); diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index 5d8aaf0..60f271d 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -33,7 +33,6 @@ vi.mock('../../../src/middleware/attachAuthMiddleware.js', async (importOriginal }); beforeAll(async () => { - vi.stubEnv('AUTH_MODE', 'server'); app = await createApp(); }); @@ -188,7 +187,7 @@ describe('POST /refresh', () => { revokedAt: null, userId: 'user-1', infraId: 'app', - mode: 'web', + mode: 'server', userAgent: 'agent', save: vi.fn(), }; diff --git a/tests/integration/oauth/oauth.spec.ts b/tests/integration/oauth/oauth.spec.ts index 4ffd75f..867733e 100644 --- a/tests/integration/oauth/oauth.spec.ts +++ b/tests/integration/oauth/oauth.spec.ts @@ -41,7 +41,6 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks(); - vi.stubEnv('AUTH_MODE', 'server'); vi.stubEnv('GOOGLE_CLIENT_SECRET', 'secret'); (getSystemConfig as any).mockResolvedValue( buildSystemConfig({ diff --git a/tests/integration/otp/otp.security.spec.ts b/tests/integration/otp/otp.security.spec.ts index a22456e..359e8ec 100644 --- a/tests/integration/otp/otp.security.spec.ts +++ b/tests/integration/otp/otp.security.spec.ts @@ -20,7 +20,7 @@ describe('OTP Security - Phone Verification', () => { it('rejects missing verification token', async () => { const res = await request(app) .post('/otp/verify-phone-otp') - .set('Cookie', ['seamless_ephemeral=token']) + .set('Authorization', 'Bearer token') .send({}); expect(res.status).toBe(400); @@ -34,7 +34,7 @@ describe('OTP Security - Phone Verification', () => { const res = await request(app) .post('/otp/verify-phone-otp') - .set('Cookie', ['seamless_ephemeral=token']) + .set('Authorization', 'Bearer token') .send({ verificationToken: 'bad-token' }); expect(res.status).toBe(401); @@ -59,7 +59,7 @@ describe('OTP Security - Phone Verification', () => { const res = await request(app) .post('/otp/verify-phone-otp') - .set('Cookie', ['seamless_ephemeral=token']) + .set('Authorization', 'Bearer token') .send({ verificationToken: '123456' }); expect(res.status).toBe(401); @@ -84,7 +84,7 @@ describe('OTP Security - Phone Verification', () => { const res = await request(app) .post('/otp/verify-phone-otp') - .set('Cookie', ['seamless_ephemeral=token']) + .set('Authorization', 'Bearer token') .send({ verificationToken: '123456' }); expect(res.status).toBe(401); @@ -95,7 +95,7 @@ describe('OTP Security - Email Verification', () => { it('rejects missing verification token', async () => { const res = await request(app) .post('/otp/verify-email-otp') - .set('Cookie', ['seamless_ephemeral=token']) + .set('Authorization', 'Bearer token') .send({}); expect(res.status).toBe(400); @@ -109,7 +109,7 @@ describe('OTP Security - Email Verification', () => { const res = await request(app) .post('/otp/verify-email-otp') - .set('Cookie', ['seamless_ephemeral=token']) + .set('Authorization', 'Bearer token') .send({ verificationToken: 'bad' }); // ⚠️ matches your current controller behavior @@ -135,7 +135,7 @@ describe('OTP Security - Email Verification', () => { const res = await request(app) .post('/otp/verify-email-otp') - .set('Cookie', ['seamless_ephemeral=token']) + .set('Authorization', 'Bearer token') .send({ verificationToken: '123456' }); expect(res.status).toBe(500); diff --git a/tests/integration/totp/totp.spec.ts b/tests/integration/totp/totp.spec.ts index 4618436..5baa19f 100644 --- a/tests/integration/totp/totp.spec.ts +++ b/tests/integration/totp/totp.spec.ts @@ -82,9 +82,9 @@ describe('TOTP routes', () => { expect(issueSessionAndRespondMock).toHaveBeenCalledWith( expect.objectContaining({ user: expect.objectContaining({ id: 'user-1' }), - clearExistingCookies: true, }), ); + expect(issueSessionAndRespondMock.mock.calls[0][0]).not.toHaveProperty('clearExistingCookies'); }); it('verifies TOTP as MFA and records step-up freshness', async () => { diff --git a/tests/setup/env.ts b/tests/setup/env.ts index 784e424..ea8e70a 100644 --- a/tests/setup/env.ts +++ b/tests/setup/env.ts @@ -1,5 +1,4 @@ process.env.NODE_ENV = 'test'; -process.env.AUTH_MODE = 'api'; process.env.APP_ORIGINS = 'http://localhost:5174'; // Default: use mock DB mode diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 4a3f423..656872b 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -180,11 +180,6 @@ vi.mock('../../src/lib/token.js', () => ({ createRefreshTokenLookup: vi.fn(), })); -vi.mock('../../src/lib/cookie.js', () => ({ - setAuthCookies: vi.fn(), - clearAuthCookies: vi.fn(), -})); - vi.mock('bcrypt-ts', () => ({ compareSync: vi.fn(), })); diff --git a/tests/unit/controllers/authentication.spec.ts b/tests/unit/controllers/authentication.spec.ts index 46fc8f9..57b8199 100644 --- a/tests/unit/controllers/authentication.spec.ts +++ b/tests/unit/controllers/authentication.spec.ts @@ -27,8 +27,6 @@ function mockReqRes(authorization?: string) { } async function loadAuthenticationModule() { - vi.stubEnv('AUTH_MODE', 'server'); - const [ { refreshSession }, { getSystemConfig }, @@ -36,7 +34,6 @@ async function loadAuthenticationModule() { { User }, { AuthEventService }, tokenLib, - cookieLib, sessionService, ] = await Promise.all([ import('../../../src/controllers/authentication.js'), @@ -45,7 +42,6 @@ async function loadAuthenticationModule() { import('../../../src/models/users.js'), import('../../../src/services/authEventService.js'), import('../../../src/lib/token.js'), - import('../../../src/lib/cookie.js'), import('../../../src/services/sessionService.js'), ]); @@ -60,7 +56,6 @@ async function loadAuthenticationModule() { hashRefreshToken: tokenLib.hashRefreshToken, createRefreshTokenLookup: tokenLib.createRefreshTokenLookup, signAccessToken: tokenLib.signAccessToken, - setAuthCookies: cookieLib.setAuthCookies, }; } @@ -69,7 +64,7 @@ beforeEach(() => { }); describe('refreshSession', () => { - it('rejects missing refresh token in server mode', async () => { + it('rejects missing refresh token', async () => { const { refreshSession, AuthEventService } = await loadAuthenticationModule(); const { req, res } = mockReqRes(); @@ -112,7 +107,7 @@ describe('refreshSession', () => { expect(res.json).toHaveBeenCalledWith({ error: 'invalid_refresh_token' }); }); - it('rotates the session using the raw bearer refresh token in server mode', async () => { + it('rotates the session using the raw bearer refresh token', async () => { const { refreshSession, getSystemConfig, @@ -123,7 +118,6 @@ describe('refreshSession', () => { hashRefreshToken, createRefreshTokenLookup, signAccessToken, - setAuthCookies, } = await loadAuthenticationModule(); const { req, res } = mockReqRes('Bearer raw-refresh-token'); const user = buildUser({ id: 'user-1', roles: ['admin'] }); @@ -166,7 +160,6 @@ describe('refreshSession', () => { }), ); expect(signAccessToken).toHaveBeenCalledWith('session-2', user.id, user.roles, undefined); - expect(setAuthCookies).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ message: 'Success', diff --git a/tests/unit/controllers/otp.spec.ts b/tests/unit/controllers/otp.spec.ts index b74a69d..bb52bc7 100644 --- a/tests/unit/controllers/otp.spec.ts +++ b/tests/unit/controllers/otp.spec.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const setAuthCookiesMock = vi.fn(); const signEphemeralTokenMock = vi.fn(); const authEventLogMock = vi.fn(); const issueSessionAndRespondMock = vi.fn(); @@ -19,10 +18,6 @@ const loggerMock = { debug: vi.fn(), }; -vi.mock('../../../src/lib/cookie.js', () => ({ - setAuthCookies: setAuthCookiesMock, -})); - vi.mock('../../../src/lib/token.js', () => ({ signEphemeralToken: signEphemeralTokenMock, })); @@ -115,9 +110,8 @@ function buildRes() { return res; } -async function loadOtpController(authMode: 'web' | 'server' = 'server') { +async function loadOtpController() { vi.resetModules(); - vi.stubEnv('AUTH_MODE', authMode); return import('../../../src/controllers/otp.js'); } @@ -138,8 +132,8 @@ beforeEach(() => { }); describe('otp controller', () => { - it('returns external phone OTP delivery payload in server mode', async () => { - const { sendPhoneOTP } = await loadOtpController('server'); + it('returns external phone OTP delivery payload', async () => { + const { sendPhoneOTP } = await loadOtpController(); const user = buildUser(); const req = buildReq(user, { headers: { 'x-seamless-auth-delivery-mode': 'external' }, @@ -150,7 +144,6 @@ describe('otp controller', () => { expect(generatePhoneOTPMock).toHaveBeenCalledWith(user, { sendMessage: false }); expect(signEphemeralTokenMock).toHaveBeenCalledWith(user.id); - expect(setAuthCookiesMock).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ message: 'success', @@ -164,7 +157,7 @@ describe('otp controller', () => { }); it('rejects phone OTP requests when the user phone is missing', async () => { - const { sendPhoneOTP } = await loadOtpController('server'); + const { sendPhoneOTP } = await loadOtpController(); const user = buildUser({ phone: null }); const req = buildReq(user); const res = buildRes(); @@ -182,7 +175,7 @@ describe('otp controller', () => { }); it('rejects email OTP requests when the email is invalid', async () => { - const { sendEmailOTP } = await loadOtpController('server'); + const { sendEmailOTP } = await loadOtpController(); const user = buildUser({ email: 'bad-email' }); const req = buildReq(user); const res = buildRes(); @@ -195,8 +188,8 @@ describe('otp controller', () => { expect(res.json).toHaveBeenCalledWith({ error: 'Invalid data.' }); }); - it('sets cookies and returns external email delivery payload in web mode', async () => { - const { sendEmailOTP } = await loadOtpController('web'); + it('returns external email OTP delivery payload', async () => { + const { sendEmailOTP } = await loadOtpController(); const user = buildUser(); const req = buildReq(user, { headers: { 'x-seamless-auth-delivery-mode': 'external' }, @@ -206,10 +199,10 @@ describe('otp controller', () => { await sendEmailOTP(req, res); expect(generateEmailOTPMock).toHaveBeenCalledWith(user, { sendMessage: false }); - expect(setAuthCookiesMock).toHaveBeenCalledWith(res, { ephemeralToken: 'ephemeral-token' }); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ message: 'success', + token: 'ephemeral-token', delivery: { kind: 'otp_email', to: user.email, @@ -219,7 +212,7 @@ describe('otp controller', () => { }); it('rejects disabled login email OTP generation', async () => { - const { sendLoginEmailOTP } = await loadOtpController('server'); + const { sendLoginEmailOTP } = await loadOtpController(); const user = buildUser(); const req = buildReq(user); const res = buildRes(); @@ -237,7 +230,7 @@ describe('otp controller', () => { }); it('returns 401 when phone verification data is missing', async () => { - const { verifyPhoneNumber } = await loadOtpController('server'); + const { verifyPhoneNumber } = await loadOtpController(); const user = buildUser({ phoneVerificationToken: undefined, phoneVerificationTokenExpiry: null, @@ -254,7 +247,7 @@ describe('otp controller', () => { }); it('returns success without issuing a session when phone verification is partial', async () => { - const { verifyPhoneNumber } = await loadOtpController('server'); + const { verifyPhoneNumber } = await loadOtpController(); const verifiedUser = buildUser({ phoneVerified: true, emailVerified: false, @@ -277,7 +270,7 @@ describe('otp controller', () => { }); it('issues a session when login phone verification fully verifies the user', async () => { - const { verifyLoginPhoneNumber } = await loadOtpController('server'); + const { verifyLoginPhoneNumber } = await loadOtpController(); const verifiedUser = buildUser(); const req = buildReq(buildUser(), { body: { verificationToken: '123456' }, @@ -300,7 +293,6 @@ describe('otp controller', () => { }, req, res, - authMode: 'server', }); expect(verifiedUser.update).toHaveBeenCalledWith({ lastLogin: expect.any(Date), @@ -308,7 +300,7 @@ describe('otp controller', () => { }); it('rejects disabled login phone OTP verification', async () => { - const { verifyLoginPhoneNumber } = await loadOtpController('server'); + const { verifyLoginPhoneNumber } = await loadOtpController(); const req = buildReq(buildUser(), { body: { verificationToken: '123456' }, }); @@ -327,7 +319,7 @@ describe('otp controller', () => { }); it('returns 401 when login phone verification fails', async () => { - const { verifyLoginPhoneNumber } = await loadOtpController('server'); + const { verifyLoginPhoneNumber } = await loadOtpController(); const failingUser = buildUser(); const req = buildReq(buildUser(), { body: { verificationToken: '123456' }, @@ -352,7 +344,7 @@ describe('otp controller', () => { }); it('issues a session when email verification fully verifies the user', async () => { - const { verifyEmail } = await loadOtpController('server'); + const { verifyEmail } = await loadOtpController(); const verifiedUser = buildUser(); const req = buildReq(buildUser(), { body: { verificationToken: 'EMAILOTP' }, @@ -375,7 +367,6 @@ describe('otp controller', () => { }, req, res, - authMode: 'server', }); expect(verifiedUser.update).toHaveBeenCalledWith({ lastLogin: expect.any(Date), @@ -383,7 +374,7 @@ describe('otp controller', () => { }); it('returns 500 when login email verification fails after lookup succeeds', async () => { - const { verifyLoginEmail } = await loadOtpController('server'); + const { verifyLoginEmail } = await loadOtpController(); const failingUser = buildUser(); const req = buildReq(buildUser(), { body: { verificationToken: 'EMAILOTP' }, diff --git a/tests/unit/lib/cookie.spec.ts b/tests/unit/lib/cookie.spec.ts deleted file mode 100644 index 5da3bd2..0000000 --- a/tests/unit/lib/cookie.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { vi } from 'vitest'; - -vi.unmock('../../../src/lib/cookie'); -vi.mock('../../../src/config/getSystemConfig', () => ({ - getSystemConfig: vi.fn(), -})); - -function buildRes() { - return { - cookie: vi.fn(), - clearCookie: vi.fn(), - } as any; -} - -import { describe, it, expect, beforeEach } from 'vitest'; -import { setAuthCookies, clearAuthCookies } from '../../../src/lib/cookie'; -import { getSystemConfig } from '../../../src/config/getSystemConfig'; - -describe('cookie utils', () => { - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - delete process.env.NODE_ENV; - }); - - describe('setAuthCookies', () => { - it('sets access token cookie', async () => { - const res = buildRes(); - - (getSystemConfig as any).mockResolvedValue({ - access_token_ttl: '15m', - }); - - await setAuthCookies(res, { accessToken: 'access' }); - - expect(res.cookie).toHaveBeenCalledWith( - 'seamless_access', - 'access', - expect.objectContaining({ - httpOnly: true, - path: '/', - }), - ); - }); - - it('sets refresh token cookie', async () => { - const res = buildRes(); - - (getSystemConfig as any).mockResolvedValue({ - refresh_token_ttl: '1h', - }); - - await setAuthCookies(res, { refreshToken: 'refresh' }); - - expect(res.cookie).toHaveBeenCalledWith( - 'seamless_refresh', - 'refresh', - expect.objectContaining({ - httpOnly: true, - }), - ); - }); - - it('sets ephemeral token cookie', async () => { - const res = buildRes(); - - await setAuthCookies(res, { ephemeralToken: 'temp' }); - - expect(res.cookie).toHaveBeenCalledWith( - 'seamless_ephemeral', - 'temp', - expect.objectContaining({ - httpOnly: true, - maxAge: 5 * 60 * 1000, - }), - ); - }); - - it('sets secure + sameSite in production', async () => { - process.env.NODE_ENV = 'production'; - - const res = buildRes(); - - (getSystemConfig as any).mockResolvedValue({ - access_token_ttl: '15m', - }); - - await setAuthCookies(res, { accessToken: 'access' }); - - expect(res.cookie).toHaveBeenCalledWith( - 'seamless_access', - 'access', - expect.objectContaining({ - secure: true, - sameSite: 'none', - }), - ); - }); - - it('uses default TTL when missing config', async () => { - const res = buildRes(); - - (getSystemConfig as any).mockResolvedValue({}); - - await setAuthCookies(res, { accessToken: 'access' }); - - expect(res.cookie).toHaveBeenCalled(); - }); - }); - - describe('clearAuthCookies', () => { - it('clears all cookies', () => { - const res = buildRes(); - - clearAuthCookies(res); - - expect(res.clearCookie).toHaveBeenCalledTimes(3); - expect(res.clearCookie).toHaveBeenCalledWith( - 'seamless_access', - expect.objectContaining({ httpOnly: true }), - ); - expect(res.clearCookie).toHaveBeenCalledWith( - 'seamless_refresh', - expect.objectContaining({ httpOnly: true }), - ); - expect(res.clearCookie).toHaveBeenCalledWith( - 'seamless_ephemeral', - expect.objectContaining({ httpOnly: true }), - ); - }); - - it('uses secure flag in production', () => { - process.env.NODE_ENV = 'production'; - - const res = buildRes(); - - clearAuthCookies(res); - - expect(res.clearCookie).toHaveBeenCalledWith( - 'seamless_access', - expect.objectContaining({ secure: true }), - ); - }); - }); -}); diff --git a/tests/unit/lib/defineRoute.spec.ts b/tests/unit/lib/defineRoute.spec.ts index 8b99180..7e2c48a 100644 --- a/tests/unit/lib/defineRoute.spec.ts +++ b/tests/unit/lib/defineRoute.spec.ts @@ -3,16 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; vi.mock('../../../src/middleware/attachAuthMiddleware.js', () => ({ - attachAuthMiddleware: vi.fn((cookieType: 'access' | 'ephemeral' = 'access') => - Object.assign(vi.fn(), { seamlessAuthType: cookieType }), + attachAuthMiddleware: vi.fn((authType: 'access' | 'ephemeral' = 'access') => + Object.assign(vi.fn(), { seamlessAuthType: authType }), ), - getSecuritySchemeName: vi.fn((cookieType: 'access' | 'ephemeral') => { - if (process.env.AUTH_MODE === 'server') { - return 'bearerAuth'; - } - - return cookieType === 'ephemeral' ? 'ephemeralCookieAuth' : 'accessCookieAuth'; - }), + getSecuritySchemeName: vi.fn(() => 'bearerAuth'), })); vi.mock('../../../src/openapi/registry', () => ({ @@ -25,10 +19,9 @@ describe('defineRoute', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - delete process.env.AUTH_MODE; }); - it('adds access cookie security when auth is inferred from middleware in web mode', async () => { + it('adds bearer security when auth is inferred from access middleware', async () => { const { defineRoute } = await import('../../../src/lib/defineRoute'); const { registry } = await import('../../../src/openapi/registry'); const middleware = Object.assign(vi.fn(), { seamlessAuthType: 'access' as const }); @@ -49,12 +42,12 @@ describe('defineRoute', () => { expect(registry.registerPath).toHaveBeenCalledWith( expect.objectContaining({ - security: [{ accessCookieAuth: [] }], + security: [{ bearerAuth: [] }], }), ); }); - it('adds ephemeral cookie security when auth is inferred from middleware in web mode', async () => { + it('adds bearer security when auth is inferred from ephemeral middleware', async () => { const { defineRoute } = await import('../../../src/lib/defineRoute'); const { registry } = await import('../../../src/openapi/registry'); const middleware = Object.assign(vi.fn(), { seamlessAuthType: 'ephemeral' as const }); @@ -73,34 +66,6 @@ describe('defineRoute', () => { handler: vi.fn(), }); - expect(registry.registerPath).toHaveBeenCalledWith( - expect.objectContaining({ - security: [{ ephemeralCookieAuth: [] }], - }), - ); - }); - - it('adds bearer security in server mode even when auth comes from middleware', async () => { - process.env.AUTH_MODE = 'server'; - - const { defineRoute } = await import('../../../src/lib/defineRoute'); - const { registry } = await import('../../../src/openapi/registry'); - const middleware = Object.assign(vi.fn(), { seamlessAuthType: 'access' as const }); - - defineRoute(Router(), { - method: 'get', - path: '/server-secure', - middleware: [middleware], - schemas: { - response: { - 200: z.object({ - message: z.string(), - }), - }, - }, - handler: vi.fn(), - }); - expect(registry.registerPath).toHaveBeenCalledWith( expect.objectContaining({ security: [{ bearerAuth: [] }], @@ -114,13 +79,13 @@ describe('defineRoute', () => { const { attachAuthMiddleware } = await import('../../../src/middleware/attachAuthMiddleware.js'); - (attachAuthMiddleware as any).mockImplementation((cookieType: 'access' | 'ephemeral') => + (attachAuthMiddleware as any).mockImplementation((authType: 'access' | 'ephemeral') => Object.assign( (_req: any, _res: any, next: () => void) => { - order.push(`auth:${cookieType}`); + order.push(`auth:${authType}`); next(); }, - { seamlessAuthType: cookieType }, + { seamlessAuthType: authType }, ), ); diff --git a/tests/unit/middleware/attachAuthMiddleware.spec.ts b/tests/unit/middleware/attachAuthMiddleware.spec.ts index af13272..8f25d55 100644 --- a/tests/unit/middleware/attachAuthMiddleware.spec.ts +++ b/tests/unit/middleware/attachAuthMiddleware.spec.ts @@ -1,35 +1,27 @@ -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.unmock('../../../src/middleware/verifyBearerAuth'); -vi.unmock('../../../src/middleware/verifyCookieAuth'); vi.unmock('../../../src/middleware/attachAuthMiddleware'); vi.mock('../../../src/middleware/verifyBearerAuth', () => ({ verifyBearerAuth: vi.fn((_req: any, _res: any, next: any) => next()), })); -vi.mock('../../../src/middleware/verifyCookieAuth', () => ({ - verifyCookieAuth: vi.fn(() => vi.fn((_req: any, _res: any, next: any) => next())), -})); - describe('attachAuthMiddleware', () => { beforeEach(() => { vi.resetModules(); - delete process.env.AUTH_MODE; }); - afterAll(() => { - vi.unstubAllEnvs(); - }); - it('defaults to cookie auth', async () => { + it('defaults to access bearer auth', async () => { const { attachAuthMiddleware } = await import('../../../src/middleware/attachAuthMiddleware'); + const { verifyBearerAuth } = await import('../../../src/middleware/verifyBearerAuth'); const middleware = attachAuthMiddleware(); + expect(middleware).toBe(verifyBearerAuth); expect(middleware.seamlessAuthType).toBe('access'); - expect(typeof middleware).toBe('function'); }); - it('uses ephemeral cookie', async () => { + it('tracks ephemeral bearer auth for route metadata', async () => { const { attachAuthMiddleware } = await import('../../../src/middleware/attachAuthMiddleware'); const middleware = attachAuthMiddleware('ephemeral'); @@ -37,13 +29,10 @@ describe('attachAuthMiddleware', () => { expect(typeof middleware).toBe('function'); }); - it('uses bearer in server mode', async () => { - vi.stubEnv('AUTH_MODE', 'server'); - - const { attachAuthMiddleware } = await import('../../../src/middleware/attachAuthMiddleware'); - const { verifyBearerAuth } = await import('../../../src/middleware/verifyBearerAuth'); - const res = attachAuthMiddleware(); + it('always maps protected routes to bearer security', async () => { + const { getSecuritySchemeName } = await import('../../../src/middleware/attachAuthMiddleware'); - expect(res).toBe(verifyBearerAuth); + expect(getSecuritySchemeName('access')).toBe('bearerAuth'); + expect(getSecuritySchemeName('ephemeral')).toBe('bearerAuth'); }); }); diff --git a/tests/unit/openapi/document.spec.ts b/tests/unit/openapi/document.spec.ts index 93b5156..7c7d51c 100644 --- a/tests/unit/openapi/document.spec.ts +++ b/tests/unit/openapi/document.spec.ts @@ -1,4 +1,3 @@ -import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; import { vi } from 'vitest'; vi.mock('fs', async () => { @@ -60,20 +59,17 @@ describe('getPackageVersion', () => { }); describe('generateOpenApiDocument', () => { - it('exposes the correct cookie auth schemes', async () => { + it('exposes bearer auth only', async () => { const { generateOpenApiDocument } = await import('../../../src/openapi/document'); const result = generateOpenApiDocument(); - expect(result.components.securitySchemes.accessCookieAuth).toEqual({ - type: 'apiKey', - in: 'cookie', - name: 'seamless_access', - }); - expect(result.components.securitySchemes.ephemeralCookieAuth).toEqual({ - type: 'apiKey', - in: 'cookie', - name: 'seamless_ephemeral', + expect(result.components.securitySchemes).toEqual({ + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, }); }); }); diff --git a/tests/unit/services/bootstrapPromotionService.spec.ts b/tests/unit/services/bootstrapPromotionService.spec.ts index 5f78528..9fa7660 100644 --- a/tests/unit/services/bootstrapPromotionService.spec.ts +++ b/tests/unit/services/bootstrapPromotionService.spec.ts @@ -1,13 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { maybePromoteBootstrapAdmin } from '../../../src/services/bootstrapPromotionService.js'; +import { + createBootstrapInviteTokenHash, + maybePromoteBootstrapAdmin, +} from '../../../src/services/bootstrapPromotionService.js'; // ---- mocks ---- -vi.mock('../../../src/lib/bootstrapCookie.js', () => ({ - getBootstrapCookie: vi.fn(), -})); - vi.mock('../../../src/models/bootstrapInvites.js', () => ({ BootstrapInvite: { findOne: vi.fn(), @@ -36,7 +35,6 @@ vi.mock('../../../src/services/authEventService.js', () => ({ // ---- imports AFTER mocks ---- -import { getBootstrapCookie } from '../../../src/lib/bootstrapCookie.js'; import { BootstrapInvite } from '../../../src/models/bootstrapInvites.js'; import { User } from '../../../src/models/users.js'; import { getSequelize } from '../../../src/models/index.js'; @@ -45,6 +43,7 @@ import { AuthEventService } from '../../../src/services/authEventService.js'; // ---- helpers ---- const mockReq = {} as any; +const inviteTokenHash = createBootstrapInviteTokenHash('token'); const baseUser = () => ({ @@ -121,9 +120,7 @@ it('skips if user already has admin write scope', async () => { }); }); -it('returns missing_token when no cookie', async () => { - (getBootstrapCookie as any).mockReturnValue(null); - +it('returns missing_token when no invite token hash is present', async () => { const result = await maybePromoteBootstrapAdmin({ user: baseUser(), req: mockReq, @@ -137,20 +134,19 @@ it('returns missing_token when no cookie', async () => { }); it('returns invalid_token when invite not found', async () => { - (getBootstrapCookie as any).mockReturnValue('token'); (BootstrapInvite.findOne as any).mockResolvedValue(null); const result = await maybePromoteBootstrapAdmin({ user: baseUser(), req: mockReq, completionMethod: 'webauthn_registration', + bootstrapInviteTokenHash: inviteTokenHash, }); expect(result.reason).toBe('invalid_token'); }); it('returns invite_consumed when already used', async () => { - (getBootstrapCookie as any).mockReturnValue('token'); (BootstrapInvite.findOne as any).mockResolvedValue({ ...validInvite(), consumedAt: new Date(), @@ -160,13 +156,13 @@ it('returns invite_consumed when already used', async () => { user: baseUser(), req: mockReq, completionMethod: 'webauthn_registration', + bootstrapInviteTokenHash: inviteTokenHash, }); expect(result.reason).toBe('invite_consumed'); }); it('returns invite_expired when expired', async () => { - (getBootstrapCookie as any).mockReturnValue('token'); (BootstrapInvite.findOne as any).mockResolvedValue({ ...validInvite(), expiresAt: new Date(Date.now() - 1000), @@ -176,13 +172,13 @@ it('returns invite_expired when expired', async () => { user: baseUser(), req: mockReq, completionMethod: 'webauthn_registration', + bootstrapInviteTokenHash: inviteTokenHash, }); expect(result.reason).toBe('invite_expired'); }); it('returns email_mismatch when emails differ', async () => { - (getBootstrapCookie as any).mockReturnValue('token'); (BootstrapInvite.findOne as any).mockResolvedValue({ ...validInvite(), email: 'other@example.com', @@ -192,13 +188,13 @@ it('returns email_mismatch when emails differ', async () => { user: baseUser(), req: mockReq, completionMethod: 'webauthn_registration', + bootstrapInviteTokenHash: inviteTokenHash, }); expect(result.reason).toBe('email_mismatch'); }); it('returns admin_exists when admin already present', async () => { - (getBootstrapCookie as any).mockReturnValue('token'); (BootstrapInvite.findOne as any).mockResolvedValue(validInvite()); (User.count as any).mockResolvedValue(1); @@ -207,13 +203,13 @@ it('returns admin_exists when admin already present', async () => { user: baseUser(), req: mockReq, completionMethod: 'webauthn_registration', + bootstrapInviteTokenHash: inviteTokenHash, }); expect(result.reason).toBe('admin_exists'); }); it('returns invite_consumed if update fails (race condition)', async () => { - (getBootstrapCookie as any).mockReturnValue('token'); (BootstrapInvite.findOne as any).mockResolvedValue(validInvite()); (BootstrapInvite.update as any).mockResolvedValue([0]); @@ -224,13 +220,13 @@ it('returns invite_consumed if update fails (race condition)', async () => { user, req: mockReq, completionMethod: 'webauthn_registration', + bootstrapInviteTokenHash: inviteTokenHash, }); expect(result.reason).toBe('invite_consumed'); }); it('promotes user to admin successfully', async () => { - (getBootstrapCookie as any).mockReturnValue('token'); (BootstrapInvite.findOne as any).mockResolvedValue(validInvite()); const user = baseUser(); @@ -239,6 +235,7 @@ it('promotes user to admin successfully', async () => { user, req: mockReq, completionMethod: 'webauthn_registration', + bootstrapInviteTokenHash: inviteTokenHash, }); expect(result).toEqual({ diff --git a/tests/unit/services/sessionIssueService.spec.ts b/tests/unit/services/sessionIssueService.spec.ts index 27241f3..ebee098 100644 --- a/tests/unit/services/sessionIssueService.spec.ts +++ b/tests/unit/services/sessionIssueService.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { issueSessionAndRespond } from '../../../src/services/sessionIssuance.js'; @@ -15,15 +15,6 @@ vi.mock('../../../src/models/sessions.js', () => ({ }, })); -vi.mock('../../../src/lib/cookie.js', () => ({ - setAuthCookies: vi.fn(), - clearAuthCookies: vi.fn(), -})); - -vi.mock('../../../src/lib/bootstrapCookie.js', () => ({ - clearBootstrapCookie: vi.fn(), -})); - vi.mock('../../../src/config/getSystemConfig.js', () => ({ getSystemConfig: vi.fn(), })); @@ -37,24 +28,17 @@ vi.mock('../../../src/utils/utils.js', () => ({ parseDurationToSeconds: vi.fn(), })); -// ---- Imports AFTER mocks ---- - +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; import { createRefreshTokenLookup, generateRefreshToken, hashRefreshToken, signAccessToken, } from '../../../src/lib/token.js'; - import { Session } from '../../../src/models/sessions.js'; -import { setAuthCookies, clearAuthCookies } from '../../../src/lib/cookie.js'; -import { clearBootstrapCookie } from '../../../src/lib/bootstrapCookie.js'; -import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; import { getDefaultOrganizationIdForUser } from '../../../src/services/organizationService.js'; import { computeSessionTimes, parseDurationToSeconds } from '../../../src/utils/utils.js'; -// ---- Helpers ---- - const mockReq = () => ({ get: vi.fn().mockReturnValue('test-agent'), @@ -75,8 +59,6 @@ const mockUser = { roles: ['user'], }; -// ---- Setup ---- - beforeEach(() => { vi.clearAllMocks(); @@ -101,157 +83,107 @@ beforeEach(() => { (getDefaultOrganizationIdForUser as any).mockResolvedValue(null); }); -it('issues session in web mode and sets cookies', async () => { - const req = mockReq(); - const res = mockRes(); - - await issueSessionAndRespond({ - user: mockUser, - req, - res, - authMode: 'web', - }); - - expect(Session.create).toHaveBeenCalled(); - expect(createRefreshTokenLookup).toHaveBeenCalledWith('refresh-token'); - expect(signAccessToken).toHaveBeenCalled(); - - expect(setAuthCookies).toHaveBeenCalledWith(res, { - accessToken: 'access-token', - refreshToken: 'refresh-token', - }); - - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ message: 'Success' }); -}); - -it('issues session in server mode and returns JSON payload', async () => { - const req = mockReq(); - const res = mockRes(); - - await issueSessionAndRespond({ - user: mockUser, - req, - res, - authMode: 'server', - }); - - expect(res.status).toHaveBeenCalledWith(200); - - expect(res.json).toHaveBeenCalledWith({ - message: 'Success', - token: 'access-token', - refreshToken: 'refresh-token', - sub: mockUser.id, - organizationId: null, - roles: mockUser.roles, - email: mockUser.email, - phone: mockUser.phone, - ttl: 900, - refreshTtl: 3600, - }); -}); - -it('clears existing auth cookies when flag set', async () => { - const req = mockReq(); - const res = mockRes(); +describe('issueSessionAndRespond', () => { + it('issues a bearer/json session response', async () => { + const req = mockReq(); + const res = mockRes(); - await issueSessionAndRespond({ - user: mockUser, - req, - res, - authMode: 'web', - clearExistingCookies: true, + await issueSessionAndRespond({ + user: mockUser, + req, + res, + }); + + expect(Session.create).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'server', + refreshTokenHash: 'hashed-refresh', + refreshTokenLookup: 'refresh-lookup', + }), + ); + expect(signAccessToken).toHaveBeenCalledWith('session-1', mockUser.id, mockUser.roles, null); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + message: 'Success', + token: 'access-token', + refreshToken: 'refresh-token', + sub: mockUser.id, + organizationId: null, + roles: mockUser.roles, + email: mockUser.email, + phone: mockUser.phone, + ttl: 900, + refreshTtl: 3600, + }); }); - expect(clearAuthCookies).toHaveBeenCalledWith(res); -}); + it('throws if token generation fails', async () => { + const req = mockReq(); + const res = mockRes(); -it('clears bootstrap cookie when flag set', async () => { - const req = mockReq(); - const res = mockRes(); + (signAccessToken as any).mockResolvedValue(null); - await issueSessionAndRespond({ - user: mockUser, - req, - res, - authMode: 'web', - clearBootstrap: true, + await expect( + issueSessionAndRespond({ + user: mockUser, + req, + res, + }), + ).rejects.toThrow('Failed to issue session tokens'); }); - expect(clearBootstrapCookie).toHaveBeenCalledWith(res); -}); - -it('throws if token generation fails', async () => { - const req = mockReq(); - const res = mockRes(); - - (signAccessToken as any).mockResolvedValue(null); + it('passes request metadata into session creation', async () => { + const req = mockReq(); + const res = mockRes(); - await expect( - issueSessionAndRespond({ + await issueSessionAndRespond({ user: mockUser, req, res, - authMode: 'web', - }), - ).rejects.toThrow('Failed to issue session tokens'); -}); - -it('passes request metadata into session creation', async () => { - const req = mockReq(); - const res = mockRes(); - - await issueSessionAndRespond({ - user: mockUser, - req, - res, - authMode: 'web', + }); + + expect(Session.create).toHaveBeenCalledWith( + expect.objectContaining({ + userAgent: 'test-agent', + ipAddress: '127.0.0.1', + organizationId: null, + }), + ); }); - expect(Session.create).toHaveBeenCalledWith( - expect.objectContaining({ - userAgent: 'test-agent', - ipAddress: '127.0.0.1', - organizationId: null, - }), - ); -}); - -it('stores the default organization when one exists', async () => { - const req = mockReq(); - const res = mockRes(); + it('stores the default organization when one exists', async () => { + const req = mockReq(); + const res = mockRes(); - (getDefaultOrganizationIdForUser as any).mockResolvedValue('org-1'); + (getDefaultOrganizationIdForUser as any).mockResolvedValue('org-1'); - await issueSessionAndRespond({ - user: mockUser, - req, - res, - authMode: 'server', + await issueSessionAndRespond({ + user: mockUser, + req, + res, + }); + + expect(Session.create).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org-1', + }), + ); + expect(signAccessToken).toHaveBeenCalledWith('session-1', mockUser.id, mockUser.roles, 'org-1'); }); - expect(Session.create).toHaveBeenCalledWith( - expect.objectContaining({ - organizationId: 'org-1', - }), - ); - expect(signAccessToken).toHaveBeenCalledWith('session-1', mockUser.id, mockUser.roles, 'org-1'); -}); + it('uses default TTL values when config missing', async () => { + const req = mockReq(); + const res = mockRes(); -it('uses default TTL values when config missing', async () => { - const req = mockReq(); - const res = mockRes(); + (getSystemConfig as any).mockResolvedValue({}); - (getSystemConfig as any).mockResolvedValue({}); + await issueSessionAndRespond({ + user: mockUser, + req, + res, + }); - await issueSessionAndRespond({ - user: mockUser, - req, - res, - authMode: 'server', + expect(parseDurationToSeconds).toHaveBeenCalledWith('15m'); + expect(parseDurationToSeconds).toHaveBeenCalledWith('1h'); }); - - expect(parseDurationToSeconds).toHaveBeenCalledWith('15m'); - expect(parseDurationToSeconds).toHaveBeenCalledWith('1h'); }); diff --git a/validateEnvs.sh b/validateEnvs.sh index 06b90c6..571b757 100644 --- a/validateEnvs.sh +++ b/validateEnvs.sh @@ -27,7 +27,6 @@ require_var APP_NAME require_var APP_ID require_var APP_ORIGINS require_var ISSUER -require_var AUTH_MODE require_var DEFAULT_ROLES require_var AVAILABLE_ROLES require_var DB_LOGGING @@ -47,9 +46,7 @@ else require_var DB_NAME fi -if [ "${AUTH_MODE:-}" = "server" ]; then - require_var API_SERVICE_TOKEN -fi +require_var API_SERVICE_TOKEN if [ "${SEAMLESS_BOOTSTRAP_ENABLED:-false}" = "true" ]; then require_var SEAMLESS_BOOTSTRAP_SECRET