diff --git a/.env.example b/.env.example index 7c381b5..6e8c2f2 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,7 @@ DEFAULT_ROLES=user,betaUser AVAILABLE_ROLES=user,admin,betaUser,team # Login methods administrators allow after /login creates a pre-auth session. -# Supported values: passkey,magic_link,email_otp,phone_otp +# Supported values: passkey,magic_link,email_otp,phone_otp,oauth LOGIN_METHODS=passkey,magic_link # When true, magic_link/email_otp/phone_otp can appear alongside passkey when allowed. # When false, passkey-capable sessions continue with passkey only. @@ -42,6 +42,9 @@ ACCESS_TOKEN_TTL=30m REFRESH_TOKEN_TTL=1h RATE_LIMIT=100 DELAY_AFTER=50 +# JSON. Failed login attempts for an identified user inside windowSeconds lock the account +# for lockoutSeconds. Set enabled=false only when an upstream policy handles this. +LOCKOUT_POLICY={"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeconds":900} # SERVICE TOKENS # Required for trusted server adapters and internal bearer validation. @@ -50,11 +53,19 @@ API_SERVICE_TOKEN=32-byte-hex-string # If unset, the server falls back to API_SERVICE_TOKEN, and in development only # it will use a derived local secret. REFRESH_TOKEN_LOOKUP_SECRET= +# 32-byte base64url/base64/hex key for encrypted TOTP secrets. +# If unset, the API falls back to API_SERVICE_TOKEN, and production requires one of these values. +TOTP_SECRET_ENCRYPTION_KEY= # WEBAUTHN RPID=localhost ORIGINS=http://localhost:5173,http://localhost:5174 +# OAUTH +# JSON array of provider config. Provider client secrets stay in env vars referenced by +# clientSecretEnv, never in system_config or this JSON value. +OAUTH_PROVIDERS=[] + # ADMIN BOOTSTRAP SEAMLESS_BOOTSTRAP_ENABLED=true SEAMLESS_BOOTSTRAP_SECRET=dev-bootstrap-secret-123 diff --git a/.gitignore b/.gitignore index 4115147..a1a2263 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,9 @@ docker-data/ # Lint / tooling cache .eslintcache -docs/ +docs/* +!docs/admin-operations.md +!docs/architecture.md +!docs/oauth.md +!docs/production-operations.md +!docs/webauthn-prf.md diff --git a/README.md b/README.md index f907e32..249998f 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Never commit real secrets. Use `.env.example` for documentation. SeamlessAuth can request PRF-capable passkeys and PRF assertions without ever receiving PRF output. See [docs/webauthn-prf.md](./docs/webauthn-prf.md) for API usage, browser limitations, SDK -contract guidance, and Seamless Secrets consumption rules. +contract guidance, and local key-material handling rules. ### Login Method Policy @@ -139,10 +139,14 @@ system config. "userInfoUrl": "https://openidconnect.googleapis.com/v1/userinfo", "scopes": ["openid", "email", "profile"], "redirectUri": "https://app.example.com/oauth/callback", + "redirectUris": ["https://app.example.com/oauth/callback"], "subjectJsonPath": "sub", "emailJsonPath": "email", + "emailVerifiedJsonPath": "email_verified", "nameJsonPath": "name", - "allowSignup": true + "allowSignup": true, + "accountLinking": "email", + "requireEmailVerified": true } ] ``` @@ -182,11 +186,45 @@ curl -X POST http://localhost:5312/oauth/google/callback \ Security notes: - OAuth `state` is signed and expires after a short window. -- `redirectUri` and `returnTo` must match configured `origins`. +- `redirectUri` must exactly match a provider `redirectUris` entry when configured. Providers + without a redirect allowlist fall back to trusted configured origins. +- `returnTo` must match configured `origins`. +- OIDC providers that request the `openid` scope receive a nonce bound into the signed state. - Provider access tokens are never persisted. - OAuth identities are stored as provider id + provider subject in `oauth_identities`. -- Existing users are linked by verified email; new users are created only when `allowSignup` is - enabled for that provider. +- Existing users are linked by email only when `accountLinking` is `email`; set + `accountLinking: "disabled"` to require an existing provider identity. +- New users are created only when `allowSignup` is enabled for that provider. +- Set `requireEmailVerified: true` for providers that expose a reliable email verification claim. + +### Lockout Policy + +`LOCKOUT_POLICY` configures account lockout for identified users after repeated failed login +attempts. The value is JSON and is also manageable through `system_config`: + +```json +{ + "enabled": true, + "maxFailures": 10, + "windowSeconds": 900, + "lockoutSeconds": 900 +} +``` + +Lockout is checked after Seamless Auth has identified the target user. Keep route-level rate limits +enabled for unknown identifiers, OTP delivery abuse, and broad IP pressure. + +### Admin-Assisted Device Replacement + +Administrators with write access can prepare an account for device replacement with: + +```http +POST /admin/users/:userId/recovery/device-replacement +``` + +The endpoint requires a fresh step-up session and can revoke active sessions, remove registered +passkeys, and disable enabled TOTP credentials. It returns counts only; it never returns secrets, +credential private material, TOTP secrets, or recovery codes. ### Sensitive Data Redaction @@ -321,6 +359,9 @@ For production deployments: - Back up your database - Monitor authentication failures +See [docs/production-operations.md](./docs/production-operations.md) for key, secret, rotation, +lockout, and deployment guidance. + ## Prefer not to self-host? SeamlessAuth managed services provides a fully managed experience built on top of this same open-source core, including hosting, upgrades, dashboards, backups, and SLAs. @@ -342,10 +383,14 @@ For production deployments: See [CONTRIBUTING.md](./CONTRIBUTING.md). -## Maintainer Docs +## Public Docs - [AGENTS.md](./AGENTS.md) for a fast codebase briefing aimed at coding agents and maintainers -- [docs/architecture.md](./docs/architecture.md) for a deeper walkthrough of runtime flow, auth modes, config, and testing +- [docs/architecture.md](./docs/architecture.md) for runtime structure and request flow +- [docs/oauth.md](./docs/oauth.md) for OAuth provider setup and security behavior +- [docs/webauthn-prf.md](./docs/webauthn-prf.md) for PRF-capable passkey usage +- [docs/admin-operations.md](./docs/admin-operations.md) for scoped admin and recovery operations +- [docs/production-operations.md](./docs/production-operations.md) for production deployment guidance ## Security diff --git a/docs/admin-operations.md b/docs/admin-operations.md new file mode 100644 index 0000000..5d686f3 --- /dev/null +++ b/docs/admin-operations.md @@ -0,0 +1,64 @@ +# Admin Operations + +Seamless Auth API includes administrative endpoints for self-hosted operators. Admin access is controlled by scoped roles and should be used from a trusted operator surface. + +## Scoped Admin Roles + +Admin routes are split by intent: + +- Read routes accept `admin`, `admin:read`, or `admin:write`. +- Write routes accept `admin` or `admin:write`. +- `admin:write` satisfies `admin:read`. +- `admin:read` does not satisfy write checks. + +The legacy `admin` role remains broad for backwards compatibility. + +## Device Replacement Recovery + +Administrators with write access can prepare an account for device replacement: + +```http +POST /admin/users/:userId/recovery/device-replacement +``` + +The endpoint requires a fresh step-up session. By default it: + +- revokes active sessions +- removes passkeys +- disables enabled TOTP credentials + +The response returns counts only: + +```json +{ + "userId": "user-id", + "revokedSessions": 2, + "removedCredentials": 1, + "disabledTotpCredentials": 1 +} +``` + +It does not return credential private material, TOTP secrets, recovery codes, refresh tokens, or PRF output. + +## Session Hygiene + +Administrative session endpoints can list sessions and revoke individual or all sessions for a user. Use these endpoints when responding to suspicious account activity or user-requested device cleanup. + +## Lockout Policy + +`lockout_policy` controls account lockout for identified users after repeated failed login attempts: + +```json +{ + "enabled": true, + "maxFailures": 10, + "windowSeconds": 900, + "lockoutSeconds": 900 +} +``` + +Lockout is checked after a user has been identified. Keep route-level and destination-aware limits enabled for unknown identifiers and delivery abuse. + +## Audit Events + +Admin actions are recorded as auth events with redacted metadata. Do not store raw secrets, tokens, OTPs, magic-link URLs, PRF values, account keys, or provider tokens in admin metadata. diff --git a/docs/architecture.md b/docs/architecture.md index 09154ba..090b1ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,240 +1,53 @@ -# Seamless Auth API Architecture +# Architecture -This document is the deeper companion to [AGENTS.md](/Users/brandoncorbett/git/seamless-auth-api/AGENTS.md). It explains how the service boots, how authentication flows move through the codebase, and which pieces tend to matter during maintenance. +Seamless Auth API is an Express and TypeScript authentication service backed by Postgres. It provides passwordless authentication primitives, session issuance, JWKS publication, runtime system configuration, and administrative API endpoints for operators. -## 1. Boot Sequence +## Runtime Components -Request handling is assembled in this order: +- Express app: global middleware, CORS, rate limits, OpenAPI metadata, and route loading. +- Routes: thin endpoint declarations with request/response schemas. +- Controllers: request handling and response shaping. +- Services: reusable auth, session, messaging, organization, OAuth, lockout, redaction, and step-up logic. +- Models: Sequelize models for users, credentials, sessions, system config, auth events, organizations, TOTP credentials, and OAuth identities. +- Postgres: source of truth for users, credentials, sessions, config, and audit records. -1. [src/server.ts](/Users/brandoncorbett/git/seamless-auth-api/src/server.ts) -2. [src/models/index.ts](/Users/brandoncorbett/git/seamless-auth-api/src/models/index.ts) -3. [src/db.ts](/Users/brandoncorbett/git/seamless-auth-api/src/db.ts) -4. [src/config/bootstrapSystemConfig.ts](/Users/brandoncorbett/git/seamless-auth-api/src/config/bootstrapSystemConfig.ts) -5. [src/app.ts](/Users/brandoncorbett/git/seamless-auth-api/src/app.ts) -6. [src/lib/loadRoutes.ts](/Users/brandoncorbett/git/seamless-auth-api/src/lib/loadRoutes.ts) +## Request Flow -Important implications: +1. Express receives a request and applies global middleware. +2. Route declarations validate params, query, and body schemas. +3. Auth middleware validates access or ephemeral tokens where required. +4. Controllers call service/model layers. +5. Response schemas validate JSON responses when configured. +6. Auth events are logged with sensitive metadata redacted. -- Models must initialize before route handlers run. -- The process will fail fast on missing database or required system configuration. -- Route registration is file-system driven, so every `*.routes.ts` file in `src/routes` is mounted automatically. +## Token Model -## 2. Request Pipeline +Seamless Auth API uses three token states: -Global behavior is configured in [src/app.ts](/Users/brandoncorbett/git/seamless-auth-api/src/app.ts): +- Ephemeral token: short-lived pre-auth token used to continue registration or login flows. +- Access token: signed JWT for authenticated API access. +- Refresh token: opaque token stored only as a hash plus lookup fingerprint. -- `helmet` -- JSON body parsing -- CORS -- request logging -- rate limiting and slow-down outside test mode -- development-only OpenAPI and Swagger UI -- generic error and 404 handlers +Access tokens are signed with configured JWKS signing keys. Refresh tokens are rotated and stored in the `sessions` table as non-raw values. -Route modules use [src/lib/createRouter.ts](/Users/brandoncorbett/git/seamless-auth-api/src/lib/createRouter.ts) and [src/lib/defineRoute.ts](/Users/brandoncorbett/git/seamless-auth-api/src/lib/defineRoute.ts). `defineRoute` is more than syntactic sugar: +## Authentication Methods -- parses params/query/body with Zod -- registers OpenAPI metadata -- can validate JSON responses against Zod schemas -- optionally attaches auth middleware through the `auth` property +Supported login methods are controlled by `login_methods` system config: -If request parsing or OpenAPI output looks wrong, inspect the route definition before the controller. +- `passkey` +- `magic_link` +- `email_otp` +- `phone_otp` +- `oauth` -## 3. Main Auth Flows +Passkey-capable sessions can be restricted to passkey-only continuation by disabling `passkey_login_fallback_enabled`. -### Registration +## System Configuration -Primary files: +Runtime configuration lives in the `system_config` table and is bootstrapped from environment variables when missing. Configuration includes token TTLs, allowed origins, WebAuthn relying-party settings, roles, OAuth providers, login methods, and lockout policy. -- [src/routes/registration.routes.ts](/Users/brandoncorbett/git/seamless-auth-api/src/routes/registration.routes.ts) -- [src/controllers/registration.ts](/Users/brandoncorbett/git/seamless-auth-api/src/controllers/registration.ts) +Use environment variables for raw secrets. Do not store raw secrets in `system_config`. -Behavior: +## Operational Boundaries -- validates email/phone -- finds or creates the user -- issues an ephemeral token -- optionally sends or returns phone OTP delivery info -- 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. - -### OTP - -Primary files: - -- [src/routes/otp.routes.ts](/Users/brandoncorbett/git/seamless-auth-api/src/routes/otp.routes.ts) -- [src/controllers/otp.ts](/Users/brandoncorbett/git/seamless-auth-api/src/controllers/otp.ts) -- [src/utils/otp.ts](/Users/brandoncorbett/git/seamless-auth-api/src/utils/otp.ts) - -Behavior: - -- requires ephemeral auth for generation and verification endpoints -- supports both registration verification and login verification -- can either send messages directly or return delivery payloads to an external caller - -### Magic Link - -Primary files: - -- [src/routes/magicLink.routes.ts](/Users/brandoncorbett/git/seamless-auth-api/src/routes/magicLink.routes.ts) -- [src/controllers/magicLinks.ts](/Users/brandoncorbett/git/seamless-auth-api/src/controllers/magicLinks.ts) - -Behavior: - -- requires ephemeral auth to request the link -- stores hashed token plus device fingerprint data -- verification endpoint marks the token used -- polling endpoint finalizes the login/session if the same device later confirms it - -### WebAuthn / Passkeys - -Primary files: - -- [src/routes/webauthn.routes.ts](/Users/brandoncorbett/git/seamless-auth-api/src/routes/webauthn.routes.ts) -- [src/controllers/webauthn.ts](/Users/brandoncorbett/git/seamless-auth-api/src/controllers/webauthn.ts) - -Behavior: - -- start endpoints generate registration/authentication challenges -- finish endpoints verify browser responses with `@simplewebauthn/server` -- successful completion issues a real session -- registration/login completion can also trigger bootstrap admin promotion - -### Session Management - -Primary files: - -- [src/services/sessionIssuance.ts](/Users/brandoncorbett/git/seamless-auth-api/src/services/sessionIssuance.ts) -- [src/services/sessionService.ts](/Users/brandoncorbett/git/seamless-auth-api/src/services/sessionService.ts) -- [src/controllers/authentication.ts](/Users/brandoncorbett/git/seamless-auth-api/src/controllers/authentication.ts) -- [src/controllers/sessions.ts](/Users/brandoncorbett/git/seamless-auth-api/src/controllers/sessions.ts) - -Key concepts: - -- refresh tokens are opaque random values stored only as bcrypt hashes -- access tokens are signed JWTs using the JWKS-managed signing key -- 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 Contract - -The API exposes a single bearer/JSON auth contract. - -- 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 - -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 - -### Environment variables - -The process relies on `.env` or runtime env vars for: - -- database connection -- issuer/origin metadata -- bootstrap toggles -- service token secrets -- production signing/JWKS material -- optional direct messaging provider credentials - -Reference points: - -- [.env.example](/Users/brandoncorbett/git/seamless-auth-api/.env.example) -- [validateEnvs.sh](/Users/brandoncorbett/git/seamless-auth-api/validateEnvs.sh) - -### `system_config` - -Bootstrapped runtime values include: - -- `app_name` -- `default_roles` -- `available_roles` -- token TTLs -- rate limiting config -- WebAuthn RP ID -- allowed origins - -Reference points: - -- [src/config/systemConfig.envMap.ts](/Users/brandoncorbett/git/seamless-auth-api/src/config/systemConfig.envMap.ts) -- [src/schemas/systemConfig.schema.ts](/Users/brandoncorbett/git/seamless-auth-api/src/schemas/systemConfig.schema.ts) -- [src/controllers/systemConfig.ts](/Users/brandoncorbett/git/seamless-auth-api/src/controllers/systemConfig.ts) - -The cache in `getSystemConfig()` is process-local. Any write path should invalidate it. - -## 6. Messaging - -Direct delivery lives in: - -- [src/services/messagingService.ts](/Users/brandoncorbett/git/seamless-auth-api/src/services/messagingService.ts) -- [src/config/directMessaging.ts](/Users/brandoncorbett/git/seamless-auth-api/src/config/directMessaging.ts) - -Supported direct transports: - -- AWS email -- AWS SMS -- Twilio SMS - -Flows can opt out of direct sending and instead return delivery payloads by sending `x-seamless-auth-delivery-mode: external`. -Because delivery payloads contain OTPs or one-time links, production external delivery also requires -a valid `x-seamless-service-token` from a trusted server adapter. Non-production development and test -flows may request external delivery explicitly without a service token. - -This split is important when writing tests or integrating with an upstream orchestration service. - -## 7. Data Model Highlights - -Useful tables/models to understand early: - -- `users` -- `credentials` -- `sessions` -- `auth_events` -- `magic_links` -- `system_config` -- `bootstrap_invites` - -Model definitions live in [src/models](/Users/brandoncorbett/git/seamless-auth-api/src/models). Migrations live in [src/migrations](/Users/brandoncorbett/git/seamless-auth-api/src/migrations). - -## 8. Testing Strategy - -The test suite uses Vitest with: - -- unit tests for utilities, middleware, services, config, and OpenAPI generation -- integration tests for route/controller behavior -- e2e and smoke tests for higher-level flow coverage - -Reference points: - -- [vitest.config.ts](/Users/brandoncorbett/git/seamless-auth-api/vitest.config.ts) -- [tests/setup/env.ts](/Users/brandoncorbett/git/seamless-auth-api/tests/setup/env.ts) -- [tests/setup/globalSetup.ts](/Users/brandoncorbett/git/seamless-auth-api/tests/setup/globalSetup.ts) - -Practical guidance: - -- use unit tests for small auth helper changes -- use integration tests when touching controllers or middleware -- update OpenAPI tests if you change route schemas or docs generation - -## 9. Maintenance Notes And Sharp Edges - -- 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. -- `system_config` can mask env changes after first bootstrap because the DB value becomes authoritative. -- 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 - -1. Read the relevant route file. -2. Read the controller. -3. Read the service/helper/model touched by the controller. -4. Check the corresponding test file before editing. -5. If changing auth or schema behavior, inspect OpenAPI impact too. -6. Run `npm run build`, `npm run lint`, and targeted tests before wrapping up. +This repository contains the auth server only. It does not include billing, hosted tenant lifecycle, managed observability, managed secret storage, or the hosted control plane. Self-hosted deployments can integrate their own infrastructure for those responsibilities. diff --git a/docs/oauth.md b/docs/oauth.md new file mode 100644 index 0000000..d142f66 --- /dev/null +++ b/docs/oauth.md @@ -0,0 +1,64 @@ +# OAuth Login + +Seamless Auth API can use external OAuth/OIDC-style providers for login while still issuing the final Seamless Auth access and refresh session. + +Provider access tokens are used only during callback handling to fetch profile data. They are not stored, logged, returned to clients, or included in auth event responses. + +## Enable OAuth + +1. Add `oauth` to `LOGIN_METHODS`. +2. Configure one or more providers in `oauth_providers` system config or the `OAUTH_PROVIDERS` environment variable. +3. Store provider client secrets in environment variables referenced by `clientSecretEnv`. + +Example provider: + +```json +{ + "id": "google", + "name": "Google", + "enabled": true, + "clientId": "google-oauth-client-id", + "clientSecretEnv": "GOOGLE_CLIENT_SECRET", + "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth", + "tokenUrl": "https://oauth2.googleapis.com/token", + "userInfoUrl": "https://openidconnect.googleapis.com/v1/userinfo", + "scopes": ["openid", "email", "profile"], + "redirectUri": "https://app.example.com/oauth/callback", + "redirectUris": ["https://app.example.com/oauth/callback"], + "subjectJsonPath": "sub", + "emailJsonPath": "email", + "emailVerifiedJsonPath": "email_verified", + "nameJsonPath": "name", + "allowSignup": true, + "accountLinking": "email", + "requireEmailVerified": true +} +``` + +## Browser Flow + +1. `GET /oauth/providers` +2. `POST /oauth/:providerId/start` +3. Redirect browser to returned `authorizationUrl`. +4. Provider redirects back to your configured callback URL with `code` and `state`. +5. `POST /oauth/:providerId/callback` +6. Seamless Auth validates state, exchanges the code, fetches userinfo, resolves the local user, and issues a Seamless Auth session. + +## Redirect Policy + +For production providers, configure `redirectUris` as exact callback URLs. When a provider has a redirect allowlist, requested redirect URIs must exactly match it. + +Providers without `redirectUris` fall back to configured trusted origins. Exact provider allowlists are preferred for production. + +## Account Linking + +`accountLinking` controls whether a provider identity can attach to an existing local user by email: + +- `email`: existing users may be linked by email when policy allows. +- `disabled`: only existing provider identities can continue. + +Use `requireEmailVerified: true` for providers that expose a reliable email verification claim. + +## OIDC Notes + +When provider scopes include `openid`, Seamless Auth includes a nonce bound into signed state. PKCE support depends on provider and client flow requirements; keep provider callback handling server-side and avoid exposing provider tokens to browsers. diff --git a/docs/production-operations.md b/docs/production-operations.md new file mode 100644 index 0000000..f8862b2 --- /dev/null +++ b/docs/production-operations.md @@ -0,0 +1,63 @@ +# Production Operations + +Authentication infrastructure is security-sensitive. This guide covers public deployment guidance for self-hosted Seamless Auth API operators. + +## Required Practices + +- Use HTTPS end to end. +- Restrict CORS and WebAuthn origins to exact trusted origins. +- Keep access and refresh tokens out of browser-readable storage. +- Store raw secrets in environment variables, a secret manager, or a user-supplied secret store. +- Back up Postgres and test restores. +- Monitor authentication failures and suspicious events. +- Keep route-level rate limits enabled. + +## Secrets Inventory + +Production deployments should define: + +- `API_SERVICE_TOKEN` +- `REFRESH_TOKEN_LOOKUP_SECRET` +- `TOTP_SECRET_ENCRYPTION_KEY` +- `SEAMLESS_JWKS_ACTIVE_KID` +- `SEAMLESS_JWKS_KEY__PRIVATE` +- `JWKS_PUBLIC_KEYS` +- OAuth client-secret environment variables referenced by provider `clientSecretEnv` +- Messaging provider credentials when direct delivery is enabled + +Do not store raw secrets in `system_config`. + +## Signing Keys + +Access tokens are signed with configured JWKS signing keys. A typical rotation is: + +1. Generate a new key pair. +2. Publish the new public key in `JWKS_PUBLIC_KEYS`. +3. Deploy with both old and new public keys available. +4. Switch `SEAMLESS_JWKS_ACTIVE_KID` to the new key id. +5. Keep retired public keys until all tokens signed with them expire. +6. Remove retired public keys after the token TTL window. + +## Refresh Tokens + +Refresh tokens are opaque values. The API stores hashes and lookup fingerprints, not raw refresh tokens. + +Use a stable `REFRESH_TOKEN_LOOKUP_SECRET` in production. Rotating it without dual-lookup support requires revoking existing sessions or running a planned migration. + +## TOTP Secrets + +TOTP secrets are encrypted at rest. Use `TOTP_SECRET_ENCRYPTION_KEY` in production instead of relying on development fallbacks. + +If rotating the TOTP encryption key, decrypt and re-encrypt stored secrets in a controlled migration. If the old key is unavailable, users must re-enroll TOTP. + +## OAuth Secrets + +OAuth provider client secrets live in environment variables referenced by `clientSecretEnv`. Rotate them at the provider and update the deployed environment value. Do not enter client secret values into runtime system config or admin UI fields. + +## Messaging Credentials + +Direct email/SMS delivery requires provider credentials. If you use an external trusted server adapter for delivery, keep delivery tokens on that server and avoid returning delivery payloads to browsers. + +## Redaction + +Logs and auth events redact sensitive metadata by default. Sensitive values include tokens, OTPs, magic-link URLs, OAuth state/codes, PRF salts and output, TOTP secrets, email/phone snapshots, private keys, and configured provider secrets. diff --git a/docs/webauthn-prf.md b/docs/webauthn-prf.md new file mode 100644 index 0000000..8847640 --- /dev/null +++ b/docs/webauthn-prf.md @@ -0,0 +1,49 @@ +# WebAuthn PRF + +WebAuthn PRF lets compatible passkeys derive browser-local key material during registration or assertion. Seamless Auth API supports PRF-capable passkey primitives without receiving PRF output. + +PRF output must stay in the browser caller. Do not send it to Seamless Auth API or to application APIs unless your application has a separate, explicit design for local key material handling. + +## Registration + +Normal passkey registration does not require PRF. Callers can request PRF-capable credential creation when their application needs local key material later. + +The server can include WebAuthn `extensions.prf` creation options and persist whether the credential is suitable for PRF use. Registration remains backwards compatible for non-PRF passkeys. + +## Login and Assertion + +Callers can request PRF during assertion with a caller-provided salt. The browser reads: + +```ts +credential.getClientExtensionResults().prf?.results?.first; +``` + +The assertion response sent to Seamless Auth must not include PRF output. The server verifies the WebAuthn assertion normally and stores only credential metadata. + +## Step-up Authentication + +PRF can also be requested during WebAuthn step-up. This lets applications require fresh user verification while deriving local key material in the browser. + +The React SDK exposes headless helpers for this flow. The shape intended for local key consumers is: + +```ts +{ + credentialId: string; + output: Uint8Array; +} +``` + +## Salt Handling + +PRF salts may be application-provided and may be stored by downstream apps when needed. Treat salts as sensitive in logs because they can identify key-derivation contexts. + +## Browser Support + +Browser and authenticator support is not universal. Applications should check support before requiring PRF and provide a fallback for passkeys that authenticate successfully without returning PRF output. + +## Security Notes + +- Authentication proves identity and user presence. +- PRF output is local key material. +- PRF output is never logged, stored, sent to Seamless Auth API, or returned by server responses. +- Do not include PRF output in error telemetry, analytics, auth events, or delivery payloads. diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index f2075e9..8466b2a 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 80.8% + + coverage: 81.5% @@ -17,7 +17,7 @@ coverage coverage - 80.8% - 80.8% + 81.5% + 81.5% diff --git a/src/config/systemConfig.defaults.ts b/src/config/systemConfig.defaults.ts index b481415..2a42b83 100644 --- a/src/config/systemConfig.defaults.ts +++ b/src/config/systemConfig.defaults.ts @@ -9,5 +9,11 @@ import type { SystemConfig } from '../schemas/systemConfig.schema.js'; export const SYSTEM_CONFIG_DEFAULTS: Partial = { login_methods: ['passkey', 'magic_link'], oauth_providers: [], + lockout_policy: { + enabled: true, + maxFailures: 10, + windowSeconds: 15 * 60, + lockoutSeconds: 15 * 60, + }, passkey_login_fallback_enabled: true, }; diff --git a/src/config/systemConfig.envMap.ts b/src/config/systemConfig.envMap.ts index 0a39b4c..10d6b73 100644 --- a/src/config/systemConfig.envMap.ts +++ b/src/config/systemConfig.envMap.ts @@ -9,6 +9,7 @@ export const SYSTEM_CONFIG_ENV_MAP = { available_roles: 'AVAILABLE_ROLES', login_methods: 'LOGIN_METHODS', oauth_providers: 'OAUTH_PROVIDERS', + lockout_policy: 'LOCKOUT_POLICY', passkey_login_fallback_enabled: 'PASSKEY_LOGIN_FALLBACK_ENABLED', access_token_ttl: 'ACCESS_TOKEN_TTL', refresh_token_ttl: 'REFRESH_TOKEN_TTL', diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts index 97b0366..162c2a0 100644 --- a/src/controllers/admin.ts +++ b/src/controllers/admin.ts @@ -11,8 +11,13 @@ import { AuthEvent, AuthEventAttributes } from '../models/authEvents.js'; import { Credential } from '../models/credentials.js'; import { getSequelize } from '../models/index.js'; import { Session } from '../models/sessions.js'; +import { TotpCredential } from '../models/totpCredentials.js'; import { User } from '../models/users.js'; -import { CreateUserSchema, UpdateUserSchema } from '../schemas/admin.requests.js'; +import { + CreateUserSchema, + DeviceReplacementRecoverySchema, + UpdateUserSchema, +} from '../schemas/admin.requests.js'; import { AuthEventQuerySchema } from '../schemas/internal.query.js'; import { serializeAuthEvents } from '../services/authEventSerialization.js'; import { AuthEventService } from '../services/authEventService.js'; @@ -320,6 +325,126 @@ export const revokeAllUserSessions = async (req: Request, res: Response) => { } }; +export const revokeUserSessionById = async (req: Request, res: Response) => { + const { id } = req.params; + + try { + const session = await Session.findOne({ + where: { + id, + revokedAt: null, + }, + }); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + await hardRevokeSession(session, 'admin_revoke'); + + await AuthEventService.log({ + userId: session.userId, + type: 'admin_session_revoked', + req, + metadata: { + targetUser: session.userId, + sessionId: session.id, + }, + }); + + return res.json({ message: 'Success' }); + } catch (err) { + logger.error(`Failed to revoke session: ${err}`); + return res.status(500).json({ error: 'Failed to revoke session' }); + } +}; + +export const recoverUserForDeviceReplacement = async (req: Request, res: Response) => { + const { userId } = req.params; + const parsed = DeviceReplacementRecoverySchema.safeParse(req.body ?? {}); + + if (!parsed.success) { + return res.status(400).json({ + error: 'Invalid recovery payload', + details: parsed.error, + }); + } + + const user = await User.findByPk(userId); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const { revokeSessions, removePasskeys, disableTotp } = parsed.data; + let revokedSessions = 0; + let removedCredentials = 0; + let disabledTotpCredentials = 0; + + if (revokeSessions) { + const sessions = await Session.findAll({ + where: { + userId, + revokedAt: null, + }, + }); + + for (const session of sessions) { + await hardRevokeSession(session, 'admin_device_replacement'); + } + + revokedSessions = sessions.length; + } + + if (removePasskeys) { + const credentials = await Credential.findAll({ where: { userId } }); + + for (const credential of credentials) { + await credential.destroy(); + } + + removedCredentials = credentials.length; + } + + if (disableTotp) { + const [count] = await TotpCredential.update( + { enabled: false }, + { + where: { + userId, + enabled: true, + }, + }, + ); + + disabledTotpCredentials = count; + } + + await AuthEventService.log({ + userId, + type: 'admin_device_replacement_recovery', + req, + metadata: { + targetUser: userId, + actions: { + revokeSessions, + removePasskeys, + disableTotp, + }, + revokedSessions, + removedCredentials, + disabledTotpCredentials, + }, + }); + + return res.json({ + userId, + revokedSessions, + removedCredentials, + disabledTotpCredentials, + }); +}; + // TODO: Need a public session return type for sessions export const listAllSessions = async (req: Request, res: Response) => { const { limit = 10, offset = 0 } = req.query; diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index ac8c8b7..2b86e20 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -19,6 +19,7 @@ import { Credential } from '../models/credentials.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; +import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; import { getLoginPolicy, resolveAvailableLoginMethods } from '../services/loginPolicyService.js'; import { findRefreshSessionByToken, @@ -131,6 +132,10 @@ export const login = async (req: Request, res: Response) => { return res.status(401).json({ error: 'Not Allowed' }); } + if (await rejectIfUserLocked({ userId: user.id, req, res })) { + return; + } + // pre-auth token const token = await signEphemeralToken(user.id); diff --git a/src/controllers/oauth.ts b/src/controllers/oauth.ts index 0996c12..d5c7a48 100644 --- a/src/controllers/oauth.ts +++ b/src/controllers/oauth.ts @@ -24,7 +24,13 @@ import { issueSessionAndRespond } from '../services/sessionIssuance.js'; function allowedReturnTo(value: string | undefined, origins: string[]) { if (!value) return undefined; - return origins.some((origin) => value.startsWith(origin)) ? value : undefined; + + try { + const url = new URL(value); + return origins.some((origin) => url.origin === new URL(origin).origin) ? value : undefined; + } catch { + return undefined; + } } export async function listOAuthProviders(_req: Request, res: Response) { @@ -52,6 +58,7 @@ export async function startOAuthLogin(req: Request, res: Response) { redirectUri, ...(returnTo ? { returnTo } : {}), }); + const statePayload = verifyOAuthState(state, provider.id); await AuthEventService.log({ type: 'oauth_login_started', @@ -66,6 +73,7 @@ export async function startOAuthLogin(req: Request, res: Response) { provider, redirectUri, state, + ...(statePayload?.nonce ? { nonce: statePayload.nonce } : {}), }), }); } catch { diff --git a/src/controllers/organizations.ts b/src/controllers/organizations.ts index 7159ccd..7c44f86 100644 --- a/src/controllers/organizations.ts +++ b/src/controllers/organizations.ts @@ -16,6 +16,7 @@ import { countOwners, createOrganizationForUser, findMembership, + hasOrganizationScope, listAllOrganizations, listOrganizationMembers, listOrganizationsForUser, @@ -159,9 +160,9 @@ export async function switchOrganization(req: Request, res: Response) { export async function listMembers(req: Request, res: Response) { const user = authUser(req); const { organizationId } = req.params; - const { organization } = await requireOrganizationAccess(user, organizationId); + const { organization, membership } = await requireOrganizationAccess(user, organizationId); - if (!organization) { + if (!organization || !hasOrganizationScope(user, membership, 'members:read')) { return res.status(404).json({ error: 'Organization not found' }); } diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index c2732f3..72f0c3b 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -9,6 +9,7 @@ import { Request, Response } from 'express'; import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { signEphemeralToken } from '../lib/token.js'; import { AuthEventService } from '../services/authEventService.js'; +import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; import { getLoginPolicy, isLoginMethodEnabled, @@ -396,6 +397,10 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { return; } + if (await rejectIfUserLocked({ userId: user.id, req, res })) { + return; + } + logger.info(`Verifying login phone number: ${phone}`); if (!user || !user.phoneVerificationTokenExpiry || !user.phoneVerificationToken) { @@ -489,6 +494,10 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { return; } + if (await rejectIfUserLocked({ userId: user.id, req, res })) { + return; + } + logger.info(`Verifying login email: ${email}`); if (!user || !user.emailVerificationTokenExpiry || !user.emailVerificationToken) { diff --git a/src/controllers/totp.ts b/src/controllers/totp.ts index d6633f3..b4a9c6b 100644 --- a/src/controllers/totp.ts +++ b/src/controllers/totp.ts @@ -8,6 +8,7 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { AuthEventService } from '../services/authEventService.js'; +import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; import { recordStepUpVerification, serializeStepUpStatus } from '../services/stepUpService.js'; import { @@ -161,6 +162,10 @@ export const verifyTotpLogin = async (req: Request, res: Response) => { return res.status(401).json({ error: 'unauthorized' }); } + if (await rejectIfUserLocked({ userId: user.id, req, res })) { + return; + } + const result = await verifyEnabledTotp(user.id, code); if (!result.verified) { diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index 40d7b47..307e32d 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -30,6 +30,7 @@ import { getBootstrapInviteTokenHash, maybePromoteBootstrapAdmin, } from '../services/bootstrapPromotionService.js'; +import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -456,6 +457,10 @@ const verifyWebAuthn = async (req: Request, res: Response) => { const phone = verifiedUser.phone; let user = verifiedUser; + if (await rejectIfUserLocked({ userId: user.id, req, res })) { + return; + } + if (!phone && !email) { logger.error('No pre authenticated Identifier found'); await AuthEvent.create({ diff --git a/src/lib/defineRoute.ts b/src/lib/defineRoute.ts index c9be4ce..770e9f4 100644 --- a/src/lib/defineRoute.ts +++ b/src/lib/defineRoute.ts @@ -48,6 +48,12 @@ type OpenApiResponse = { }; }; +function isZodSchema(value: unknown): value is ZodTypeAny { + return Boolean( + value && typeof value === 'object' && typeof (value as ZodTypeAny).safeParse === 'function', + ); +} + function buildResponses( response?: ZodTypeAny | Record, ): Record { @@ -57,16 +63,14 @@ function buildResponses( }; } - if (!(response instanceof Object)) { - const schema = response as ZodTypeAny; - + if (isZodSchema(response)) { return { '200': { description: 'Success', content: { 'application/json': { - schema, - example: generateExample(schema), + schema: response, + example: generateExample(response), }, }, }, @@ -179,10 +183,10 @@ export function defineRoute( let schema: ZodTypeAny | undefined; - if (typeof response === 'object') { - schema = (response as Record)[status]; - } else { + if (isZodSchema(response)) { schema = response; + } else { + schema = (response as Record)[status]; } if (schema) { diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index 91123b9..37a2291 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -14,7 +14,9 @@ import { getUsers, listAllSessions, listUserSessions, + recoverUserForDeviceReplacement, revokeAllUserSessions, + revokeUserSessionById, updateUser, } from '../controllers/admin.js'; import { @@ -29,9 +31,17 @@ import { } from '../controllers/organizations.js'; import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; +import { requireStepUp } from '../middleware/requireStepUp.js'; import { UserIdParamSchema } from '../schemas/admin.query.js'; -import { CreateUserSchema, UpdateUserSchema } from '../schemas/admin.requests.js'; -import { UserResponseSchema } from '../schemas/admin.responses.js'; +import { + CreateUserSchema, + DeviceReplacementRecoverySchema, + UpdateUserSchema, +} from '../schemas/admin.requests.js'; +import { + DeviceReplacementRecoveryResponseSchema, + UserResponseSchema, +} from '../schemas/admin.responses.js'; import { InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { AuthEventQuerySchema, PaginationQuerySchema } from '../schemas/internal.query.js'; import { @@ -47,6 +57,7 @@ import { UpdateOrganizationMemberRequestSchema, UpdateOrganizationRequestSchema, } from '../schemas/organization.requests.js'; +import { SessionIdParamsSchema } from '../schemas/session.params.js'; import { SessionListResponseSchema } from '../schemas/session.responses.js'; const adminRouter = createRouter('/admin'); @@ -270,6 +281,29 @@ adminRouter.patch( updateUser, ); +adminRouter.post( + '/users/:userId/recovery/device-replacement', + { + auth: 'access', + summary: 'Prepare a user for admin-assisted device replacement', + tags: ['Admin'], + middleware: [requireAdmin('write'), requireStepUp()], + + schemas: { + params: UserIdParamSchema, + body: DeviceReplacementRecoverySchema, + response: { + 200: DeviceReplacementRecoveryResponseSchema, + 400: InternalErrorSchema, + 401: InternalErrorSchema, + 403: InternalErrorSchema, + 404: InternalErrorSchema, + }, + }, + }, + recoverUserForDeviceReplacement, +); + adminRouter.get( '/users/:userId', { @@ -320,6 +354,24 @@ adminRouter.get( listUserSessions, ); +adminRouter.delete( + '/sessions/by-id/:id', + { + auth: 'access', + middleware: [requireAdmin('write')], + tags: ['Admin'], + schemas: { + params: SessionIdParamsSchema, + response: { + 200: MessageSchema, + 404: InternalErrorSchema, + 500: InternalErrorSchema, + }, + }, + }, + revokeUserSessionById, +); + adminRouter.delete( '/sessions/:userId/revoke-all', { diff --git a/src/schemas/admin.requests.ts b/src/schemas/admin.requests.ts index e08fb6a..4441ffb 100644 --- a/src/schemas/admin.requests.ts +++ b/src/schemas/admin.requests.ts @@ -23,3 +23,11 @@ export const UpdateUserSchema = z roles: z.array(RoleNameSchema).min(1).optional(), }) .strict(); + +export const DeviceReplacementRecoverySchema = z + .object({ + revokeSessions: z.boolean().default(true), + removePasskeys: z.boolean().default(true), + disableTotp: z.boolean().default(true), + }) + .strict(); diff --git a/src/schemas/admin.responses.ts b/src/schemas/admin.responses.ts index d279623..d393bd3 100644 --- a/src/schemas/admin.responses.ts +++ b/src/schemas/admin.responses.ts @@ -11,3 +11,10 @@ import { ApiUserSchema } from './user.schema.js'; export const UserResponseSchema = z.object({ user: ApiUserSchema, }); + +export const DeviceReplacementRecoveryResponseSchema = z.object({ + userId: z.string(), + revokedSessions: z.number().int().nonnegative(), + removedCredentials: z.number().int().nonnegative(), + disabledTotpCredentials: z.number().int().nonnegative(), +}); diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index 15a19fe..4af226f 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -9,6 +9,8 @@ import { z } from 'zod'; export const AUTH_EVENT_TYPES = [ 'auth_action_incremented', + 'admin_device_replacement_recovery', + 'admin_session_revoked', 'bearer_token_failed', 'bearer_token_success', 'bearer_token_suspicious', diff --git a/src/schemas/systemConfig.patch.schema.ts b/src/schemas/systemConfig.patch.schema.ts index ecc92ed..71e015c 100644 --- a/src/schemas/systemConfig.patch.schema.ts +++ b/src/schemas/systemConfig.patch.schema.ts @@ -8,10 +8,33 @@ import { z } from 'zod'; import type { SystemConfig } from './systemConfig.schema.js'; -import { SystemConfigSchema } from './systemConfig.schema.js'; +import { + LockoutPolicySchema, + OAuthProviderConfigSchema, + SystemConfigSchema, +} from './systemConfig.schema.js'; + +const SystemConfigPatchSchema = z + .object({ + app_name: SystemConfigSchema.shape.app_name.optional(), + default_roles: SystemConfigSchema.shape.default_roles.optional(), + available_roles: SystemConfigSchema.shape.available_roles.optional(), + login_methods: SystemConfigSchema.shape.login_methods.optional(), + passkey_login_fallback_enabled: + SystemConfigSchema.shape.passkey_login_fallback_enabled.optional(), + oauth_providers: z.array(OAuthProviderConfigSchema).optional(), + lockout_policy: LockoutPolicySchema.optional(), + access_token_ttl: SystemConfigSchema.shape.access_token_ttl.optional(), + refresh_token_ttl: SystemConfigSchema.shape.refresh_token_ttl.optional(), + rate_limit: SystemConfigSchema.shape.rate_limit.optional(), + delay_after: SystemConfigSchema.shape.delay_after.optional(), + rpid: SystemConfigSchema.shape.rpid.optional(), + origins: SystemConfigSchema.shape.origins.optional(), + }) + .strict(); export function createPatchSystemConfigSchema(existing: SystemConfig) { - return SystemConfigSchema.partial().superRefine((data, ctx) => { + return SystemConfigPatchSchema.superRefine((data, ctx) => { const nextAvailable = data.available_roles ?? existing.available_roles; const nextDefault = data.default_roles ?? existing.default_roles; diff --git a/src/schemas/systemConfig.schema.ts b/src/schemas/systemConfig.schema.ts index 62ebd29..b550032 100644 --- a/src/schemas/systemConfig.schema.ts +++ b/src/schemas/systemConfig.schema.ts @@ -27,19 +27,46 @@ export const OAuthProviderConfigSchema = z.object({ userInfoUrl: z.url(), scopes: z.array(z.string().trim().min(1)).default([]), redirectUri: z.url().optional(), + redirectUris: z.array(z.url()).default([]), subjectJsonPath: z.string().trim().min(1).default('sub'), emailJsonPath: z.string().trim().min(1).default('email'), + emailVerifiedJsonPath: z.string().trim().min(1).default('email_verified'), nameJsonPath: z.string().trim().min(1).optional(), allowSignup: z.boolean().default(true), + accountLinking: z.enum(['email', 'disabled']).default('email'), + requireEmailVerified: z.boolean().default(false), }); +export const LockoutPolicySchema = z.object({ + enabled: z.boolean().default(true), + maxFailures: z.number().int().positive().default(10), + windowSeconds: z + .number() + .int() + .positive() + .default(15 * 60), + lockoutSeconds: z + .number() + .int() + .positive() + .default(15 * 60), +}); + +export const DefaultLockoutPolicy = { + enabled: true, + maxFailures: 10, + windowSeconds: 15 * 60, + lockoutSeconds: 15 * 60, +} as const; + export const SystemConfigSchema = z.object({ app_name: z.string().min(3), default_roles: z.array(RoleNameSchema).min(1), available_roles: z.array(RoleNameSchema).min(1), login_methods: z.array(LoginMethodSchema).min(1), passkey_login_fallback_enabled: z.boolean(), - oauth_providers: z.array(OAuthProviderConfigSchema), + oauth_providers: z.array(OAuthProviderConfigSchema).default([]), + lockout_policy: LockoutPolicySchema.default(DefaultLockoutPolicy), access_token_ttl: z.string().regex(/^\d+[smhd]$/), refresh_token_ttl: z.string().regex(/^\d+[smhd]$/), @@ -53,3 +80,4 @@ export const SystemConfigSchema = z.object({ export type SystemConfig = z.infer; export type OAuthProviderConfig = z.infer; +export type LockoutPolicy = z.infer; diff --git a/src/services/lockoutPolicyService.ts b/src/services/lockoutPolicyService.ts new file mode 100644 index 0000000..43b67e3 --- /dev/null +++ b/src/services/lockoutPolicyService.ts @@ -0,0 +1,103 @@ +/* + * 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 { Op } from 'sequelize'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { AuthEvent } from '../models/authEvents.js'; +import type { LockoutPolicy } from '../schemas/systemConfig.schema.js'; +import { AuthEventService } from './authEventService.js'; + +const DEFAULT_LOCKOUT_POLICY: LockoutPolicy = { + enabled: true, + maxFailures: 10, + windowSeconds: 15 * 60, + lockoutSeconds: 15 * 60, +}; + +const LOCKOUT_FAILURE_TYPES = [ + 'login_failed', + 'webauthn_login_failed', + 'verify_otp_failed', + 'totp_failed', + 'magic_link_failed', +]; + +async function getLockoutPolicy(): Promise { + let configuredPolicy: LockoutPolicy | undefined; + + try { + const config = await getSystemConfig(); + configuredPolicy = config.lockout_policy; + } catch { + configuredPolicy = undefined; + } + + return { + ...DEFAULT_LOCKOUT_POLICY, + ...(configuredPolicy ?? {}), + }; +} + +export async function getUserLockoutStatus(userId: string, now = new Date()) { + const policy = await getLockoutPolicy(); + + if (!policy.enabled) { + return { + locked: false, + failureCount: 0, + retryAfterSeconds: 0, + policy, + }; + } + + const windowStart = new Date(now.getTime() - policy.windowSeconds * 1000); + const failureCount = + Number( + await AuthEvent.count({ + where: { + user_id: userId, + type: { [Op.in]: LOCKOUT_FAILURE_TYPES }, + created_at: { [Op.gte]: windowStart }, + }, + }), + ) || 0; + + return { + locked: failureCount >= policy.maxFailures, + failureCount, + retryAfterSeconds: policy.lockoutSeconds, + policy, + }; +} + +export async function rejectIfUserLocked(params: { userId: string; req: Request; res: Response }) { + const status = await getUserLockoutStatus(params.userId); + + if (!status.locked) { + return false; + } + + await AuthEventService.log({ + userId: params.userId, + type: 'login_suspicious', + req: params.req, + metadata: { + reason: 'Account lockout policy active', + failureCount: status.failureCount, + retryAfterSeconds: status.retryAfterSeconds, + }, + }); + + params.res.status(423).json({ + error: 'account_locked', + message: 'Too many failed authentication attempts. Try again later.', + retryAfterSeconds: status.retryAfterSeconds, + }); + + return true; +} diff --git a/src/services/oauthService.ts b/src/services/oauthService.ts index 2307c26..76079a2 100644 --- a/src/services/oauthService.ts +++ b/src/services/oauthService.ts @@ -30,6 +30,7 @@ export type OAuthStatePayload = { export type OAuthProfile = { subject: string; email: string; + emailVerified?: boolean; name?: string; raw: Record; }; @@ -82,8 +83,41 @@ function normalizeEmail(value: unknown) { return typeof value === 'string' && value.includes('@') ? value.toLowerCase() : null; } -function allowedRedirect(value: string, origins: string[]) { - return origins.some((origin) => value.startsWith(origin)); +function parseUrl(value: string) { + try { + return new URL(value); + } catch { + return null; + } +} + +function sameOrigin(value: string, allowedOrigin: string) { + const parsedValue = parseUrl(value); + const parsedAllowedOrigin = parseUrl(allowedOrigin); + + if (!parsedValue || !parsedAllowedOrigin) return false; + + return parsedValue.origin === parsedAllowedOrigin.origin; +} + +function allowedRedirect(value: string, allowedValues: string[], fallbackOrigins: string[]) { + const parsedValue = parseUrl(value); + if (!parsedValue) return false; + + if (allowedValues.length > 0) { + return allowedValues.some((allowedValue) => value === allowedValue); + } + + return fallbackOrigins.some((origin) => sameOrigin(value, origin)); +} + +function providerRedirectAllowlist(provider: OAuthProviderConfig) { + return Array.from( + new Set([ + ...(provider.redirectUris ?? []), + ...(provider.redirectUri ? [provider.redirectUri] : []), + ]), + ); } export async function getEnabledOAuthProviders() { @@ -114,9 +148,10 @@ export async function resolveOAuthRedirectUri( requestedRedirectUri?: string, ) { const config = await getSystemConfig(); + const providerAllowlist = providerRedirectAllowlist(provider); if (requestedRedirectUri) { - if (!allowedRedirect(requestedRedirectUri, config.origins)) { + if (!allowedRedirect(requestedRedirectUri, providerAllowlist, config.origins)) { throw new Error('OAuth redirect URI is not allowed'); } @@ -148,9 +183,17 @@ export function verifyOAuthState(state: string, providerId: string): OAuthStateP if (!encodedPayload || !signature) return null; if (!safeEqual(signPayload(encodedPayload), signature)) return null; - const payload = JSON.parse(base64UrlDecode(encodedPayload)) as OAuthStatePayload; + let payload: OAuthStatePayload; + + try { + payload = JSON.parse(base64UrlDecode(encodedPayload)) as OAuthStatePayload; + } catch { + return null; + } if (payload.providerId !== providerId) return null; + if (typeof payload.redirectUri !== 'string') return null; + if (typeof payload.createdAt !== 'number') return null; if (Date.now() - payload.createdAt > STATE_TTL_MS) return null; return payload; @@ -160,10 +203,12 @@ export function buildOAuthAuthorizationUrl({ provider, redirectUri, state, + nonce, }: { provider: OAuthProviderConfig; redirectUri: string; state: string; + nonce?: string; }) { const url = new URL(provider.authorizationUrl); @@ -176,6 +221,10 @@ export function buildOAuthAuthorizationUrl({ url.searchParams.set('scope', provider.scopes.join(' ')); } + if (nonce && provider.scopes.includes('openid')) { + url.searchParams.set('nonce', nonce); + } + return url.toString(); } @@ -241,6 +290,7 @@ export async function fetchOAuthProfile(provider: OAuthProviderConfig, accessTok const raw = (await response.json()) as Record; const subject = getJsonPathValue(raw, provider.subjectJsonPath); const email = normalizeEmail(getJsonPathValue(raw, provider.emailJsonPath)); + const emailVerifiedValue = getJsonPathValue(raw, provider.emailVerifiedJsonPath); const name = getJsonPathValue(raw, provider.nameJsonPath); if (typeof subject !== 'string' && typeof subject !== 'number') { @@ -251,9 +301,21 @@ export async function fetchOAuthProfile(provider: OAuthProviderConfig, accessTok throw new Error('OAuth profile did not include an email address'); } + const emailVerified = + typeof emailVerifiedValue === 'boolean' + ? emailVerifiedValue + : typeof emailVerifiedValue === 'string' + ? emailVerifiedValue.toLowerCase() === 'true' + : undefined; + + if (emailVerified === false || (provider.requireEmailVerified && emailVerified !== true)) { + throw new Error('OAuth profile email is not verified'); + } + return { subject: String(subject), email, + ...(emailVerified === undefined ? {} : { emailVerified }), ...(typeof name === 'string' ? { name } : {}), raw, } satisfies OAuthProfile; @@ -272,7 +334,12 @@ export async function resolveOAuthUser(provider: OAuthProviderConfig, profile: O if (user) return user; } - let user = await User.findOne({ where: { email: profile.email } }); + const emailUser = await User.findOne({ where: { email: profile.email } }); + let user = emailUser; + + if (emailUser && provider.accountLinking === 'disabled') { + return null; + } if (!user) { if (!provider.allowSignup) { @@ -286,7 +353,7 @@ export async function resolveOAuthUser(provider: OAuthProviderConfig, profile: O phone: `oauth:${provider.id}:${profile.subject}`.slice(0, 255), roles: config.default_roles ?? [], verified: true, - emailVerified: true, + emailVerified: profile.emailVerified ?? true, phoneVerified: false, }); } diff --git a/src/services/organizationService.ts b/src/services/organizationService.ts index f187d3b..dd5a481 100644 --- a/src/services/organizationService.ts +++ b/src/services/organizationService.ts @@ -176,6 +176,17 @@ export function isOrganizationManager(user: User, membership?: OrganizationMembe return Boolean(membership?.roles?.some((role) => role === 'owner' || role === 'admin')); } +export function hasOrganizationScope( + user: User, + membership: OrganizationMembership | null | undefined, + requiredScope: string, +) { + const requiredAdminScope = requiredScope.endsWith(':write') ? 'admin:write' : 'admin:read'; + if (hasScopedRole(user.roles, requiredAdminScope)) return true; + if (isOrganizationManager(user, membership)) return true; + return hasScopedRole(membership?.scopes, requiredScope); +} + export async function requireOrganizationAccess(user: User, organizationId: string) { const organization = await Organization.findByPk(organizationId); @@ -189,6 +200,10 @@ export async function requireOrganizationAccess(user: User, organizationId: stri return { organization: null, membership: null }; } + if (membership && !hasOrganizationScope(user, membership, 'organization:read')) { + return { organization: null, membership: null }; + } + return { organization, membership }; } diff --git a/src/utils/parseEnvConfigs.ts b/src/utils/parseEnvConfigs.ts index 67f8573..1ddd825 100644 --- a/src/utils/parseEnvConfigs.ts +++ b/src/utils/parseEnvConfigs.ts @@ -18,6 +18,7 @@ export function parseSystemConfigEnvValue(key: keyof typeof SYSTEM_CONFIG_ENV_MA .filter(Boolean); case 'oauth_providers': + case 'lockout_policy': return JSON.parse(raw); case 'rate_limit': diff --git a/tests/factories/credentialFactory.ts b/tests/factories/credentialFactory.ts index f1637a1..6d334ec 100644 --- a/tests/factories/credentialFactory.ts +++ b/tests/factories/credentialFactory.ts @@ -6,7 +6,7 @@ export function buildCredential(overrides: any = {}) { userId: 'user-1', friendlyName: 'My Device', transports: [], - deviceType: 'platform', + deviceType: 'singleDevice', backedup: false, backedUp: false, prfCapable: false, diff --git a/tests/integration/admin/admin.spec.ts b/tests/integration/admin/admin.spec.ts index 4f19f68..d58af7b 100644 --- a/tests/integration/admin/admin.spec.ts +++ b/tests/integration/admin/admin.spec.ts @@ -8,6 +8,9 @@ import { User } from '../../../src/models/users.js'; import { buildUser, testGuid } from '../../factories/userFactory'; import { AuthEvent } from '../../../src/models/authEvents.js'; import { Session } from '../../../src/models/sessions.js'; +import { TotpCredential } from '../../../src/models/totpCredentials.js'; +import { hardRevokeSession } from '../../../src/services/sessionService.js'; +import { buildCredential } from '../../factories/credentialFactory.js'; import { buildSession } from '../../factories/sessionFactory.js'; let app: Application; @@ -130,6 +133,26 @@ describe('GET /admin/sessions/:userId', () => { }); }); +describe('DELETE /admin/sessions/by-id/:id', () => { + it('revokes a single admin-managed session', async () => { + const session = buildSession({ id: 'session-to-revoke', userId: testGuid }); + (Session.findOne as any).mockResolvedValue(session); + + const res = await request(app).delete('/admin/sessions/by-id/session-to-revoke'); + + expect(res.status).toBe(200); + expect(hardRevokeSession).toHaveBeenCalledWith(session, 'admin_revoke'); + }); + + it('returns 404 when a single admin-managed session is missing', async () => { + (Session.findOne as any).mockResolvedValue(null); + + const res = await request(app).delete('/admin/sessions/by-id/missing-session'); + + expect(res.status).toBe(404); + }); +}); + describe('DELETE /admin/sessions/:userId/revoke-all', () => { it('revokes all sessions', async () => { (Session.findAll as any).mockResolvedValue([{ id: 's1' }, { id: 's2' }]); @@ -140,6 +163,56 @@ describe('DELETE /admin/sessions/:userId/revoke-all', () => { }); }); +describe('POST /admin/users/:userId/recovery/device-replacement', () => { + it('revokes sessions, removes passkeys, and disables TOTP for device replacement', async () => { + const sessions = [buildSession({ id: 's1' }), buildSession({ id: 's2' })]; + const credentials = [buildCredential({ id: 'cred-1' }), buildCredential({ id: 'cred-2' })]; + + (Session.findOne as any).mockResolvedValue( + buildSession({ stepUpVerifiedAt: new Date(), stepUpMethod: 'webauthn' }), + ); + (User.findByPk as any).mockResolvedValue(buildUser({ id: testGuid })); + (Session.findAll as any).mockResolvedValue(sessions); + (Credential.findAll as any).mockResolvedValue(credentials); + (TotpCredential.update as any).mockResolvedValue([1]); + + const res = await request(app) + .post(`/admin/users/${testGuid}/recovery/device-replacement`) + .send({}); + + expect(res.status).toBe(200); + expect(hardRevokeSession).toHaveBeenCalledTimes(2); + expect(credentials[0].destroy).toHaveBeenCalled(); + expect(credentials[1].destroy).toHaveBeenCalled(); + expect(TotpCredential.update).toHaveBeenCalledWith( + { enabled: false }, + expect.objectContaining({ + where: expect.objectContaining({ userId: testGuid, enabled: true }), + }), + ); + expect(res.body).toEqual({ + userId: testGuid, + revokedSessions: 2, + removedCredentials: 2, + disabledTotpCredentials: 1, + }); + }); + + it('requires a fresh step-up verification', async () => { + (Session.findOne as any).mockResolvedValue( + buildSession({ stepUpVerifiedAt: null, stepUpMethod: null }), + ); + + const res = await request(app) + .post(`/admin/users/${testGuid}/recovery/device-replacement`) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('step_up_required'); + expect(User.findByPk).not.toHaveBeenCalled(); + }); +}); + describe('GET /admin/auth-events', () => { it('returns events', async () => { (AuthEvent.findAll as any).mockResolvedValue([]); diff --git a/tests/integration/oauth/oauth.spec.ts b/tests/integration/oauth/oauth.spec.ts index 867733e..4563199 100644 --- a/tests/integration/oauth/oauth.spec.ts +++ b/tests/integration/oauth/oauth.spec.ts @@ -29,10 +29,14 @@ const provider = { userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', scopes: ['openid', 'email'], redirectUri: 'http://localhost:5174/oauth/callback', + redirectUris: ['http://localhost:5174/oauth/callback'], subjectJsonPath: 'sub', emailJsonPath: 'email', + emailVerifiedJsonPath: 'email_verified', nameJsonPath: 'name', allowSignup: true, + accountLinking: 'email' as const, + requireEmailVerified: false, }; beforeAll(async () => { @@ -76,6 +80,15 @@ describe('OAuth routes', () => { expect(res.body.state).toMatch(/\./); expect(res.body.authorizationUrl).toContain('client_id=client-id'); expect(res.body.authorizationUrl).toContain('state='); + expect(res.body.authorizationUrl).toContain('nonce='); + }); + + it('rejects redirect URI prefix lookalikes', async () => { + const res = await request(app).post('/oauth/google/start').send({ + redirectUri: 'http://localhost:5174.evil.test/oauth/callback', + }); + + expect(res.status).toBe(400); }); it('finishes OAuth login and issues a SeamlessAuth session', async () => { @@ -102,6 +115,7 @@ describe('OAuth routes', () => { json: async () => ({ sub: 'provider-user', email: 'person@example.com', + email_verified: true, name: 'Person Example', }), }); diff --git a/tests/integration/organizations/organizations.spec.ts b/tests/integration/organizations/organizations.spec.ts index e8a3bde..ff6ac22 100644 --- a/tests/integration/organizations/organizations.spec.ts +++ b/tests/integration/organizations/organizations.spec.ts @@ -100,6 +100,34 @@ describe('organizations', () => { ); }); + it('does not expose another organization to a non-member', async () => { + (Organization.findByPk as any).mockResolvedValue( + buildOrganization({ + id: '9c793c14-7009-4524-b889-23284c6999c2', + }), + ); + (OrganizationMembership.findOne as any).mockResolvedValue(null); + + const res = await request(app).get('/organizations/9c793c14-7009-4524-b889-23284c6999c2'); + + expect(res.status).toBe(404); + }); + + it('requires members:read access before listing organization members', async () => { + (Organization.findByPk as any).mockResolvedValue(buildOrganization()); + (OrganizationMembership.findOne as any).mockResolvedValue( + buildOrganizationMembership({ + roles: ['member'], + scopes: ['organization:read'], + }), + ); + + const res = await request(app).get(`/organizations/${testOrganizationId}/members`); + + expect(res.status).toBe(404); + expect(OrganizationMembership.findAll).not.toHaveBeenCalled(); + }); + it('switches the current session organization', async () => { const session = buildSession({ id: 'session-1', userId: 'user-1' }); diff --git a/tests/unit/lib/defineRoute.spec.ts b/tests/unit/lib/defineRoute.spec.ts index 7e2c48a..91fd1d1 100644 --- a/tests/unit/lib/defineRoute.spec.ts +++ b/tests/unit/lib/defineRoute.spec.ts @@ -117,13 +117,14 @@ describe('defineRoute', () => { const layer = (router as any).stack.find((entry: any) => entry.route?.path === '/ordered'); const handlers = layer.route.stack.map((entry: any) => entry.handle); const req = { params: {}, query: {}, body: {} }; + const json = vi.fn(); const res = { statusCode: 200, status(code: number) { this.statusCode = code; return this; }, - json: vi.fn(), + json, }; const run = async (index: number): Promise => { @@ -140,4 +141,66 @@ describe('defineRoute', () => { expect(order).toEqual(['auth:access', 'custom', 'handler']); }); + + it('documents and validates a direct Zod response schema as HTTP 200', async () => { + const { defineRoute } = await import('../../../src/lib/defineRoute'); + const { registry } = await import('../../../src/openapi/registry'); + const router = Router(); + + defineRoute(router, { + method: 'get', + path: '/direct-schema', + schemas: { + response: z.object({ + message: z.string(), + }), + }, + handler: (_req, res) => { + res.status(200).json({ message: 'ok', extra: 'removed' }); + }, + }); + + expect(registry.registerPath).toHaveBeenCalledWith( + expect.objectContaining({ + responses: expect.objectContaining({ + '200': expect.objectContaining({ + content: expect.objectContaining({ + 'application/json': expect.objectContaining({ + schema: expect.any(Object), + }), + }), + }), + }), + }), + ); + + const layer = (router as any).stack.find( + (entry: any) => entry.route?.path === '/direct-schema', + ); + const handlers = layer.route.stack.map((entry: any) => entry.handle); + const req = { params: {}, query: {}, body: {} }; + const json = vi.fn(); + const res = { + statusCode: 200, + status(code: number) { + this.statusCode = code; + return this; + }, + json, + }; + + const run = async (index: number): Promise => { + const handler = handlers[index]; + + if (!handler) { + return; + } + + await handler(req, res, () => run(index + 1)); + }; + + await run(0); + + expect(json).toHaveBeenCalledWith({ message: 'ok' }); + }); }); diff --git a/tests/unit/services/lockoutPolicyService.spec.ts b/tests/unit/services/lockoutPolicyService.spec.ts new file mode 100644 index 0000000..31f1b59 --- /dev/null +++ b/tests/unit/services/lockoutPolicyService.spec.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/config/getSystemConfig.js', () => ({ + getSystemConfig: vi.fn(), +})); + +vi.mock('../../../src/models/authEvents.js', () => ({ + AuthEvent: { + count: vi.fn(), + }, +})); + +vi.mock('../../../src/services/authEventService.js', () => ({ + AuthEventService: { + log: vi.fn(), + }, +})); + +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { AuthEvent } from '../../../src/models/authEvents.js'; +import { AuthEventService } from '../../../src/services/authEventService.js'; +import { + getUserLockoutStatus, + rejectIfUserLocked, +} from '../../../src/services/lockoutPolicyService.js'; + +describe('lockoutPolicyService', () => { + beforeEach(() => { + vi.clearAllMocks(); + (getSystemConfig as any).mockResolvedValue({ + lockout_policy: { + enabled: true, + maxFailures: 3, + windowSeconds: 900, + lockoutSeconds: 600, + }, + }); + }); + + it('reports unlocked when failures are below the configured threshold', async () => { + (AuthEvent.count as any).mockResolvedValue(2); + + await expect(getUserLockoutStatus('user-1')).resolves.toEqual( + expect.objectContaining({ + locked: false, + failureCount: 2, + }), + ); + }); + + it('reports locked when failures meet the configured threshold', async () => { + (AuthEvent.count as any).mockResolvedValue(3); + + await expect(getUserLockoutStatus('user-1')).resolves.toEqual( + expect.objectContaining({ + locked: true, + failureCount: 3, + retryAfterSeconds: 600, + }), + ); + }); + + it('returns a lockout response and audit event when active', async () => { + (AuthEvent.count as any).mockResolvedValue(3); + + const req = { + ip: '127.0.0.1', + headers: { 'user-agent': 'vitest' }, + } as any; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as any; + + await expect(rejectIfUserLocked({ userId: 'user-1', req, res })).resolves.toBe(true); + + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + type: 'login_suspicious', + }), + ); + expect(res.status).toHaveBeenCalledWith(423); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'account_locked', + retryAfterSeconds: 600, + }), + ); + }); +}); diff --git a/tests/unit/services/oauthService.spec.ts b/tests/unit/services/oauthService.spec.ts index 2ad432d..2a77974 100644 --- a/tests/unit/services/oauthService.spec.ts +++ b/tests/unit/services/oauthService.spec.ts @@ -26,10 +26,14 @@ const provider = { tokenUrl: 'https://oauth2.googleapis.com/token', userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', scopes: ['openid', 'email', 'profile'], + redirectUris: [], subjectJsonPath: 'sub', emailJsonPath: 'email', + emailVerifiedJsonPath: 'email_verified', nameJsonPath: 'name', allowSignup: true, + accountLinking: 'email' as const, + requireEmailVerified: false, }; describe('oauthService', () => { @@ -77,6 +81,18 @@ describe('oauthService', () => { expect(url).toContain('response_type=code'); expect(url).toContain('scope=openid+email+profile'); expect(url).toContain('state=state'); + expect(url).not.toContain('nonce='); + }); + + it('adds an OIDC nonce when one is supplied for openid scopes', () => { + const url = buildOAuthAuthorizationUrl({ + provider, + redirectUri: 'https://app.example.com/oauth/callback', + state: 'state', + nonce: 'nonce-value', + }); + + expect(url).toContain('nonce=nonce-value'); }); it('rejects redirect URIs outside configured origins', async () => { @@ -85,6 +101,12 @@ describe('oauthService', () => { ).rejects.toThrow('OAuth redirect URI is not allowed'); }); + it('rejects redirect URI prefix lookalikes', async () => { + await expect( + resolveOAuthRedirectUri(provider, 'https://app.example.com.evil.test/oauth/callback'), + ).rejects.toThrow('OAuth redirect URI is not allowed'); + }); + it('exchanges code and parses profile without exposing provider tokens', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); @@ -99,6 +121,7 @@ describe('oauthService', () => { json: async () => ({ sub: 'provider-user', email: 'Person@Example.com', + email_verified: true, name: 'Person Example', }), }); @@ -114,15 +137,35 @@ describe('oauthService', () => { expect(profile).toEqual({ subject: 'provider-user', email: 'person@example.com', + emailVerified: true, name: 'Person Example', raw: { sub: 'provider-user', email: 'Person@Example.com', + email_verified: true, name: 'Person Example', }, }); }); + it('rejects provider profiles with explicitly unverified email addresses', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sub: 'provider-user', + email: 'person@example.com', + email_verified: false, + }), + }); + + await expect(fetchOAuthProfile(provider, 'provider-token')).rejects.toThrow( + 'OAuth profile email is not verified', + ); + }); + it('links an OAuth profile to an existing user', async () => { const user = buildUser({ id: 'user-1', email: 'person@example.com' }); @@ -152,4 +195,28 @@ describe('oauthService', () => { }), ); }); + + it('does not link an OAuth profile to an existing user when account linking is disabled', async () => { + const user = buildUser({ id: 'user-1', email: 'person@example.com' }); + + (OAuthIdentity.findOne as any).mockResolvedValue(null); + (User.findOne as any).mockResolvedValue(user); + + await expect( + resolveOAuthUser( + { + ...provider, + accountLinking: 'disabled', + }, + { + subject: 'provider-user', + email: 'person@example.com', + emailVerified: true, + raw: {}, + }, + ), + ).resolves.toBeNull(); + + expect(OAuthIdentity.findOrCreate).not.toHaveBeenCalled(); + }); });