From d794476fff6c587357b312cf38c2090732ed9ad9 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 25 Aug 2026 10:08:32 -0400 Subject: [PATCH 1/2] feat: add full local and hybrid identity management (AXI-180) --- .env.example | 23 +- DEVLOG.md | 31 + docs/guide/instance-admin.md | 69 +- docs/guide/local-development.md | 18 +- docs/guide/settings.md | 81 ++- docs/reference/env.md | 65 +- .../migration.sql | 166 +++++ prisma/schema.prisma | 158 ++++- prisma/seed.ts | 37 +- src/app/(app)/settings/account/page.tsx | 130 +++- src/app/(app)/settings/connections/page.tsx | 54 +- src/app/(app)/settings/page.tsx | 4 +- src/app/(app)/settings/security/page.tsx | 422 ++++++++++++ src/app/(auth)/activate/[token]/page.tsx | 124 ++++ src/app/(auth)/forgot-password/page.tsx | 73 ++ src/app/(auth)/invite/[token]/local/page.tsx | 100 +++ src/app/(auth)/invite/[token]/page.tsx | 72 +- .../(auth)/reset-password/[token]/page.tsx | 119 ++++ src/app/(auth)/signin/local/page.tsx | 124 ++++ src/app/(auth)/signin/page.tsx | 329 +++++---- src/app/(auth)/signup/page.tsx | 59 ++ src/app/api/avatar/[userId]/route.ts | 52 ++ src/components/admin-shell/admin-overview.tsx | 10 +- src/components/admin-shell/admin-users.tsx | 191 +++++- .../admin-shell/auth-policy-settings.tsx | 233 +++++++ .../admin-shell/identity-settings.tsx | 5 +- src/components/auth/auth-card-shell.tsx | 42 ++ src/components/auth/auth-message.tsx | 26 + src/components/auth/provider-form.tsx | 70 ++ src/components/settings/settings-nav.ts | 7 + src/components/settings/settings-rail.tsx | 4 +- src/lib/auth-callback.ts | 22 + src/server/actions/identity-linking.ts | 22 + src/server/actions/invitations.ts | 35 +- src/server/actions/local-auth.ts | 179 +++++ src/server/auth.ts | 468 +++++++++---- .../routers/__tests__/identity-policy.test.ts | 142 ++++ .../__tests__/project-authorization.test.ts | 84 +++ src/server/routers/_app.ts | 2 + src/server/routers/avatar.ts | 93 +++ src/server/routers/instance-admin.ts | 371 ++++++++-- src/server/routers/project.ts | 19 +- src/server/routers/sso.ts | 207 +++++- src/server/routers/user.ts | 236 +++++++ .../local-invitation-registration.test.ts | 96 +++ .../services/__tests__/user-avatar.test.ts | 121 ++++ .../services/__tests__/user-lifecycle.test.ts | 296 ++++++++ src/server/services/auth-policy.ts | 156 +++++ src/server/services/auth-tokens.ts | 156 +++++ src/server/services/authorization.ts | 117 ++++ src/server/services/email.ts | 220 +++++- src/server/services/local-credentials.ts | 161 +++++ src/server/services/user-avatar.ts | 367 ++++++++++ src/server/services/user-lifecycle.ts | 634 ++++++++++++++++++ src/server/services/workspace-invitations.ts | 163 ++++- src/server/sso.ts | 2 +- tests/e2e/global-setup.ts | 16 +- tests/e2e/identity-auth.spec.ts | 159 +++++ tests/unit/auth-callback.test.ts | 30 + tests/unit/auth-policy.test.ts | 64 ++ tests/unit/auth-tokens.test.ts | 119 ++++ tests/unit/authorization.test.ts | 134 ++++ tests/unit/email.test.ts | 126 ++++ tests/unit/local-credentials.test.ts | 35 + tests/unit/user-avatar.test.ts | 43 ++ 65 files changed, 7494 insertions(+), 499 deletions(-) create mode 100644 prisma/migrations/20260825150000_local_identity_foundation/migration.sql create mode 100644 src/app/(app)/settings/security/page.tsx create mode 100644 src/app/(auth)/activate/[token]/page.tsx create mode 100644 src/app/(auth)/forgot-password/page.tsx create mode 100644 src/app/(auth)/invite/[token]/local/page.tsx create mode 100644 src/app/(auth)/reset-password/[token]/page.tsx create mode 100644 src/app/(auth)/signin/local/page.tsx create mode 100644 src/app/(auth)/signup/page.tsx create mode 100644 src/app/api/avatar/[userId]/route.ts create mode 100644 src/components/admin-shell/auth-policy-settings.tsx create mode 100644 src/components/auth/auth-card-shell.tsx create mode 100644 src/components/auth/auth-message.tsx create mode 100644 src/components/auth/provider-form.tsx create mode 100644 src/lib/auth-callback.ts create mode 100644 src/server/actions/identity-linking.ts create mode 100644 src/server/actions/local-auth.ts create mode 100644 src/server/routers/__tests__/identity-policy.test.ts create mode 100644 src/server/routers/__tests__/project-authorization.test.ts create mode 100644 src/server/routers/avatar.ts create mode 100644 src/server/services/__tests__/local-invitation-registration.test.ts create mode 100644 src/server/services/__tests__/user-avatar.test.ts create mode 100644 src/server/services/__tests__/user-lifecycle.test.ts create mode 100644 src/server/services/auth-policy.ts create mode 100644 src/server/services/auth-tokens.ts create mode 100644 src/server/services/authorization.ts create mode 100644 src/server/services/local-credentials.ts create mode 100644 src/server/services/user-avatar.ts create mode 100644 src/server/services/user-lifecycle.ts create mode 100644 tests/e2e/identity-auth.spec.ts create mode 100644 tests/unit/auth-callback.test.ts create mode 100644 tests/unit/auth-policy.test.ts create mode 100644 tests/unit/auth-tokens.test.ts create mode 100644 tests/unit/authorization.test.ts create mode 100644 tests/unit/email.test.ts create mode 100644 tests/unit/local-credentials.test.ts create mode 100644 tests/unit/user-avatar.test.ts diff --git a/.env.example b/.env.example index 749daaa5..20bb429b 100644 --- a/.env.example +++ b/.env.example @@ -13,13 +13,18 @@ REDIS_URL=redis://localhost:6379 # Auth — NextAuth v5 # AUTH_SECRET also keys the AES-256-GCM encryption of stored SSO client -# secrets (src/server/crypto.ts) — rotating it invalidates them, so re-enter -# providers in Settings → Authentication after a rotation. +# secrets (src/server/crypto.ts) — rotating it invalidates sessions and those +# secrets, so re-enter providers in Identity & sign-in after a rotation. AUTH_SECRET=change-me-to-32-random-bytes AUTH_URL=http://localhost:3000 -ADMIN_EMAIL=admin@example.local # the instance admin (manages SSO providers) +# Environment-backed bootstrap/break-glass instance administrator. Normal +# local users have durable, individually hashed passwords in Postgres. +ADMIN_EMAIL=admin@example.local +ADMIN_PASSWORD=change-me-bootstrap-password +ADMIN_NAME=Forge Administrator +ADMIN_HANDLE=admin -# OAuth providers are now managed in the UI (Settings → Authentication), +# OAuth providers are now managed in the UI (Identity & sign-in), # stored in the SsoProvider table. These env vars are OPTIONAL bootstrap # only: if set and no matching DB row exists yet, a provider row is seeded # from them on first boot. After that, manage everything in the UI. @@ -28,10 +33,11 @@ AUTH_GITHUB_SECRET= AUTH_GOOGLE_ID= AUTH_GOOGLE_SECRET= -# Outbound workspace invitations. EMAIL_SERVER is the compact SMTP URL form; +# Outbound account setup, password reset, password-change, and workspace +# invitation mail. EMAIL_SERVER is the compact SMTP URL form; # alternatively set SMTP_HOST/SMTP_PORT/SMTP_SECURE/SMTP_USER/SMTP_PASSWORD, # or use RESEND_API_KEY. Production invite attempts fail closed when no -# transport is configured. +# account-email attempt fails closed when no transport is configured. EMAIL_SERVER=smtp://user:pass@smtp.example.com:587 EMAIL_FROM="Forge " SMTP_HOST= @@ -87,7 +93,9 @@ FORGE_AI_BASE_URL= FORGE_AI_API_KEY= # ---- Object storage (S3 / MinIO) ---- -# Forge stores attachments in an S3-compatible bucket per workspace. +# Forge stores attachments in an S3-compatible bucket per workspace. Global +# user avatars use S3_GLOBAL_BUCKET (default forge-global) with the same S3 +# endpoint and credentials, keeping account media outside tenant buckets. # In dev we run MinIO via docker/docker-compose.yml: # # docker compose -f docker/docker-compose.yml up -d minio @@ -105,3 +113,4 @@ S3_ACCESS_KEY=forgeminio S3_SECRET_KEY=forgeminio-dev-password S3_REGION=us-east-1 S3_FORCE_PATH_STYLE=true +S3_GLOBAL_BUCKET=forge-global diff --git a/DEVLOG.md b/DEVLOG.md index 39f72a59..2f43aae4 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,37 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-08-25 — Canonical local and external user identity foundation + +- Added a provider-neutral instance authentication policy for local-only, + external-only, and hybrid sign-in, including registration, safe provider + auto-redirect, protected environment-backed break glass, password/reset + policy, and lockout controls. +- Added durable per-user scrypt credentials, single-use hashed account setup + and password-reset tokens, enumeration-safe reset requests, session + revocation generations, and audited invited/active/suspended/deleted account + lifecycle management with last-admin and last-workspace-owner guards. +- Added atomic invite-based local registration: a pending workspace invitation + can create the canonical user, verified email, local credential, and + membership in one transaction, while existing accounts must authenticate + before the invitation can affect their login methods. +- Kept local passwords and linked OIDC/OAuth login identities on one canonical + user while preserving Integration Connections as a separate authorization + boundary. Added self-service login-method management and global S3-backed + profile pictures with provider-image fallback. +- Added workspace-role authorization enforcement for project mutations and + policy primitives for future restricted-project and integration capability + grants. Grant persistence and management UI remain intentionally deferred. +- Kept the database migration additive and compatibility-first: existing users + are normalized, the prior hybrid behavior is seeded, and case-variant email + conflicts fail explicitly instead of silently merging accounts. +- Verification: the migration applied from an empty v0.32-compatible database; + lint and typecheck passed; the serialized local suite passed 1,577 tests with + one intentional skip; focused identity/authorization coverage passed 58 + tests; the complete Playwright run passed 59 tests with one scenario-only + skip; and the rebuilt focused identity suite passed all four local login, + enumeration-safe reset, password rotation, and invite-registration flows. + ## 2026-07-30 — v0.32.0 release preparation - Squash-merged AXI-168 implementation PR #94 to `main` at diff --git a/docs/guide/instance-admin.md b/docs/guide/instance-admin.md index d160b50f..ca4404b5 100644 --- a/docs/guide/instance-admin.md +++ b/docs/guide/instance-admin.md @@ -51,10 +51,71 @@ so they can manage it immediately). ### Users (`/admin/users`) -Every user on the instance, with their instance role, workspace count, -and handle. Promote or demote a user's `instanceRole` inline, and -invite a new user (optionally as an instance admin). This is the only -place instance role is set. +Every canonical user on the instance, including lifecycle status, attached +login methods, instance role, workspace count, and handle. Administrators can: + +- create an invited user and email a single-use account-setup link; +- resend setup for an invited user or send password reset for a user who has a + local password; +- promote or demote `instanceRole` and revoke every active session; +- suspend and later reactivate an account; or +- soft-delete and anonymize an account while preserving authored work and + audit attribution. + +Suspension immediately invalidates sessions, revokes personal API keys, +invalidates pending account tokens, disconnects user-owned integration +credentials, and pauses their workspace mappings. Deletion additionally +removes local and linked login credentials, memberships, and the global avatar, +and replaces personal fields with a tombstone. Reactivation restores the Forge +principal but does not restore revoked keys or external credential tokens. + +Safety guards refuse demotion, suspension, or deletion of the last active +instance administrator. They also refuse suspension or deletion when the user +is the last active owner of any workspace; transfer ownership first. This is +the only place instance role and account lifecycle are administered. + +### Identity & sign-in (`/settings/auth`) + +Instance administrators own the singleton authentication policy and the +global OIDC, GitHub, and Google provider registry. The modes are: + +| Mode | Normal sign-in methods | +| --------------- | --------------------------------------------------------- | +| `LOCAL_ONLY` | Durable Forge passwords | +| `EXTERNAL_ONLY` | Enabled external providers; optional operator break glass | +| `HYBRID` | Durable passwords plus enabled external providers | + +The policy also controls registration, optional automatic redirect, password +minimum length, reset expiry, and lockout behavior. Provider/client secrets +remain encrypted with `AUTH_SECRET`; the environment operator remains a +separate recovery credential when break glass is enabled. + +All identity-policy and account-lifecycle mutations write the instance-wide +security audit ledger with actor, target, request metadata, and timestamp. + +::: warning Authorization scope in this release +The identity core enforces existing workspace roles and closes project mutation +paths that previously treated every membership as equivalent. The schema and +management UI for restricted-project grants and per-integration capabilities +(for example GitHub repo read/link/sync/write) remain follow-up work. A login +identity never implies permission to use an Integration Connection. +::: + +## Migration and rollback compatibility + +The identity migration is additive. It backfills normalized email keys, creates +the policy/credential/token/avatar/audit tables, adds provider archival state, +and seeds `HYBRID + INVITE_ONLY + break glass` to preserve the +pre-policy presentation. Migration aborts if case-insensitive duplicate user +emails already exist, rather than merging people silently. + +Do not drop the new tables as a routine rollback. An older Forge binary can +ignore the additive columns, but it cannot authenticate durable +`LocalCredential` passwords or enforce the new lifecycle/policy state. Before +an application rollback, ensure the environment bootstrap operator and a +working external provider are available; local-only users otherwise cannot +sign in until the new version is restored. Keep the migrated data intact and +roll forward after diagnosis. ### Runtimes (`/admin/runtimes`) diff --git a/docs/guide/local-development.md b/docs/guide/local-development.md index ff4fcce3..2c1322de 100644 --- a/docs/guide/local-development.md +++ b/docs/guide/local-development.md @@ -26,13 +26,27 @@ Every command prints what it started, skipped, migrated, generated, seeded, or replaced plus the selected local database and endpoints. The TypeScript orchestrator works when invoked from Windows PowerShell or Git Bash. -Sign in with the bootstrap credentials it prints: +Sign in with the durable local owner account it prints: ``` owner@forge.local / forge-dev ``` -(Override via `ADMIN_EMAIL` / `ADMIN_PASSWORD` env vars before running.) +On an empty local database the seed creates a `LocalCredential` for this user. +Override the owner address with `FORGE_SEED_OWNER_EMAIL` (or +`E2E_OWNER_EMAIL` under E2E) and the seeded password with +`FORGE_SEED_OWNER_PASSWORD`, `E2E_OWNER_PASSWORD`, or `ADMIN_PASSWORD` (in +that precedence order). Existing credentials are never overwritten by a later +seed. +`ADMIN_EMAIL` / `ADMIN_PASSWORD` remain the separate bootstrap and break-glass +operator path, so local development can exercise ordinary password login and +recovery without conflating it with emergency access. + +Account setup and password reset send email through the configured local mail +transport. For end-to-end tests, use the fixture-captured delivery/link rather +than a real mailbox. Avatar uploads use the same local MinIO service as +attachments but live in the instance-global `S3_GLOBAL_BUCKET` (default +`forge-global`), not a workspace attachment bucket. The first visit to a route is slower than later refreshes because Next compiles that route on demand in development. Forge pins Turbopack to the repository diff --git a/docs/guide/settings.md b/docs/guide/settings.md index c49c20fe..d6a8aa15 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -97,8 +97,10 @@ interact with the dispatch mode. Invite, remove, and manage members. - Invite — send an expiring, single-use email link for `ADMIN`, `MEMBER`, or - `GUEST`. The recipient must authenticate as the invited email before Forge - creates membership; `OWNER` remains transfer-only. + `GUEST`. Existing users authenticate as the invited email. When local + registration is enabled, a new recipient can instead create the canonical + user, verified email, password, and membership atomically from that exact + invitation. `OWNER` remains transfer-only. - Invitations — pending invites appear before accepted, expired, and revoked history. Resend rotates the bearer token only after the replacement email is accepted by the configured provider; revoke disables the pending token. @@ -189,13 +191,43 @@ URL: `/settings`. These follow the user across all workspaces. ### Account -- **Email** — the address NextAuth knows you by. Editable; verification - required. +- **Profile picture** — upload one PNG, JPEG, GIF, or WebP image up to 5 MiB. + It is global to your Forge account, not copied into each workspace. Removing + it restores the last linked-provider picture when available. +- **Email** — the case-insensitive canonical address shared by every login + method attached to this account. - **Handle** — your `@handle`, used in mentions and the activity feed. - **Name** — display name. -- **Password** — change password (if password auth is enabled in this - deployment). -- **Sessions** — list of active sessions; revoke individually. + +### Security & sign-in + +URL: `/settings/security`. A Forge account is one canonical `User`; local and +external login methods can be added to or removed from that same account. + +- **Local password** — add or change a durable password. Changing it revokes + existing sessions. Removing it requires another linked login method and is + forbidden while the instance is in local-only mode. +- **Linked login methods** — attach enabled OIDC, GitHub, or Google identities, + or unlink one after another login method exists. Linking proves control of + the provider account; unlinking revokes existing Forge sessions. +- **Sessions** — sign out all devices. Password, login-method, lifecycle, and + role changes also increment the account authorization version so existing + JWT sessions stop authorizing. + +The sign-in screen exposes **Forgot password** when local credentials are +available. Reset requests are enumeration-safe, rate-limited, expire according +to instance policy, and consume a single-use emailed token. Administrators can +also send a reset for a non-deleted account with a local password from +`/admin/users`; suspension still prevents sign-in until an administrator +reactivates the account. + +::: info Login identity is not an integration connection +An Auth.js `Account` row proves who you are when signing into Forge. An +**Integration account** under `/settings/connections` authorizes operations +against GitHub or another external system and may be mapped into workspaces. +Linking or unlinking a GitHub login never creates, changes, or deletes a GitHub +integration connection. +::: ### Appearance @@ -224,14 +256,34 @@ See [Automation → API keys](/automation/api-keys.html). ### Authentication -URL: `/settings/auth`. **Instance-admin only** — gated on the operator -whose email matches `ADMIN_EMAIL`, since sign-in providers are global to -the whole self-hosted instance (auth is per _user_, not per workspace). +URL: `/settings/auth`. **Instance-admin only** — gated by +`User.instanceRole === INSTANCE_ADMIN` (with `ADMIN_EMAIL` as the bootstrap +fallback), because sign-in policy and providers apply to the whole instance. Configure how people sign in, without a redeploy: -- **Email + password** is always available (the bootstrap admin - credential). It can't be removed here. +- **Local only** — present and accept durable Forge passwords. External + providers are not loaded. +- **External only** — present enabled OIDC/OAuth providers. Forge refuses this + mode until at least one external provider is usable. The protected + environment-backed operator may still use `/signin/local` when break glass + is enabled. +- **Hybrid** — present local passwords and any enabled external providers. +- **Automatic redirect** — select one enabled provider to redirect normal + sign-in visits automatically, or leave it unset for the provider chooser. + Manual and error-return visits bypass automatic redirect to avoid loops. +- **Registration** — `DISABLED` requires an administrator-created principal; + `INVITE_ONLY` accepts first-time external sign-in or atomic local account + creation only from an exact invitation; `OPEN` additionally exposes local + email verification/setup and permits eligible external identities to create + their canonical account. Administrator-created local users also activate + through a one-time setup link. +- **Password policy** — configure minimum length, reset-link expiry, failed + attempt threshold, and lockout duration. Passwords are stored as versioned + scrypt hashes; raw passwords and raw reset/setup tokens are never persisted. +- **Break glass** — keeps only the `ADMIN_EMAIL` / `ADMIN_PASSWORD` operator + credential available at `/signin/local`. It does not turn every local user + into a break-glass administrator. - **Add a provider** — pick a type: - **OpenID Connect (OIDC)** — the generic, discovery-based type. Covers any OIDC IdP: self-hosted **Authelia**, Authentik, Keycloak, or hosted @@ -249,6 +301,11 @@ Configure how people sign in, without a redeploy: trust the IdP to assert verified emails. - **Enable / disable** toggles take effect within ~30s, no restart. +Forge prevents policy/provider changes that would select a missing automatic +redirect target or leave external-only mode without an enabled provider. +Disabling, archiving, or deleting an automatic redirect provider clears that +selection. + Existing `AUTH_GITHUB_*` / `AUTH_GOOGLE_*` env vars (if set) are seeded into this table once on first boot, then managed here — see [Reference → Environment](/reference/env.html#auth-nextauth-v5). diff --git a/docs/reference/env.md b/docs/reference/env.md index a946eb5a..009c5369 100644 --- a/docs/reference/env.md +++ b/docs/reference/env.md @@ -29,8 +29,8 @@ transaction mode, append `?pgbouncer=true&connection_limit=1` to | `AUTH_URL` | Yes | Public app URL (e.g. `https://forge.example`). | | `AUTH_SECRET` | Yes | JWT secret. Generate with `openssl rand -base64 32`. Also keys the AES-256-GCM encryption of stored SSO client secrets. | | `AUTH_TRUST_HOST` | No | Set to `true` if proxied behind a load balancer. | -| `ADMIN_EMAIL` | Yes | Bootstrap admin login **and** the instance admin who manages SSO providers (Settings → Authentication). | -| `ADMIN_PASSWORD` | Yes | Password for the `ADMIN_EMAIL` credential login. | +| `ADMIN_EMAIL` | Yes | Environment-backed bootstrap and break-glass instance administrator. | +| `ADMIN_PASSWORD` | Yes | Password for the bootstrap/break-glass operator. It is not a normal user's durable local password. | | `ADMIN_NAME` / `ADMIN_HANDLE` | No | Display name / handle for the bootstrap admin. | ```bash @@ -41,10 +41,23 @@ ADMIN_EMAIL="admin@forge.example" ADMIN_PASSWORD="..." ``` +Normal local accounts do not use environment variables. Forge stores a +versioned scrypt password hash in `LocalCredential`, attached to the same +canonical `User` as any linked OIDC, GitHub, or Google login. `ADMIN_EMAIL` and +`ADMIN_PASSWORD` remain outside that account-managed credential set so an +instance administrator can use `/signin/local` when external identity is +unavailable. Keep them unique, protected, and available to the operator; do not +reuse a person's normal password. + +Authentication mode, registration, automatic provider redirect, password +minimum length, reset expiry, and lockout thresholds are runtime database +settings under **Identity & sign-in**. They are intentionally not environment +variables. + ### SSO providers (optional bootstrap) Sign-in providers (OIDC / GitHub / Google) are configured at runtime in -**Settings → Authentication** and stored in the `SsoProvider` table — not +**Identity & sign-in** and stored in the `SsoProvider` table — not in env. The vars below are **optional one-time bootstrap**: if set and no provider row of that type exists yet, a row is seeded from them on first boot, then managed in the UI. Leave them blank to manage everything from @@ -60,9 +73,27 @@ the UI. ::: warning Rotating `AUTH_SECRET` invalidates all active sessions (users are signed out on the next request) **and** the encrypted SSO client secrets — re-enter each -provider's secret in Settings → Authentication after a rotation. +provider's secret in Identity & sign-in after a rotation. ::: +### Account email delivery + +Account invitations/setup, password reset, password-change notices, and +workspace invitations share the outbound email configuration below. Public +password-reset requests return the same response for known and unknown email +addresses. Production delivery fails closed when no transport is configured. + +| Var | Required | Notes | +| ---------------- | -------- | ------------------------------------------------------------------------------- | +| `EMAIL_FROM` | Yes | Sender used for account and workspace identity mail. | +| `EMAIL_SERVER` | No | Compact SMTP URL. Use this or the expanded `SMTP_*` fields or `RESEND_API_KEY`. | +| `SMTP_HOST` | No | SMTP hostname when not using `EMAIL_SERVER`. | +| `SMTP_PORT` | No | SMTP port; defaults to `587`. | +| `SMTP_SECURE` | No | `true` for implicit TLS. | +| `SMTP_USER` | No | SMTP username. | +| `SMTP_PASSWORD` | No | SMTP password. | +| `RESEND_API_KEY` | No | Resend transport alternative to SMTP. | + ## GitHub App integration The preferred setup is **Workspace Settings → GitHub Apps**. Forge stores that @@ -127,14 +158,15 @@ docker bridge while the browser hits a public hostname for presigned URLs — this is the difference between "`PUT` works server-side" and "`PUT` works from the browser". -| Var | Required | Notes | -| --------------------- | -------- | -------------------------------------------------- | -| `S3_ENDPOINT` | Yes | Internal endpoint (e.g. docker bridge IP). | -| `S3_PUBLIC_ENDPOINT` | Yes | Public hostname presigned URLs are signed against. | -| `S3_REGION` | Yes | Usually `us-east-1`. | -| `S3_ACCESS_KEY` | Yes | Credentials. | -| `S3_SECRET_KEY` | Yes | Credentials. | -| `S3_FORCE_PATH_STYLE` | No | `true` for MinIO; `false` for AWS S3. | +| Var | Required | Notes | +| --------------------- | -------- | ----------------------------------------------------------------- | +| `S3_ENDPOINT` | Yes | Internal endpoint (e.g. docker bridge IP). | +| `S3_PUBLIC_ENDPOINT` | Yes | Public hostname presigned URLs are signed against. | +| `S3_REGION` | Yes | Usually `us-east-1`. | +| `S3_ACCESS_KEY` | Yes | Credentials. | +| `S3_SECRET_KEY` | Yes | Credentials. | +| `S3_FORCE_PATH_STYLE` | No | `true` for MinIO; `false` for AWS S3. | +| `S3_GLOBAL_BUCKET` | No | Instance-global account-media bucket; defaults to `forge-global`. | ```bash S3_ENDPOINT="http://minio:9000" @@ -143,8 +175,17 @@ S3_REGION="us-east-1" S3_ACCESS_KEY="forge" S3_SECRET_KEY="..." S3_FORCE_PATH_STYLE="true" +S3_GLOBAL_BUCKET="forge-global" ``` +Workspace attachments remain in workspace-scoped buckets. User-uploaded +avatars are account-global and live in `S3_GLOBAL_BUCKET` under user-scoped +keys, because the same profile follows a person across workspaces. Forge +creates the bucket lazily, validates PNG, JPEG, GIF, or WebP content (maximum +5 MiB), and serves it through the stable `/api/avatar/` route. Removing +a local avatar restores the last provider-supplied profile image when one was +recorded. + ## AI providers Optional unless the workspace has `aiEnabled = true`. Set only the variables diff --git a/prisma/migrations/20260825150000_local_identity_foundation/migration.sql b/prisma/migrations/20260825150000_local_identity_foundation/migration.sql new file mode 100644 index 00000000..9876a457 --- /dev/null +++ b/prisma/migrations/20260825150000_local_identity_foundation/migration.sql @@ -0,0 +1,166 @@ +-- CreateEnum +CREATE TYPE "AuthenticationMode" AS ENUM ('LOCAL_ONLY', 'EXTERNAL_ONLY', 'HYBRID'); + +-- CreateEnum +CREATE TYPE "RegistrationMode" AS ENUM ('DISABLED', 'INVITE_ONLY', 'OPEN'); + +-- CreateEnum +CREATE TYPE "UserStatus" AS ENUM ('INVITED', 'ACTIVE', 'SUSPENDED', 'DELETED'); + +-- CreateEnum +CREATE TYPE "UserActionTokenType" AS ENUM ('ACCOUNT_SETUP', 'PASSWORD_RESET', 'EMAIL_VERIFICATION'); + +-- AlterTable: add identity lifecycle columns without changing current access. +ALTER TABLE "User" + ADD COLUMN "normalizedEmail" TEXT, + ADD COLUMN "status" "UserStatus" NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN "authVersion" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "lastLoginAt" TIMESTAMP(3), + ADD COLUMN "disabledAt" TIMESTAMP(3), + ADD COLUMN "deletedAt" TIMESTAMP(3); + +UPDATE "User" +SET "normalizedEmail" = lower(trim("email")); + +-- Fail explicitly rather than silently merging pre-existing case variants. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM "User" + GROUP BY "normalizedEmail" + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'Cannot enable normalized user emails: case-insensitive duplicates exist'; + END IF; +END $$; + +CREATE UNIQUE INDEX "User_normalizedEmail_key" ON "User"("normalizedEmail"); +CREATE UNIQUE INDEX "User_email_case_insensitive_key" ON "User" (lower(trim("email"))); + +-- AlterTable +ALTER TABLE "SsoProvider" ADD COLUMN "archivedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "InstanceAuthPolicy" ( + "id" TEXT NOT NULL DEFAULT 'default', + "mode" "AuthenticationMode" NOT NULL DEFAULT 'HYBRID', + "registrationMode" "RegistrationMode" NOT NULL DEFAULT 'INVITE_ONLY', + "breakGlassCredentialsEnabled" BOOLEAN NOT NULL DEFAULT true, + "autoRedirectProviderId" TEXT, + "passwordMinLength" INTEGER NOT NULL DEFAULT 12, + "passwordResetTtlMinutes" INTEGER NOT NULL DEFAULT 30, + "lockoutThreshold" INTEGER NOT NULL DEFAULT 10, + "lockoutMinutes" INTEGER NOT NULL DEFAULT 15, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "InstanceAuthPolicy_pkey" PRIMARY KEY ("id") +); + +-- Preserve the pre-policy behavior: credentials and configured external +-- providers remain available, with the environment operator as break glass. +INSERT INTO "InstanceAuthPolicy" ( + "id", + "mode", + "registrationMode", + "breakGlassCredentialsEnabled", + "updatedAt" +) VALUES ('default', 'HYBRID', 'INVITE_ONLY', true, CURRENT_TIMESTAMP); + +-- CreateTable +CREATE TABLE "LocalCredential" ( + "userId" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "passwordChangedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "mustChangePassword" BOOLEAN NOT NULL DEFAULT false, + "failedAttempts" INTEGER NOT NULL DEFAULT 0, + "lastFailedAt" TIMESTAMP(3), + "lockedUntil" TIMESTAMP(3), + "lastUsedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LocalCredential_pkey" PRIMARY KEY ("userId") +); + +-- CreateTable +CREATE TABLE "UserActionToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" "UserActionTokenType" NOT NULL, + "tokenHash" TEXT NOT NULL, + "emailSnapshot" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserActionToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "InstanceAuditLog" ( + "id" TEXT NOT NULL, + "actorId" TEXT, + "targetUserId" TEXT, + "action" TEXT NOT NULL, + "metadata" JSONB, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "InstanceAuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserAvatar" ( + "userId" TEXT NOT NULL, + "objectKey" TEXT NOT NULL, + "contentType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "etag" TEXT, + "fallbackImage" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserAvatar_pkey" PRIMARY KEY ("userId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserActionToken_tokenHash_key" ON "UserActionToken"("tokenHash"); +CREATE INDEX "UserActionToken_userId_type_expiresAt_idx" ON "UserActionToken"("userId", "type", "expiresAt"); +CREATE INDEX "InstanceAuditLog_actorId_createdAt_idx" ON "InstanceAuditLog"("actorId", "createdAt"); +CREATE INDEX "InstanceAuditLog_targetUserId_createdAt_idx" ON "InstanceAuditLog"("targetUserId", "createdAt"); +CREATE INDEX "InstanceAuditLog_action_createdAt_idx" ON "InstanceAuditLog"("action", "createdAt"); +CREATE UNIQUE INDEX "UserAvatar_objectKey_key" ON "UserAvatar"("objectKey"); + +-- AddForeignKey +ALTER TABLE "InstanceAuthPolicy" + ADD CONSTRAINT "InstanceAuthPolicy_autoRedirectProviderId_fkey" + FOREIGN KEY ("autoRedirectProviderId") REFERENCES "SsoProvider"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "LocalCredential" + ADD CONSTRAINT "LocalCredential_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "UserActionToken" + ADD CONSTRAINT "UserActionToken_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "InstanceAuditLog" + ADD CONSTRAINT "InstanceAuditLog_actorId_fkey" + FOREIGN KEY ("actorId") REFERENCES "User"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "InstanceAuditLog" + ADD CONSTRAINT "InstanceAuditLog_targetUserId_fkey" + FOREIGN KEY ("targetUserId") REFERENCES "User"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "UserAvatar" + ADD CONSTRAINT "UserAvatar_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d8f3e71e..d64d2a33 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -82,6 +82,40 @@ enum SsoType { GOOGLE } +/// Instance-wide policy for which authentication methods Forge presents and +/// accepts. Method availability is derived from this mode plus configured +/// providers; overlapping booleans are intentionally not persisted. +enum AuthenticationMode { + LOCAL_ONLY + EXTERNAL_ONLY + HYBRID +} + +/// Who may create a local Forge account. Workspace and instance invitations +/// remain the authorization boundary when registration is INVITE_ONLY. +enum RegistrationMode { + DISABLED + INVITE_ONLY + OPEN +} + +/// Durable lifecycle of a human account. DELETED users are retained as +/// anonymized tombstones so authorship and audit history remain referentially +/// intact. +enum UserStatus { + INVITED + ACTIVE + SUSPENDED + DELETED +} + +/// Purpose-bound, single-use bearer links for local identity workflows. +enum UserActionTokenType { + ACCOUNT_SETUP + PASSWORD_RESET + EMAIL_VERIFICATION +} + enum WorkItemKind { EPIC ISSUE @@ -764,6 +798,14 @@ enum AgentRunStatus { model User { id String @id @default(cuid()) email String @unique + /// Canonical case-insensitive identity key. All account creation and lookup + /// paths write the trimmed lowercase form; the unique constraint prevents + /// case-variant duplicate people. + /// Nullable during the additive compatibility window because NextAuth and + /// existing fixtures still create User rows directly. Identity services + /// always populate it; the migration also adds a functional unique index on + /// lower(trim(email)) so null cannot reopen case-variant duplicates. + normalizedEmail String? @unique emailVerified DateTime? name String? handle String? @unique @@ -837,11 +879,23 @@ model User { /// backfill script. Gates the /admin shell and global AgentProfile /// creation. See {@link InstanceRole}. instanceRole InstanceRole @default(MEMBER) + /// Account lifecycle and JWT revocation generation. Security-sensitive + /// changes increment authVersion so previously issued JWTs stop authorizing. + status UserStatus @default(ACTIVE) + authVersion Int @default(0) + lastLoginAt DateTime? + disabledAt DateTime? + deletedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accounts Account[] sessions Session[] + localCredential LocalCredential? + actionTokens UserActionToken[] + avatar UserAvatar? + instanceAuditActions InstanceAuditLog[] @relation("InstanceAuditActor") + instanceAuditTargets InstanceAuditLog[] @relation("InstanceAuditTarget") memberships Membership[] invitationsSent WorkspaceInvitation[] @relation("InvitationSender") invitationsAccepted WorkspaceInvitation[] @relation("InvitationAcceptor") @@ -943,6 +997,95 @@ model VerificationToken { @@unique([identifier, token]) } +/// Singleton instance authentication policy. The fixed default id keeps reads +/// deterministic while still allowing an additive, zero-downtime migration. +model InstanceAuthPolicy { + id String @id @default("default") + mode AuthenticationMode @default(HYBRID) + registrationMode RegistrationMode @default(INVITE_ONLY) + breakGlassCredentialsEnabled Boolean @default(true) + autoRedirectProviderId String? + passwordMinLength Int @default(12) + passwordResetTtlMinutes Int @default(30) + lockoutThreshold Int @default(10) + lockoutMinutes Int @default(15) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + autoRedirectProvider SsoProvider? @relation(fields: [autoRedirectProviderId], references: [id], onDelete: SetNull) +} + +/// Optional local sign-in method attached to the same canonical User used by +/// every OAuth/OIDC identity. +model LocalCredential { + userId String @id + passwordHash String + passwordChangedAt DateTime @default(now()) + mustChangePassword Boolean @default(false) + failedAttempts Int @default(0) + lastFailedAt DateTime? + lockedUntil DateTime? + lastUsedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +/// Purpose-bound reset/setup/verification token. Only tokenHash is persisted; +/// the raw 256-bit bearer value is returned once to the mail workflow. +model UserActionToken { + id String @id @default(cuid()) + userId String + type UserActionTokenType + tokenHash String @unique + emailSnapshot String + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, type, expiresAt]) +} + +/// Instance-scoped security audit. Workspace AuditLog remains tenant scoped; +/// identity and policy mutations need a durable instance-wide ledger. +model InstanceAuditLog { + id String @id @default(cuid()) + actorId String? + targetUserId String? + action String + metadata Json? + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + + actor User? @relation("InstanceAuditActor", fields: [actorId], references: [id], onDelete: SetNull) + targetUser User? @relation("InstanceAuditTarget", fields: [targetUserId], references: [id], onDelete: SetNull) + + @@index([actorId, createdAt]) + @@index([targetUserId, createdAt]) + @@index([action, createdAt]) +} + +/// Global user avatar metadata. Bytes live in object storage under a +/// user-scoped key rather than the workspace-scoped Attachment model. +model UserAvatar { + userId String @id + objectKey String @unique + contentType String + sizeBytes Int + etag String? + /// Provider-supplied User.image captured before the first local upload so + /// removing the avatar can restore the prior identity picture. + fallbackImage String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + /// Instance-global sign-in provider, configured from the admin UI rather /// than env vars. Auth is per *user* (not per workspace), so these rows are /// not tenant-scoped. The provider list is read at request time by the @@ -953,13 +1096,13 @@ model VerificationToken { /// derived from AUTH_SECRET — see `src/server/crypto.ts`); it is never /// returned to the client. model SsoProvider { - id String @id @default(cuid()) + id String @id @default(cuid()) /// Doubles as the NextAuth provider id, so the OAuth callback URL is /// `/api/auth/callback/`. Stable for the row's lifetime. type SsoType /// Display name + button label, e.g. "Authelia", "Company SSO". name String - enabled Boolean @default(false) + enabled Boolean @default(false) /// OIDC issuer base URL (discovery via `/.well-known/ /// openid-configuration`). Required for OIDC, null for GitHub/Google. issuer String? @@ -970,10 +1113,13 @@ model SsoProvider { scopes String? /// Maps to NextAuth `allowDangerousEmailAccountLinking`. Off by default — /// only enable when you trust the IdP to assert verified emails. - allowLinking Boolean @default(false) - sortOrder Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + allowLinking Boolean @default(false) + sortOrder Int @default(0) + archivedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + autoRedirectPolicies InstanceAuthPolicy[] } // ---------------------------------------------------------------------------- diff --git a/prisma/seed.ts b/prisma/seed.ts index 643f19ad..539b7094 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -30,6 +30,7 @@ import { ConnectionProvider, ConnectionStatus, } from "@prisma/client"; +import { hashPassword } from "../src/server/services/local-credentials"; const prisma = new PrismaClient(); @@ -37,11 +38,41 @@ const DAY = 86_400_000; async function main() { // ---- People -------------------------------------------------------- + const isLocalOrE2e = process.env.NODE_ENV !== "production" || process.env.FORGE_E2E === "1"; + const ownerEmail = ( + process.env.FORGE_SEED_OWNER_EMAIL ?? + (process.env.FORGE_E2E === "1" ? process.env.E2E_OWNER_EMAIL : undefined) ?? + "owner@forge.local" + ) + .trim() + .toLowerCase(); const owner = await prisma.user.upsert({ - where: { email: "owner@forge.local" }, - update: { instanceRole: "INSTANCE_ADMIN" }, - create: { email: "owner@forge.local", name: "Forge Owner", handle: "owner", instanceRole: "INSTANCE_ADMIN" }, + where: { email: ownerEmail }, + update: { instanceRole: "INSTANCE_ADMIN", normalizedEmail: ownerEmail }, + create: { + email: ownerEmail, + normalizedEmail: ownerEmail, + name: "Forge Owner", + handle: "owner", + instanceRole: "INSTANCE_ADMIN", + }, }); + const seedOwnerPassword = + process.env.FORGE_SEED_OWNER_PASSWORD ?? + (isLocalOrE2e + ? (process.env.E2E_OWNER_PASSWORD ?? process.env.ADMIN_PASSWORD ?? "forge-dev") + : undefined); + if (seedOwnerPassword) { + const credential = await prisma.localCredential.findUnique({ + where: { userId: owner.id }, + select: { userId: true }, + }); + if (!credential) { + await prisma.localCredential.create({ + data: { userId: owner.id, passwordHash: await hashPassword(seedOwnerPassword) }, + }); + } + } const dev = await prisma.user.upsert({ where: { email: "dev@forge.local" }, update: {}, diff --git a/src/app/(app)/settings/account/page.tsx b/src/app/(app)/settings/account/page.tsx index 66947606..16372ee7 100644 --- a/src/app/(app)/settings/account/page.tsx +++ b/src/app/(app)/settings/account/page.tsx @@ -1,9 +1,11 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; +import { Trash2, Upload } from "lucide-react"; import { toast } from "sonner"; import { useTheme } from "next-themes"; import { Topbar } from "@/components/topbar"; import { Badge } from "@/components/ui/badge"; +import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Section } from "@/components/settings/section"; @@ -50,6 +52,9 @@ export default function AccountPage() { const utils = trpc.useUtils(); const { theme: currentTheme, setTheme } = useTheme(); const isMac = useIsMac(); + const avatarInputId = useId(); + const avatar = trpc.avatar.me.useQuery(); + const [avatarBusy, setAvatarBusy] = useState(false); const [name, setName] = useState(""); const [handle, setHandle] = useState(""); @@ -109,6 +114,58 @@ export default function AccountPage() { onError: (e) => toast.error(e.message), }); + async function refreshAvatarConsumers() { + await Promise.all([ + utils.avatar.me.invalidate(), + utils.user.me.invalidate(), + utils.workspace.me.invalidate(), + ]); + } + + const initAvatarUpload = trpc.avatar.initUpload.useMutation(); + const finalizeAvatar = trpc.avatar.finalize.useMutation(); + const removeAvatar = trpc.avatar.remove.useMutation({ + onSuccess: async () => { + await refreshAvatarConsumers(); + toast.success("Profile picture removed."); + }, + onError: (error) => toast.error(error.message), + }); + + async function uploadAvatar(file: File) { + const allowedTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const; + if (!allowedTypes.includes(file.type as (typeof allowedTypes)[number])) { + toast.error("Choose a PNG, JPEG, GIF, or WebP image."); + return; + } + if (file.size <= 0 || file.size > 5 * 1024 * 1024) { + toast.error("Profile pictures must be 5 MB or smaller."); + return; + } + setAvatarBusy(true); + try { + const initialized = await initAvatarUpload.mutateAsync({ + contentType: file.type as (typeof allowedTypes)[number], + sizeBytes: file.size, + }); + const response = await fetch(initialized.uploadUrl, { + method: "PUT", + headers: initialized.headers, + body: file, + }); + if (!response.ok) throw new Error(`Upload failed (${response.status}).`); + await finalizeAvatar.mutateAsync({ objectKey: initialized.objectKey }); + await refreshAvatarConsumers(); + toast.success( + avatar.data?.hasLocalAvatar ? "Profile picture replaced." : "Profile picture added.", + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Profile picture upload failed."); + } finally { + setAvatarBusy(false); + } + } + const dismissOnboarding = trpc.user.dismissOnboarding.useMutation({ onSuccess: () => { utils.user.me.invalidate(); @@ -162,8 +219,55 @@ export default function AccountPage() {
+
+ +
+
Profile picture
+

+ PNG, JPEG, GIF, or WebP · up to 5 MB. Removing a custom picture restores your + provider image or initials. +

+
+ { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + if (file) void uploadAvatar(file); + }} + /> +
+ + {avatar.data?.hasLocalAvatar && ( + + )} +
+
setName(e.target.value)} /> @@ -176,7 +280,7 @@ export default function AccountPage() { className="font-mono" /> - + @@ -350,9 +454,7 @@ export default function AccountPage() { variant="ghost" className="ml-auto" disabled={unskipStep.isPending} - onClick={() => - unskipStep.mutate({ stepId: stepId as "member" }) - } + onClick={() => unskipStep.mutate({ stepId: stepId as "member" })} > Un-skip @@ -371,13 +473,11 @@ export default function AccountPage() {
); diff --git a/src/app/(app)/settings/connections/page.tsx b/src/app/(app)/settings/connections/page.tsx index b02b4244..58e2b0bb 100644 --- a/src/app/(app)/settings/connections/page.tsx +++ b/src/app/(app)/settings/connections/page.tsx @@ -36,7 +36,7 @@ const PROVIDER_OPTIONS: { value: string; label: string; needsIssuer?: boolean }[ function WsChipDense({ ws }: { ws: Workspace }) { return ( - + -

Use the invited account

- You are signed in as {session.user.email}, but this invitation belongs to {invitation.email}. Sign out, then continue with the invited email. + You are signed in as {session.user.email}, but this invitation belongs to{" "} + {invitation.email}. Sign out, then continue with the invited email.

-
@@ -105,10 +144,15 @@ export default async function InvitationPage({ ) : (
- -

Signed in as {session.user.email}

+

+ Signed in as {session.user.email} +

)} diff --git a/src/app/(auth)/reset-password/[token]/page.tsx b/src/app/(auth)/reset-password/[token]/page.tsx new file mode 100644 index 00000000..bb71a452 --- /dev/null +++ b/src/app/(auth)/reset-password/[token]/page.tsx @@ -0,0 +1,119 @@ +import Link from "next/link"; +import { UserActionTokenType } from "@prisma/client"; +import { AuthCardShell } from "@/components/auth/auth-card-shell"; +import { AuthMessage } from "@/components/auth/auth-message"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { completePasswordResetAction } from "@/server/actions/local-auth"; +import { getInstanceAuthPolicy } from "@/server/services/auth-policy"; +import { inspectUserActionToken } from "@/server/services/auth-tokens"; + +const INVALID_COPY = { + INVALID: "This password-reset link is invalid.", + EXPIRED: "This password-reset link has expired.", + USED: "This password-reset link has already been used.", +} as const; + +export default async function ResetPasswordPage({ + params, + searchParams, +}: { + params: Promise<{ token: string }>; + searchParams: Promise<{ error?: string }>; +}) { + const [{ token }, { error }, policy] = await Promise.all([ + params, + searchParams, + getInstanceAuthPolicy(), + ]); + const inspection = await inspectUserActionToken({ + rawToken: token, + type: UserActionTokenType.PASSWORD_RESET, + }); + + if (inspection.state !== "VALID") { + return ( + + ← Back to sign in + + } + > + + Request a new link + + + ); + } + + return ( + + Cancel and return to sign in + + } + > + {error && ( +
+ + {error === "mismatch" + ? "The passwords do not match." + : error === "password" + ? `Use at least ${policy.passwordMinLength} characters.` + : "This reset could not be completed. Request a new link."} + +
+ )} +
+ + + + +
+
+ ); +} diff --git a/src/app/(auth)/signin/local/page.tsx b/src/app/(auth)/signin/local/page.tsx new file mode 100644 index 00000000..6b4d1381 --- /dev/null +++ b/src/app/(auth)/signin/local/page.tsx @@ -0,0 +1,124 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { AuthError } from "next-auth"; +import { AuthCardShell } from "@/components/auth/auth-card-shell"; +import { AuthMessage } from "@/components/auth/auth-message"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { safeAuthCallbackUrl } from "@/lib/auth-callback"; +import { signIn } from "@/server/auth"; +import { deriveAuthPresentation, getInstanceAuthPolicy } from "@/server/services/auth-policy"; +import { getEnabledSsoRows } from "@/server/sso"; + +export default async function LocalSignInPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string; callbackUrl?: string; notice?: string }>; +}) { + const { error, callbackUrl, notice } = await searchParams; + const target = safeAuthCallbackUrl(callbackUrl); + const [policy, providers] = await Promise.all([getInstanceAuthPolicy(), getEnabledSsoRows()]); + const presentation = deriveAuthPresentation(policy, providers); + const allowed = presentation.localCredentialsEnabled || presentation.breakGlassCredentialsEnabled; + + async function credentialsAction(formData: FormData) { + "use server"; + try { + await signIn("credentials", { + email: String(formData.get("email") ?? ""), + password: String(formData.get("password") ?? ""), + breakGlass: formData.get("breakGlass") === "1" ? "1" : "0", + redirectTo: target, + }); + } catch (err) { + if (err instanceof AuthError) { + redirect(`/signin/local?${new URLSearchParams({ error: err.type, callbackUrl: target })}`); + } + throw err; + } + } + + if (!allowed) { + return ( + + ← Back to sign in + + } + > + + Contact an instance administrator if you need help recovering access. + + + ); + } + + const isBreakGlass = !presentation.localCredentialsEnabled; + return ( + + ← Use another sign-in method + + } + > + {error && ( +
+ Invalid email or password. +
+ )} + {notice && ( +
+ + {notice === "activated" + ? "Your account is ready. Sign in with the password you just created." + : "Your password was reset. Sign in with your new password."} + +
+ )} +
+ + + + +
+
+ ); +} diff --git a/src/app/(auth)/signin/page.tsx b/src/app/(auth)/signin/page.tsx index cb43a53a..fe002d86 100644 --- a/src/app/(auth)/signin/page.tsx +++ b/src/app/(auth)/signin/page.tsx @@ -1,18 +1,23 @@ +import Link from "next/link"; import { redirect } from "next/navigation"; -import { signIn } from "@/server/auth"; import { AuthError } from "next-auth"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Kbd } from "@/components/ui/kbd"; -import { LiveStatusPanel, LiveLoopCard } from "./live-status-panel"; -import { listEnabledSsoProviders, type PublicSsoProvider } from "@/server/sso"; +import { AuthMessage } from "@/components/auth/auth-message"; +import { AutoRedirectProvider, ProviderButton } from "@/components/auth/provider-form"; +import { safeAuthCallbackUrl } from "@/lib/auth-callback"; +import { signIn } from "@/server/auth"; import { readPackageVersion } from "@/server/build-info"; +import { decryptSecret } from "@/server/crypto"; +import { deriveAuthPresentation, getInstanceAuthPolicy } from "@/server/services/auth-policy"; +import { getEnabledSsoRows, providerIdFor } from "@/server/sso"; +import { LiveStatusPanel, LiveLoopCard } from "./live-status-panel"; -/** Short mono badge shown in the provider button. */ -function providerBadge(p: PublicSsoProvider): string { - if (p.type === "GITHUB") return "GH"; - if (p.type === "GOOGLE") return "G"; - return p.name.slice(0, 2).toUpperCase(); +function providerBadge(type: "OIDC" | "GITHUB" | "GOOGLE", name: string): string { + if (type === "GITHUB") return "GH"; + if (type === "GOOGLE") return "G"; + return name.slice(0, 2).toUpperCase(); } function instanceHost(): string { @@ -25,46 +30,55 @@ function instanceHost(): string { } } -/** Small mono-badge provider button matching the design's ProviderBtn. */ -function ProviderButton({ - action, - providerId, - icon, - label, -}: { - action: (formData: FormData) => Promise; - providerId: string; - icon: string; - label: string; -}) { - return ( -
- - -
- ); +function signInErrorMessage(error?: string): string | null { + if (!error) return null; + if (error === "CredentialsSignin") return "Invalid email or password."; + if (error === "AccessDenied") return "This account cannot sign in to this Forge instance."; + return "Sign-in failed. Choose a method and try again."; +} + +function usableProvider( + provider: T, +): boolean { + if (provider.type === "OIDC" && !provider.issuer) return false; + try { + decryptSecret(provider.clientSecret); + return true; + } catch { + return false; + } } export default async function SignInPage({ searchParams, }: { - searchParams: Promise<{ error?: string; callbackUrl?: string }>; + searchParams: Promise<{ error?: string; callbackUrl?: string; manual?: string }>; }) { - const { error, callbackUrl } = await searchParams; - const target = callbackUrl || "/dashboard"; - - const providers = await listEnabledSsoProviders(); - const hasProviders = providers.length > 0; - const host = instanceHost(); - const appVersion = await readPackageVersion(); + const { error, callbackUrl, manual } = await searchParams; + const target = safeAuthCallbackUrl(callbackUrl); + const [rows, policy, appVersion] = await Promise.all([ + getEnabledSsoRows(), + getInstanceAuthPolicy(), + readPackageVersion(), + ]); + // Match the provider set Auth.js can actually construct. An enabled row + // with a missing issuer or unreadable secret belongs in the admin health UI, + // not on a sign-in button or automatic redirect. + const usableRows = rows.filter(usableProvider); + const presentation = deriveAuthPresentation(policy, usableRows); + const providers = usableRows.map((row) => ({ + id: row.id, + providerId: providerIdFor(row), + name: row.name, + type: row.type, + })); + const autoProvider = providers.find( + (provider) => provider.id === presentation.autoRedirectProviderId, + ); + // Invitations may require deliberate account choice. Errors and the manual + // escape hatch must also stay on the chooser to prevent redirect loops. + const suppressAutoRedirect = Boolean(error || manual === "1" || target.startsWith("/invite/")); + const shouldAutoRedirect = Boolean(autoProvider && !suppressAutoRedirect); async function credentialsAction(formData: FormData) { "use server"; @@ -76,7 +90,9 @@ export default async function SignInPage({ }); } catch (err) { if (err instanceof AuthError) { - redirect(`/signin?error=${encodeURIComponent(err.type)}`); + redirect( + `/signin?${new URLSearchParams({ error: err.type, callbackUrl: target, manual: "1" })}`, + ); } throw err; } @@ -86,14 +102,22 @@ export default async function SignInPage({ "use server"; const providerId = String(formData.get("providerId") ?? ""); if (!providerId) return; - await signIn(providerId, { redirectTo: target }); + try { + await signIn(providerId, { redirectTo: target }); + } catch (err) { + if (err instanceof AuthError) { + redirect( + `/signin?${new URLSearchParams({ error: err.type, callbackUrl: target, manual: "1" })}`, + ); + } + throw err; + } } + const authError = signInErrorMessage(error); return (
- {/* LEFT: brand + live status (the marquee) */}
- {/* brand */}
{/* eslint-disable-next-line @next/next/no-img-element */} Forge
-
- keyboard · agents · cycles + keyboard · agents · sprints

Project management for humans and agents. @@ -117,27 +140,22 @@ export default async function SignInPage({ Linear-style primitives, a first-class MCP surface, and a workflow shape every run follows. Sign in to pick up where you left off.

- - {/* Full panel on desktop, compact card on mobile */}

-
forge v{appVersion} · self-hosted - {host} + {instanceHost()}
- {/* RIGHT: the form */}
all systems normal
-
@@ -145,98 +163,133 @@ export default async function SignInPage({

Welcome back.

- Use your email and password. You'll pick a workspace next. + {shouldAutoRedirect + ? "Your instance uses a preferred identity provider." + : "Choose a sign-in method enabled for this instance."}

+ {authError && ( +
+ {authError} +
+ )} -
- - - - {error && ( -

- {error === "CredentialsSignin" - ? "Invalid email or password." - : "Sign-in failed. Try again."} -

- )} - - - - -
- - {hasProviders && ( + {shouldAutoRedirect && autoProvider ? ( <> -
- - - or use a provider - - -
- -
1 ? "sm:grid-cols-2" : ""}`} + + - {providers.map((p) => ( - - ))} -
+ Use another sign-in method + + ) : ( +
+ {presentation.localCredentialsEnabled && ( +
+ + + + {presentation.registrationMode === "OPEN" && ( +

+ New here?{" "} + + Create a local account + +

+ )} +
+ )} + + {presentation.localCredentialsEnabled && presentation.externalProvidersEnabled && ( +
+ + + or use a provider + + +
+ )} + {presentation.externalProvidersEnabled && ( +
1 ? "sm:grid-cols-2" : ""}`} + > + {providers.map((provider) => ( + + ))} +
+ )} + {!presentation.localCredentialsEnabled && + !presentation.externalProvidersEnabled && ( + + No external sign-in provider is currently available. Contact the instance + administrator. + + )} + {!presentation.localCredentialsEnabled && + presentation.breakGlassCredentialsEnabled && ( + + Instance administrator recovery + + )} +
)}
- -
Need an invite? Ask a workspace admin.
+
+ Need access? Ask a workspace or instance administrator. +
); diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx new file mode 100644 index 00000000..1907d7b1 --- /dev/null +++ b/src/app/(auth)/signup/page.tsx @@ -0,0 +1,59 @@ +import Link from "next/link"; +import { AuthCardShell } from "@/components/auth/auth-card-shell"; +import { AuthMessage } from "@/components/auth/auth-message"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { requestOpenRegistrationAction } from "@/server/actions/local-auth"; +import { getInstanceAuthPolicy } from "@/server/services/auth-policy"; + +export default async function SignupPage({ + searchParams, +}: { + searchParams: Promise<{ sent?: string }>; +}) { + const [{ sent }, policy] = await Promise.all([searchParams, getInstanceAuthPolicy()]); + const enabled = policy.registrationMode === "OPEN" && policy.mode !== "EXTERNAL_ONLY"; + return ( + + ← Back to sign in + + } + > + {sent === "1" ? ( + + If this email can register, a secure account-setup link has been sent. + + ) : enabled ? ( +
+ + + +
+ ) : ( + + Only invited or administrator-created accounts can join this instance. + + )} +
+ ); +} diff --git a/src/app/api/avatar/[userId]/route.ts b/src/app/api/avatar/[userId]/route.ts new file mode 100644 index 00000000..6ea6e911 --- /dev/null +++ b/src/app/api/avatar/[userId]/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { readUserAvatar } from "@/server/services/user-avatar"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ userId: string }> }, +): Promise { + const { userId } = await params; + try { + const avatar = await readUserAvatar(userId); + if (!avatar) return unavailable(); + const etag = avatar.etag ? `"${avatar.etag}"` : null; + if (etag && request.headers.get("if-none-match") === etag) { + return new Response(null, { status: 304, headers: cacheHeaders(etag) }); + } + return new Response(avatar.bytes as BodyInit, { + headers: { + ...cacheHeaders(etag), + "Content-Type": avatar.contentType, + "Content-Length": String(avatar.bytes.byteLength), + "Last-Modified": avatar.updatedAt.toUTCString(), + "X-Content-Type-Options": "nosniff", + }, + }); + } catch { + return unavailable(); + } +} + +function cacheHeaders(etag: string | null): Record { + return { + "Cache-Control": "public, max-age=0, must-revalidate", + ...(etag ? { ETag: etag } : {}), + "Referrer-Policy": "no-referrer", + }; +} + +function unavailable(): NextResponse { + return NextResponse.json( + { error: "Avatar unavailable." }, + { + status: 404, + headers: { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }, + }, + ); +} diff --git a/src/components/admin-shell/admin-overview.tsx b/src/components/admin-shell/admin-overview.tsx index 5bbea5a5..f3331798 100644 --- a/src/components/admin-shell/admin-overview.tsx +++ b/src/components/admin-shell/admin-overview.tsx @@ -353,11 +353,11 @@ export function InviteUserDialog({ const [name, setName] = useState(""); const [makeAdmin, setMakeAdmin] = useState(false); - const invite = trpc.instanceAdmin.inviteUser.useMutation({ - onSuccess: async (u) => { + const invite = trpc.instanceAdmin.createUser.useMutation({ + onSuccess: async (result) => { await utils.instanceAdmin.users.invalidate(); await utils.instanceAdmin.system.invalidate(); - toast.success(u.created ? `Invited ${u.email}.` : `${u.email} already existed — updated.`); + toast.success(`Setup email sent to ${result.user.email}.`); }, onError: (e) => toast.error(e.message), }); @@ -377,7 +377,7 @@ export function InviteUserDialog({ onOpenChange(v); }} title="Invite user" - description="Pre-creates the account by email. They bind on first sign-in (Authelia owns identity). No workspace membership is added — owners add members per-workspace." + description="Creates one canonical Forge user and emails a single-use local account setup link. External login methods can be linked to the same user when instance policy allows. No workspace membership is added." primaryLabel="Invite" loading={invite.isPending} onSubmit={async () => { @@ -387,7 +387,7 @@ export function InviteUserDialog({ await invite.mutateAsync({ email: trimmed, name: name.trim() || undefined, - instanceAdmin: makeAdmin, + instanceRole: makeAdmin ? "INSTANCE_ADMIN" : "MEMBER", }); reset(); return undefined; diff --git a/src/components/admin-shell/admin-users.tsx b/src/components/admin-shell/admin-users.tsx index 69224487..9fdd7e06 100644 --- a/src/components/admin-shell/admin-users.tsx +++ b/src/components/admin-shell/admin-users.tsx @@ -1,6 +1,15 @@ "use client"; import { useState } from "react"; -import { ShieldPlus, ShieldMinus, Plus } from "lucide-react"; +import { + KeyRound, + PauseCircle, + PlayCircle, + Plus, + RotateCcw, + ShieldMinus, + ShieldPlus, + Trash2, +} from "lucide-react"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; import { useConfirm } from "@/components/ui/modal"; @@ -31,6 +40,40 @@ export function AdminUsers() { onSettled: () => setPendingId(null), }); + const refresh = async () => { + await utils.instanceAdmin.users.invalidate(); + await utils.instanceAdmin.system.invalidate(); + }; + const suspend = trpc.instanceAdmin.suspendUser.useMutation({ + onSuccess: async () => { + await refresh(); + toast.success("User suspended and active access revoked."); + }, + onError: (e) => toast.error(e.message), + }); + const reactivate = trpc.instanceAdmin.reactivateUser.useMutation({ + onSuccess: async () => { + await refresh(); + toast.success("User reactivated."); + }, + onError: (e) => toast.error(e.message), + }); + const resetPassword = trpc.instanceAdmin.issuePasswordResetToken.useMutation({ + onSuccess: () => toast.success("Password reset email sent."), + onError: (e) => toast.error(e.message), + }); + const revokeSessions = trpc.instanceAdmin.revokeUserSessions.useMutation({ + onSuccess: () => toast.success("All user sessions revoked."), + onError: (e) => toast.error(e.message), + }); + const remove = trpc.instanceAdmin.deleteUser.useMutation({ + onSuccess: async () => { + await refresh(); + toast.success("User disabled and anonymized; historical attribution was preserved."); + }, + onError: (e) => toast.error(e.message), + }); + const adminCount = users.data?.filter((u) => u.instanceRole === "INSTANCE_ADMIN").length ?? 0; function promote(userId: string) { @@ -49,6 +92,34 @@ export function AdminUsers() { setRole.mutate({ userId, role: "MEMBER" }); } + async function suspendUser(userId: string, name: string | null) { + if ( + await confirm({ + title: `Suspend ${name ?? "this user"}?`, + description: + "Their browser sessions and API keys will be revoked, and user-owned integration mappings will be paused.", + primaryLabel: "Suspend", + variant: "destructive", + }) + ) { + suspend.mutate({ userId }); + } + } + + async function deleteUser(userId: string, name: string | null) { + if ( + await confirm({ + title: `Delete ${name ?? "this user"}?`, + description: + "This soft-deletes and anonymizes the account while preserving authored work and audit history. Ownership guards may block the action.", + primaryLabel: "Delete account", + variant: "destructive", + }) + ) { + remove.mutate({ userId }); + } + } + return (
Name Email - Role + Status · methods Workspaces Joined - Instance role + Access actions
{users.isLoading ? ( @@ -89,11 +160,19 @@ export function AdminUsers() { .join(""); const busy = pendingId === u.id; const lastAdmin = isAdmin && adminCount <= 1; + const isSuspended = u.status === "SUSPENDED"; + const isDeleted = u.status === "DELETED"; + const methods = [ + ...(u.loginMethods.password ? ["password"] : []), + ...u.loginMethods.providers, + ]; return (
- + {u.email} - - {isAdmin ? ( - instance admin - ) : ( - member - )} + + + {u.status.toLowerCase()} + + + {methods.length ? methods.join(" · ") : "no login method"} + - + {u.workspaces} - + {relTime(u.createdAt)} - - {isAdmin ? ( + + {!isDeleted && + (isAdmin ? ( + demote(u.id, u.name)} + > + {lastAdmin ? "Last admin" : "Demote"} + + ) : ( + promote(u.id)} + > + Make admin + + ))} + {!isDeleted && ( resetPassword.mutate({ userId: u.id })} + > + Reset + + )} + {!isDeleted && ( + revokeSessions.mutate({ userId: u.id })} + > + Revoke + + )} + {isSuspended ? ( + reactivate.mutate({ userId: u.id })} + > + Reactivate + + ) : !isDeleted ? ( + demote(u.id, u.name)} + disabled={suspend.isPending || lastAdmin} + onClick={() => suspendUser(u.id, u.name)} > - {lastAdmin ? "Last admin" : "Demote"} + Suspend - ) : ( - promote(u.id)}> - Make admin + ) : null} + {!isDeleted && ( + deleteUser(u.id, u.name)} + > + Delete )} diff --git a/src/components/admin-shell/auth-policy-settings.tsx b/src/components/admin-shell/auth-policy-settings.tsx new file mode 100644 index 00000000..0ac74fde --- /dev/null +++ b/src/components/admin-shell/auth-policy-settings.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Combobox } from "@/components/ui/combobox"; +import { Section } from "@/components/settings/section"; + +type AuthenticationMode = "LOCAL_ONLY" | "EXTERNAL_ONLY" | "HYBRID"; +type RegistrationMode = "DISABLED" | "INVITE_ONLY" | "OPEN"; + +const MODES: Array<{ value: AuthenticationMode; label: string; hint: string }> = [ + { value: "LOCAL_ONLY", label: "Local only", hint: "Forge passwords only" }, + { value: "EXTERNAL_ONLY", label: "External only", hint: "OIDC or OAuth providers" }, + { value: "HYBRID", label: "Hybrid", hint: "Passwords and external providers" }, +]; + +export function AuthPolicySettings() { + const utils = trpc.useUtils(); + const query = trpc.sso.policy.useQuery(); + const providers = trpc.sso.list.useQuery(); + const [mode, setMode] = useState("HYBRID"); + const [registrationMode, setRegistrationMode] = useState("INVITE_ONLY"); + const [breakGlass, setBreakGlass] = useState(true); + const [autoRedirectProviderId, setAutoRedirectProviderId] = useState(""); + const [passwordMinLength, setPasswordMinLength] = useState(12); + const [passwordResetTtlMinutes, setPasswordResetTtlMinutes] = useState(30); + const [lockoutThreshold, setLockoutThreshold] = useState(10); + const [lockoutMinutes, setLockoutMinutes] = useState(15); + + useEffect(() => { + const policy = query.data?.policy; + if (!policy) return; + setMode(policy.mode); + setRegistrationMode(policy.registrationMode); + setBreakGlass(policy.breakGlassCredentialsEnabled); + setAutoRedirectProviderId(policy.autoRedirectProviderId ?? ""); + setPasswordMinLength(policy.passwordMinLength); + setPasswordResetTtlMinutes(policy.passwordResetTtlMinutes); + setLockoutThreshold(policy.lockoutThreshold); + setLockoutMinutes(policy.lockoutMinutes); + }, [query.data]); + + const update = trpc.sso.updatePolicy.useMutation({ + onSuccess: async () => { + await utils.sso.policy.invalidate(); + toast.success("Authentication policy saved."); + }, + onError: (error) => toast.error(error.message), + }); + + const enabledProviders = (providers.data ?? []).filter((provider) => provider.enabled); + const externalOnlyWarning = mode === "EXTERNAL_ONLY" && enabledProviders.length === 0; + + return ( +
+ update.mutate({ + mode, + registrationMode, + breakGlassCredentialsEnabled: breakGlass, + autoRedirectProviderId: autoRedirectProviderId || null, + passwordMinLength, + passwordResetTtlMinutes, + lockoutThreshold, + lockoutMinutes, + }) + } + > + Save policy + + } + > +
+
+ {MODES.map((item) => ( + + ))} +
+ + {externalOnlyWarning && ( +
+ Enable at least one external provider before selecting external-only authentication. +
+ )} + +
+ + +
+ + + + {breakGlass && query.data && !query.data.breakGlassConfigured && ( +
+ + Configure ADMIN_EMAIL and ADMIN_PASSWORD before enabling break glass. +
+ )} + +
+ + Password and lockout policy + +
+ + + + +
+
+
+
+ ); +} + +function PolicyNumber({ + label, + value, + min, + max, + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + onChange: (value: number) => void; +}) { + return ( + + ); +} diff --git a/src/components/admin-shell/identity-settings.tsx b/src/components/admin-shell/identity-settings.tsx index d5a2692d..49ee34ac 100644 --- a/src/components/admin-shell/identity-settings.tsx +++ b/src/components/admin-shell/identity-settings.tsx @@ -11,6 +11,7 @@ import { Section } from "@/components/settings/section"; import { Card } from "@/components/settings/card"; import { EmptyState } from "@/components/settings/empty-state"; import { trpc } from "@/lib/trpc"; +import { AuthPolicySettings } from "./auth-policy-settings"; type SsoTypeT = "OIDC" | "GITHUB" | "GOOGLE"; const SSO_TYPES: { value: SsoTypeT; label: string; hint: string }[] = [ @@ -202,9 +203,11 @@ export default function IdentitySettings() {
+ +
0 ? ( diff --git a/src/components/auth/auth-card-shell.tsx b/src/components/auth/auth-card-shell.tsx new file mode 100644 index 00000000..369b2c78 --- /dev/null +++ b/src/components/auth/auth-card-shell.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; + +export function AuthCardShell({ + eyebrow, + title, + description, + children, + footer, +}: { + eyebrow: string; + title: string; + description: string; + children: React.ReactNode; + footer?: React.ReactNode; +}) { + return ( +
+
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + + Forge + + +

+ {eyebrow} +

+

{title}

+

{description}

+ +
{children}
+ {footer &&
{footer}
} +
+
+ ); +} diff --git a/src/components/auth/auth-message.tsx b/src/components/auth/auth-message.tsx new file mode 100644 index 00000000..435958b2 --- /dev/null +++ b/src/components/auth/auth-message.tsx @@ -0,0 +1,26 @@ +import { AlertCircle, CheckCircle2, Info } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export function AuthMessage({ + tone = "info", + children, +}: { + tone?: "info" | "success" | "danger"; + children: React.ReactNode; +}) { + const Icon = tone === "success" ? CheckCircle2 : tone === "danger" ? AlertCircle : Info; + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/components/auth/provider-form.tsx b/src/components/auth/provider-form.tsx new file mode 100644 index 00000000..44bc951b --- /dev/null +++ b/src/components/auth/provider-form.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Loader2 } from "lucide-react"; + +export function ProviderButton({ + action, + providerId, + icon, + label, +}: { + action: (formData: FormData) => Promise; + providerId: string; + icon: string; + label: string; +}) { + return ( +
+ + +
+ ); +} + +/** + * Browser-side submission is intentional: a Server Component cannot mutate + * the cookies used by Auth.js. The visible progress state also gives users a + * way back if a provider is unreachable. + */ +export function AutoRedirectProvider({ + action, + providerId, + providerName, +}: { + action: (formData: FormData) => Promise; + providerId: string; + providerName: string; +}) { + const formRef = useRef(null); + + useEffect(() => { + formRef.current?.requestSubmit(); + }, []); + + return ( +
+ +
+ + + Continuing to {providerName}… + +
+ +
+ ); +} diff --git a/src/components/settings/settings-nav.ts b/src/components/settings/settings-nav.ts index 6ae6fbc8..554385d2 100644 --- a/src/components/settings/settings-nav.ts +++ b/src/components/settings/settings-nav.ts @@ -14,6 +14,7 @@ import { Send, Settings as SettingsIcon, Shield, + ShieldCheck, Tag, User as UserIcon, Users, @@ -226,6 +227,12 @@ export const ACCOUNT_SETTINGS_GROUP: SettingsNavGroup = { icon: UserIcon, description: "Profile, timezone, locale, time format, theme.", }, + { + path: "/settings/security", + label: "Security & sign-in", + icon: ShieldCheck, + description: "Password, linked login methods, and active sessions.", + }, { path: "/settings/appearance", label: "Appearance", diff --git a/src/components/settings/settings-rail.tsx b/src/components/settings/settings-rail.tsx index b8ad0097..908d8045 100644 --- a/src/components/settings/settings-rail.tsx +++ b/src/components/settings/settings-rail.tsx @@ -241,9 +241,9 @@ const GLOBAL_RAIL_ITEMS: RailItem[] = [ }, { href: "/settings/connections", - label: "Connected accounts", + label: "Integration accounts", icon: PlugZap, - hint: "OAuth identities", + hint: "Operational OAuth access", adminOnly: false, }, ]; diff --git a/src/lib/auth-callback.ts b/src/lib/auth-callback.ts new file mode 100644 index 00000000..e40f4194 --- /dev/null +++ b/src/lib/auth-callback.ts @@ -0,0 +1,22 @@ +/** + * Keep authentication callbacks on this Forge instance. NextAuth also checks + * redirect origins, but public auth pages use this helper before rendering + * links and hidden form values so an untrusted URL never reaches the UI. + */ +export function safeAuthCallbackUrl(value: string | null | undefined): string { + if (!value) return "/dashboard"; + if (!value.startsWith("/") || value.startsWith("//")) return "/dashboard"; + + try { + const parsed = new URL(value, "http://forge.local"); + if (parsed.origin !== "http://forge.local") return "/dashboard"; + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return "/dashboard"; + } +} + +export function authPath(pathname: string, callbackUrl?: string): string { + const callback = safeAuthCallbackUrl(callbackUrl); + return `${pathname}?callbackUrl=${encodeURIComponent(callback)}`; +} diff --git a/src/server/actions/identity-linking.ts b/src/server/actions/identity-linking.ts new file mode 100644 index 00000000..cd2595d0 --- /dev/null +++ b/src/server/actions/identity-linking.ts @@ -0,0 +1,22 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { auth, signIn } from "@/server/auth"; +import { getEnabledSsoRows, providerIdFor } from "@/server/sso"; + +/** + * Start an explicit provider-link flow from an authenticated account. Auth.js + * binds the callback to the current User session; the provider reauthentication + * proves control of the external identity. Operational Connections are never + * touched by this login-method action. + */ +export async function linkIdentityAction(formData: FormData): Promise { + const session = await auth(); + if (!session?.user?.id) redirect("/signin?manual=1"); + const requested = String(formData.get("providerId") ?? ""); + const provider = (await getEnabledSsoRows()).find( + (candidate) => providerIdFor(candidate) === requested, + ); + if (!provider) redirect("/settings/security?error=provider-unavailable"); + await signIn(requested, { redirectTo: "/settings/security?linked=1" }); +} diff --git a/src/server/actions/invitations.ts b/src/server/actions/invitations.ts index cb56c91d..1dd187ad 100644 --- a/src/server/actions/invitations.ts +++ b/src/server/actions/invitations.ts @@ -2,7 +2,10 @@ import { redirect } from "next/navigation"; import { auth } from "@/server/auth"; -import { acceptWorkspaceInvitation } from "@/server/services/workspace-invitations"; +import { + acceptWorkspaceInvitation, + registerLocalAccountFromInvitation, +} from "@/server/services/workspace-invitations"; export async function acceptInvitationAction(formData: FormData): Promise { const token = String(formData.get("token") ?? ""); @@ -21,3 +24,33 @@ export async function acceptInvitationAction(formData: FormData): Promise } redirect(`/invite/${token}?result=${result.state.toLowerCase()}`); } + +export async function registerLocalInvitationAction(formData: FormData): Promise { + const token = String(formData.get("token") ?? ""); + const name = String(formData.get("name") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + const confirmPassword = String(formData.get("confirmPassword") ?? ""); + const path = `/invite/${encodeURIComponent(token)}/local`; + if (!token || token.length > 256 || !name || name.length > 80) { + redirect(`${path}?error=invalid`); + } + if (password !== confirmPassword) redirect(`${path}?error=mismatch`); + let result: Awaited>; + try { + result = await registerLocalAccountFromInvitation({ token, name, password }); + } catch { + redirect(`${path}?error=password`); + } + if (result.state === "CREATED") { + redirect( + `/signin/local?${new URLSearchParams({ + notice: "activated", + callbackUrl: `/w/${result.workspaceSlug}/dashboard?invite=accepted`, + })}`, + ); + } + if (result.state === "EXISTING_ACCOUNT") { + redirect(`/signin?callbackUrl=${encodeURIComponent(`/invite/${token}`)}&manual=1`); + } + redirect(`${path}?error=${result.state.toLowerCase()}`); +} diff --git a/src/server/actions/local-auth.ts b/src/server/actions/local-auth.ts new file mode 100644 index 00000000..fcf396a6 --- /dev/null +++ b/src/server/actions/local-auth.ts @@ -0,0 +1,179 @@ +"use server"; + +import { createHash } from "node:crypto"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { safeAuthCallbackUrl } from "@/lib/auth-callback"; +import { db } from "@/server/db"; +import { rateLimit } from "@/server/rate-limit"; +import { + sendAccountSetupEmail, + sendPasswordChangedEmail, + sendPasswordResetEmail, +} from "@/server/services/email"; +import { + completeAccountSetup, + completePasswordReset, + createInvitedUser, + requestPasswordReset, +} from "@/server/services/user-lifecycle"; +import { getInstanceAuthPolicy } from "@/server/services/auth-policy"; + +async function requestContext() { + const h = await headers(); + return { + ipAddress: h.get("x-forwarded-for")?.split(",")[0]?.trim() || h.get("x-real-ip"), + userAgent: h.get("user-agent"), + }; +} + +function emailRateKey(email: string): string { + return createHash("sha256").update(email.trim().toLowerCase()).digest("hex").slice(0, 24); +} + +function publicAppUrl(path: string): string { + const origin = ( + process.env.NEXT_PUBLIC_APP_URL || + process.env.AUTH_URL || + "http://localhost:3000" + ).replace(/\/+$/, ""); + return `${origin}${path}`; +} + +/** Enumeration-safe: every input returns the same public result. */ +export async function requestPasswordResetAction(formData: FormData): Promise { + const email = String(formData.get("email") ?? "") + .trim() + .toLowerCase(); + const callbackUrl = safeAuthCallbackUrl(String(formData.get("callbackUrl") ?? "")); + const context = await requestContext(); + + try { + const [byIp, byEmail] = await Promise.all([ + rateLimit(`password-reset:ip:${context.ipAddress ?? "unknown"}`, 20, 3600), + rateLimit(`password-reset:email:${emailRateKey(email)}`, 5, 3600), + ]); + if (email && email.length <= 320 && byIp.ok && byEmail.ok) { + const reset = await requestPasswordReset(db, { email, ...context }); + if (reset) { + await sendPasswordResetEmail({ + to: reset.email, + url: publicAppUrl(`/reset-password/${encodeURIComponent(reset.token)}`), + expiresAt: reset.expiresAt, + }); + } + } + } catch (error) { + // Delivery and lookup failures are intentionally indistinguishable from an + // unknown email on the public surface. + console.error("[auth] password-reset request failed", error); + } + + redirect(`/forgot-password?${new URLSearchParams({ sent: "1", callbackUrl })}`); +} + +/** Open registration still verifies ownership through the emailed setup link. */ +export async function requestOpenRegistrationAction(formData: FormData): Promise { + const email = String(formData.get("email") ?? "") + .trim() + .toLowerCase(); + const name = String(formData.get("name") ?? "").trim(); + const context = await requestContext(); + try { + const policy = await getInstanceAuthPolicy(); + const [byIp, byEmail] = await Promise.all([ + rateLimit(`registration:ip:${context.ipAddress ?? "unknown"}`, 20, 3600), + rateLimit(`registration:email:${emailRateKey(email)}`, 5, 3600), + ]); + if ( + policy.registrationMode === "OPEN" && + policy.mode !== "EXTERNAL_ONLY" && + email && + email.length <= 320 && + name && + name.length <= 80 && + byIp.ok && + byEmail.ok + ) { + const created = await createInvitedUser(db, { + actorId: null, + email, + name, + ...context, + }); + await sendAccountSetupEmail({ + to: created.user.email, + name: created.user.name, + url: publicAppUrl(`/activate/${encodeURIComponent(created.setupToken)}`), + expiresAt: created.expiresAt, + }); + } + } catch { + // Deliberately indistinguishable from an existing or ineligible account. + } + redirect("/signup?sent=1"); +} + +function credentialFields(formData: FormData) { + return { + token: String(formData.get("token") ?? ""), + password: String(formData.get("password") ?? ""), + confirmPassword: String(formData.get("confirmPassword") ?? ""), + }; +} + +export async function completePasswordResetAction(formData: FormData): Promise { + const input = credentialFields(formData); + const path = `/reset-password/${encodeURIComponent(input.token)}`; + if (input.password !== input.confirmPassword) redirect(`${path}?error=mismatch`); + if (!input.token || input.token.length > 256) redirect(`${path}?error=link`); + + try { + const context = await requestContext(); + const result = await completePasswordReset(db, { + token: input.token, + password: input.password, + ...context, + }); + const user = await db.user.findUnique({ + where: { id: result.userId }, + select: { email: true, name: true }, + }); + if (user) { + await sendPasswordChangedEmail({ + to: user.email, + name: user.name, + changedAt: new Date(), + }).catch((error) => console.error("[auth] password-changed notice failed", error)); + } + } catch (error) { + console.error("[auth] password reset failed", error); + redirect(`${path}?error=password`); + } + + redirect("/signin/local?manual=1¬ice=password-reset"); +} + +export async function completeAccountSetupAction(formData: FormData): Promise { + const input = credentialFields(formData); + const name = String(formData.get("name") ?? "").trim(); + const path = `/activate/${encodeURIComponent(input.token)}`; + if (input.password !== input.confirmPassword) redirect(`${path}?error=mismatch`); + if (!input.token || input.token.length > 256 || !name || name.length > 80) { + redirect(`${path}?error=link`); + } + + try { + await completeAccountSetup(db, { + token: input.token, + password: input.password, + name, + ...(await requestContext()), + }); + } catch (error) { + console.error("[auth] account setup failed", error); + redirect(`${path}?error=password`); + } + + redirect("/signin/local?manual=1¬ice=activated"); +} diff --git a/src/server/auth.ts b/src/server/auth.ts index 7bf12f04..27995607 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -1,145 +1,211 @@ import "server-only"; -import NextAuth, { type NextAuthConfig } from "next-auth"; +import { createHash, timingSafeEqual } from "node:crypto"; +import NextAuth, { type NextAuthConfig, type Session } from "next-auth"; +import type { Adapter, AdapterUser } from "next-auth/adapters"; import type { Provider } from "next-auth/providers"; import Credentials from "next-auth/providers/credentials"; import GitHub from "next-auth/providers/github"; import Google from "next-auth/providers/google"; import { PrismaAdapter } from "@auth/prisma-adapter"; +import { UserStatus } from "@prisma/client"; +import { z } from "zod"; import { db } from "@/server/db"; import { decryptSecret } from "@/server/crypto"; import { getEnabledSsoRows, providerIdFor } from "@/server/sso"; -import { z } from "zod"; +import { getInstanceAuthPolicy } from "@/server/services/auth-policy"; +import { + hashPassword, + needsPasswordRehash, + verifyPasswordOrDummy, +} from "@/server/services/local-credentials"; +import { normalizeAuthEmail } from "@/server/services/auth-tokens"; +import { rateLimit } from "@/server/rate-limit"; -/** - * Env-driven admin authentication. - * - * The Credentials provider compares the submitted email+password against - * ADMIN_EMAIL / ADMIN_PASSWORD. On first successful sign-in, the user - * and their default workspace are upserted into the DB so later requests - * resolve a stable `session.user.id`. - * - * Credentials + the Prisma adapter require `jwt` session strategy — the - * adapter only writes the User/Account rows; the session lives in the - * JWT cookie. - */ +const credentialInput = z.object({ + email: z.string().email(), + password: z.string().min(1).max(4096), + breakGlass: z.enum(["0", "1"]).optional(), +}); -const credentialsProvider = Credentials({ - name: "Admin", - credentials: { - email: { label: "Email", type: "email" }, - password: { label: "Password", type: "password" }, +function secureStringEqual(left: string, right: string): boolean { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && timingSafeEqual(a, b); +} + +function requestIp(request: Request): string { + return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; +} + +async function authRateLimit(email: string, request: Request): Promise { + const emailKey = createHash("sha256").update(email).digest("hex"); + const [byIp, byAccount] = await Promise.all([ + rateLimit(`signin:ip:${requestIp(request)}`, 30, 60), + rateLimit(`signin:account:${emailKey}`, 12, 15 * 60), + ]); + return byIp.ok && byAccount.ok; +} + +async function ensureBootstrapOperator(email: string): Promise { + const configured = process.env.ADMIN_EMAIL; + if (!configured || normalizeAuthEmail(configured) !== email) return null; + + const existing = await db.user.findFirst({ + where: { OR: [{ normalizedEmail: email }, { email: configured }] }, + }); + if (existing) return existing as AdapterUser; + if ((await db.user.count()) > 0) return null; + + const user = await db.user.create({ + data: { + email, + normalizedEmail: email, + emailVerified: new Date(), + name: process.env.ADMIN_NAME ?? "Admin", + handle: (process.env.ADMIN_HANDLE ?? "admin").toLowerCase(), + instanceRole: "INSTANCE_ADMIN", + status: UserStatus.ACTIVE, }, - async authorize(raw) { - const parsed = z - .object({ email: z.string().email(), password: z.string().min(1) }) - .safeParse(raw); - if (!parsed.success) return null; - const { email, password } = parsed.data; - - const adminEmail = process.env.ADMIN_EMAIL; - const adminPass = process.env.ADMIN_PASSWORD; - if (!adminEmail || !adminPass) return null; - if (email.toLowerCase() !== adminEmail.toLowerCase()) return null; - - // Constant-time-ish compare — for a single admin secret the risk - // window is tiny, but avoid short-circuit just in case. - if (password.length !== adminPass.length) return null; - let ok = 1; - for (let i = 0; i < password.length; i++) { - ok &= password.charCodeAt(i) === adminPass.charCodeAt(i) ? 1 : 0; - } - if (!ok) return null; - - const user = await db.user.upsert({ - where: { email: adminEmail }, - // Do NOT re-stamp instanceRole on every login. Re-stamping made the - // bootstrap operator un-revocable: setInstanceRole(MEMBER) was silently - // overwritten on their next sign-in. The role now lives in the DB and - // is managed via instance-admin.setInstanceRole. - update: {}, - create: { - email: adminEmail, - name: process.env.ADMIN_NAME ?? "Admin", - handle: (process.env.ADMIN_HANDLE ?? "admin").toLowerCase(), - instanceRole: "INSTANCE_ADMIN", - }, - }); - // Bootstrap self-heal: promote this operator ONCE if no instance admin - // exists yet (e.g. a pre-restructure row, or a create that raced). Never - // re-promote after one exists, so a deliberate demotion sticks. - if (user.instanceRole !== "INSTANCE_ADMIN") { - const adminCount = await db.user.count({ - where: { instanceRole: "INSTANCE_ADMIN" }, - }); - if (adminCount === 0) { - await db.user.update({ - where: { id: user.id }, - data: { instanceRole: "INSTANCE_ADMIN" }, - }); - user.instanceRole = "INSTANCE_ADMIN"; - } - } + }); - const existing = await db.membership.findFirst({ where: { userId: user.id } }); - if (!existing) { - await db.workspace.create({ + await db.workspace.create({ + data: { + slug: (process.env.ADMIN_HANDLE ?? "admin").toLowerCase(), + name: process.env.ADMIN_NAME ?? "Admin", + key: "FRG", + memberships: { create: { userId: user.id, role: "OWNER" } }, + statuses: { + create: [ + { name: "Backlog", category: "BACKLOG", color: "#78716c", position: 0, isDefault: true }, + { name: "Todo", category: "TODO", color: "#a8a29e", position: 1 }, + { name: "In Progress", category: "IN_PROGRESS", color: "#d97706", position: 2 }, + { name: "In Review", category: "IN_REVIEW", color: "#ca8a04", position: 3 }, + { name: "Done", category: "DONE", color: "#65a30d", position: 4 }, + { name: "Canceled", category: "CANCELED", color: "#57534e", position: 5 }, + ], + }, + }, + }); + return user as AdapterUser; +} + +async function verifyBootstrapCredential(email: string, password: string): Promise { + const configuredEmail = process.env.ADMIN_EMAIL; + const configuredPassword = process.env.ADMIN_PASSWORD; + return Boolean( + configuredEmail && + configuredPassword && + secureStringEqual(normalizeAuthEmail(configuredEmail), email) && + secureStringEqual(configuredPassword, password), + ); +} + +const credentialsProvider = Credentials({ + name: "Forge password", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + breakGlass: { label: "Break glass", type: "hidden" }, + }, + async authorize(raw, request) { + const parsed = credentialInput.safeParse(raw); + if (!parsed.success) return null; + const email = normalizeAuthEmail(parsed.data.email); + if (!(await authRateLimit(email, request))) return null; + + const policy = await getInstanceAuthPolicy(); + const explicitBreakGlass = parsed.data.breakGlass === "1"; + const localAllowed = policy.mode !== "EXTERNAL_ONLY"; + const breakGlassAllowed = explicitBreakGlass && policy.breakGlassCredentialsEnabled; + + let user = await db.user.findFirst({ + where: { + OR: [{ normalizedEmail: email }, { email: { equals: email, mode: "insensitive" } }], + }, + include: { localCredential: true }, + }); + const localMatch = await verifyPasswordOrDummy( + parsed.data.password, + user?.localCredential?.passwordHash, + ); + const bootstrapMatch = + policy.breakGlassCredentialsEnabled && + (breakGlassAllowed || localAllowed) && + (await verifyBootstrapCredential(email, parsed.data.password)); + + if ((!localAllowed || !localMatch) && !bootstrapMatch) { + if (user?.localCredential) { + const nextAttempts = user.localCredential.failedAttempts + 1; + const lockedUntil = + nextAttempts >= policy.lockoutThreshold + ? new Date(Date.now() + policy.lockoutMinutes * 60_000) + : undefined; + await db.localCredential.update({ + where: { userId: user.id }, data: { - slug: (process.env.ADMIN_HANDLE ?? "admin").toLowerCase(), - name: process.env.ADMIN_NAME ?? "Admin", - key: "FRG", - memberships: { create: { userId: user.id, role: "OWNER" } }, - statuses: { - create: [ - { name: "Backlog", category: "BACKLOG", color: "#78716c", position: 0, isDefault: true }, - { name: "Todo", category: "TODO", color: "#a8a29e", position: 1 }, - { name: "In Progress", category: "IN_PROGRESS", color: "#d97706", position: 2 }, - { name: "In Review", category: "IN_REVIEW", color: "#ca8a04", position: 3 }, - { name: "Done", category: "DONE", color: "#65a30d", position: 4 }, - { name: "Canceled", category: "CANCELED", color: "#57534e", position: 5 }, - ], - }, + failedAttempts: { increment: 1 }, + lastFailedAt: new Date(), + ...(lockedUntil ? { lockedUntil } : {}), }, }); } + return null; + } - return { - id: user.id, - email: user.email, - name: user.name, - image: user.image, - }; - }, + if (!user && bootstrapMatch) { + const bootstrapped = await ensureBootstrapOperator(email); + if (!bootstrapped) return null; + user = await db.user.findUnique({ + where: { id: bootstrapped.id }, + include: { localCredential: true }, + }); + } + if (!user || user.status !== UserStatus.ACTIVE || user.deletedAt || user.disabledAt) + return null; + if (user.localCredential?.lockedUntil && user.localCredential.lockedUntil > new Date()) + return null; + if (explicitBreakGlass && user.instanceRole !== "INSTANCE_ADMIN") return null; + + if (localMatch && user.localCredential) { + const nextHash = needsPasswordRehash(user.localCredential.passwordHash) + ? await hashPassword(parsed.data.password) + : undefined; + await db.localCredential.update({ + where: { userId: user.id }, + data: { + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + lastUsedAt: new Date(), + ...(nextHash ? { passwordHash: nextHash, passwordChangedAt: new Date() } : {}), + }, + }); + } + + await db.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date(), normalizedEmail: email }, + }); + return { id: user.id, email: user.email, name: user.name, image: user.image }; }, -); +}); -/** - * Map the enabled `SsoProvider` rows to NextAuth provider instances. - * - * - **OIDC** rows become a generic OpenID-Connect provider (auto-discovery - * from `/.well-known/openid-configuration`). One row type covers - * every compliant IdP — Authelia, Authentik, Keycloak, Okta, Azure AD… - * - **GitHub / Google** rows use NextAuth's built-in factories so their - * callback URLs stay `/api/auth/callback/{github,google}`. - * - * Client secrets are decrypted here (server-only) right before handing them - * to NextAuth; `allowDangerousEmailAccountLinking` is opt-in per row. - */ async function ssoProvidersFromDb(): Promise { + const policy = await getInstanceAuthPolicy(); + if (policy.mode === "LOCAL_ONLY") return []; + const rows = await getEnabledSsoRows(); const providers: Provider[] = []; for (const row of rows) { let clientSecret: string; try { clientSecret = decryptSecret(row.clientSecret); - } catch (err) { - // A single un-decryptable secret (e.g. AUTH_SECRET rotated) must not - // take down sign-in for every other provider. - console.error(`[sso] skipping provider ${row.id} (${row.type}): bad secret`, err); + } catch (error) { + console.error(`[sso] skipping provider ${row.id} (${row.type}): bad secret`, error); continue; } - const link = row.allowLinking; - if (row.type === "OIDC") { - if (!row.issuer) continue; + const common = { allowDangerousEmailAccountLinking: row.allowLinking }; + if (row.type === "OIDC" && row.issuer) { providers.push({ id: providerIdFor(row), name: row.name, @@ -147,38 +213,160 @@ async function ssoProvidersFromDb(): Promise { issuer: row.issuer, clientId: row.clientId, clientSecret, - allowDangerousEmailAccountLinking: link, + ...common, ...(row.scopes ? { authorization: { params: { scope: row.scopes } } } : {}), }); } else if (row.type === "GITHUB") { - providers.push( - GitHub({ clientId: row.clientId, clientSecret, allowDangerousEmailAccountLinking: link }), - ); + providers.push(GitHub({ clientId: row.clientId, clientSecret, ...common })); } else if (row.type === "GOOGLE") { - providers.push( - Google({ clientId: row.clientId, clientSecret, allowDangerousEmailAccountLinking: link }), - ); + providers.push(Google({ clientId: row.clientId, clientSecret, ...common })); } } return providers; } -export const { handlers, auth, signIn, signOut } = NextAuth(async () => { +function forgeAdapter(): Adapter { + const base = PrismaAdapter(db); + return { + ...base, + async createUser(data) { + const email = normalizeAuthEmail(data.email); + const existing = await db.user.findFirst({ + where: { + OR: [{ normalizedEmail: email }, { email: { equals: email, mode: "insensitive" } }], + }, + }); + if (existing) { + if (existing.status === UserStatus.SUSPENDED || existing.status === UserStatus.DELETED) { + throw new Error("This Forge account is not active."); + } + return db.user.update({ + where: { id: existing.id }, + data: { + normalizedEmail: email, + status: UserStatus.ACTIVE, + emailVerified: data.emailVerified ?? existing.emailVerified, + name: existing.name ?? data.name, + image: existing.image ?? data.image, + lastLoginAt: new Date(), + }, + }) as Promise; + } + + const policy = await getInstanceAuthPolicy(); + const invited = await db.workspaceInvitation.findFirst({ + where: { email, status: "PENDING", expiresAt: { gt: new Date() } }, + select: { id: true }, + }); + if (policy.registrationMode !== "OPEN" && !invited) { + throw new Error("This Forge instance requires an invitation."); + } + return db.user.create({ + data: { + email, + normalizedEmail: email, + emailVerified: data.emailVerified, + name: data.name, + image: data.image, + status: UserStatus.ACTIVE, + }, + }) as Promise; + }, + async getUserByEmail(email) { + const normalizedEmail = normalizeAuthEmail(email); + return db.user.findFirst({ + where: { + OR: [{ normalizedEmail }, { email: { equals: normalizedEmail, mode: "insensitive" } }], + }, + }) as Promise; + }, + async updateUser(data) { + const normalizedEmail = data.email ? normalizeAuthEmail(data.email) : undefined; + return db.user.update({ + where: { id: data.id }, + data: { + ...data, + ...(normalizedEmail ? { email: normalizedEmail, normalizedEmail } : {}), + }, + }) as Promise; + }, + async linkAccount(account) { + if (!base.linkAccount) throw new Error("Auth adapter cannot link accounts."); + await base.linkAccount({ + ...account, + access_token: undefined, + refresh_token: undefined, + id_token: undefined, + }); + }, + }; +} + +const nextAuth = NextAuth(async () => { const config: NextAuthConfig = { - adapter: PrismaAdapter(db), + adapter: forgeAdapter(), session: { strategy: "jwt" }, providers: [credentialsProvider, ...(await ssoProvidersFromDb())], - pages: { - signIn: "/signin", - }, + pages: { signIn: "/signin" }, callbacks: { + async signIn({ user, account }) { + if (account?.provider !== "credentials") { + const policy = await getInstanceAuthPolicy(); + if (policy.mode === "LOCAL_ONLY") return false; + const normalizedEmail = user.email ? normalizeAuthEmail(user.email) : null; + const current = await db.user.findFirst({ + where: { + OR: [ + ...(user.id ? [{ id: user.id }] : []), + ...(normalizedEmail + ? [ + { normalizedEmail }, + { email: { equals: normalizedEmail, mode: "insensitive" as const } }, + ] + : []), + ], + }, + }); + // A new provider profile has not passed through adapter.createUser + // yet. Registration/invitation policy is enforced there. Existing + // principals must already be active. + if (!current) return true; + if (current.status !== UserStatus.ACTIVE || current.disabledAt || current.deletedAt) { + return false; + } + await db.user.update({ where: { id: current.id }, data: { lastLoginAt: new Date() } }); + return true; + } + + if (!user.id) return false; + const current = await db.user.findUnique({ where: { id: user.id } }); + if ( + !current || + current.status !== UserStatus.ACTIVE || + current.disabledAt || + current.deletedAt + ) { + return false; + } + return true; + }, async jwt({ token, user }) { - if (user?.id) token.id = user.id; + if (user?.id) { + const current = await db.user.findUnique({ + where: { id: user.id }, + select: { authVersion: true, status: true }, + }); + token.id = user.id; + token.authVersion = current?.authVersion ?? 0; + token.accountStatus = current?.status ?? UserStatus.ACTIVE; + } return token; }, async session({ session, token }) { if (session.user && token.id) { session.user.id = token.id as string; + session.user.authVersion = Number(token.authVersion ?? 0); + session.user.status = String(token.accountStatus ?? UserStatus.ACTIVE) as UserStatus; } return session; }, @@ -187,6 +375,32 @@ export const { handlers, auth, signIn, signOut } = NextAuth(async () => { return config; }); +export const { handlers, signIn, signOut } = nextAuth; + +/** + * Auth.js JWTs are stateless, so every protected request re-checks the durable + * account status and authVersion. Password changes, suspension, deletion, and + * explicit session revocation increment the version and take effect at once. + */ +export async function auth(): Promise { + const session = await nextAuth.auth(); + if (!session?.user?.id) return null; + const current = await db.user.findUnique({ + where: { id: session.user.id }, + select: { status: true, authVersion: true, disabledAt: true, deletedAt: true }, + }); + if ( + !current || + current.status !== UserStatus.ACTIVE || + current.disabledAt || + current.deletedAt || + current.authVersion !== session.user.authVersion + ) { + return null; + } + return session; +} + declare module "next-auth" { interface Session { user: { @@ -194,6 +408,8 @@ declare module "next-auth" { email: string; name?: string | null; image?: string | null; + authVersion: number; + status: UserStatus; }; } } diff --git a/src/server/routers/__tests__/identity-policy.test.ts b/src/server/routers/__tests__/identity-policy.test.ts new file mode 100644 index 00000000..15da3075 --- /dev/null +++ b/src/server/routers/__tests__/identity-policy.test.ts @@ -0,0 +1,142 @@ +import { InstanceRole } from "@prisma/client"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { ssoRouter } from "@/server/routers/sso"; +import { userRouter } from "@/server/routers/user"; +import { + buildContext, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; +import { hashPassword } from "@/server/services/local-credentials"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + const db = getPrisma(); + while (fixtures.length) await fixtures.pop()!.cleanup(); + await db.ssoProvider.deleteMany({ where: { name: { startsWith: "Identity policy test" } } }); + await db.instanceAuthPolicy.upsert({ + where: { id: "default" }, + update: { + mode: "HYBRID", + registrationMode: "INVITE_ONLY", + breakGlassCredentialsEnabled: true, + autoRedirectProviderId: null, + passwordMinLength: 12, + passwordResetTtlMinutes: 30, + lockoutThreshold: 10, + lockoutMinutes: 15, + }, + create: { id: "default" }, + }); + vi.unstubAllEnvs(); +}); + +afterAll(async () => disconnectPrisma()); + +async function adminFixture() { + const fixture = await createWorkspaceFixture({ keyPrefix: "IP" }); + fixtures.push(fixture); + await getPrisma().user.update({ + where: { id: fixture.user.id }, + data: { instanceRole: InstanceRole.INSTANCE_ADMIN }, + }); + return fixture; +} + +const policyInput = { + mode: "HYBRID" as const, + registrationMode: "INVITE_ONLY" as const, + breakGlassCredentialsEnabled: false, + autoRedirectProviderId: null, + passwordMinLength: 12, + passwordResetTtlMinutes: 30, + lockoutThreshold: 10, + lockoutMinutes: 15, +}; + +describe("identity policy guards", () => { + it("refuses to remove the final administrator recovery path", async () => { + const fixture = await adminFixture(); + vi.stubEnv("ADMIN_EMAIL", ""); + vi.stubEnv("ADMIN_PASSWORD", ""); + const caller = ssoRouter.createCaller(await buildContext(fixture)); + + await expect(caller.updatePolicy(policyInput)).rejects.toThrow(/usable sign-in method/i); + }); + + it("allows a local-only policy when an active administrator has a password", async () => { + const fixture = await adminFixture(); + vi.stubEnv("ADMIN_EMAIL", ""); + vi.stubEnv("ADMIN_PASSWORD", ""); + await getPrisma().localCredential.create({ + data: { + userId: fixture.user.id, + passwordHash: await hashPassword("identity policy password"), + }, + }); + const caller = ssoRouter.createCaller(await buildContext(fixture)); + + await expect( + caller.updatePolicy({ ...policyInput, mode: "LOCAL_ONLY" }), + ).resolves.toMatchObject({ mode: "LOCAL_ONLY", breakGlassCredentialsEnabled: false }); + }); + + it("does not count a disabled provider account as a usable password replacement", async () => { + const fixture = await adminFixture(); + const password = "identity removal password"; + await getPrisma().localCredential.create({ + data: { userId: fixture.user.id, passwordHash: await hashPassword(password) }, + }); + await getPrisma().account.create({ + data: { + userId: fixture.user.id, + type: "oauth", + provider: "github", + providerAccountId: `disabled-${fixture.user.id}`, + }, + }); + const caller = userRouter.createCaller(await buildContext(fixture)); + + await expect(caller.removePassword({ currentPassword: password })).rejects.toThrow( + /enabled external sign-in method/i, + ); + }); + + it("does not count a local password in external-only mode when unlinking the final provider", async () => { + const fixture = await adminFixture(); + const db = getPrisma(); + await db.localCredential.create({ + data: { userId: fixture.user.id, passwordHash: await hashPassword("external-only password") }, + }); + const provider = await db.ssoProvider.create({ + data: { + type: "GITHUB", + name: "Identity policy test GitHub", + enabled: true, + clientId: "test-client", + clientSecret: "test-secret", + }, + }); + void provider; + const account = await db.account.create({ + data: { + userId: fixture.user.id, + type: "oauth", + provider: "github", + providerAccountId: `enabled-${fixture.user.id}`, + }, + }); + await db.instanceAuthPolicy.update({ + where: { id: "default" }, + data: { mode: "EXTERNAL_ONLY" }, + }); + const caller = userRouter.createCaller(await buildContext(fixture)); + + await expect(caller.unlinkIdentity({ accountId: account.id })).rejects.toThrow( + /final sign-in method/i, + ); + }); +}); diff --git a/src/server/routers/__tests__/project-authorization.test.ts b/src/server/routers/__tests__/project-authorization.test.ts new file mode 100644 index 00000000..5501f993 --- /dev/null +++ b/src/server/routers/__tests__/project-authorization.test.ts @@ -0,0 +1,84 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { Role } from "@prisma/client"; +import { projectRouter } from "@/server/routers/project"; +import { + buildContext, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "./helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +async function fixture(keyPrefix: string) { + const created = await createWorkspaceFixture({ keyPrefix }); + fixtures.push(created); + return created; +} + +describe("project mutation authorization", () => { + it("rejects project mutations from guests", async () => { + const setup = await fixture("PGA"); + const prisma = getPrisma(); + await prisma.membership.update({ + where: { + userId_workspaceId: { + userId: setup.secondUser.id, + workspaceId: setup.workspace.id, + }, + }, + data: { role: Role.GUEST }, + }); + const owner = projectRouter.createCaller(await buildContext(setup)); + const guest = projectRouter.createCaller( + await buildContext(setup, { asUserId: setup.secondUser.id }), + ); + const project = await owner.create({ key: "AUTH", name: "Authorization" }); + + await expect(guest.create({ key: "NOPE", name: "Forbidden" })).rejects.toThrow( + /workspace role/i, + ); + await expect(guest.update({ id: project.id, name: "Forbidden" })).rejects.toThrow( + /workspace role/i, + ); + await expect(guest.archive({ id: project.id })).rejects.toThrow(/workspace role/i); + await expect(guest.softDelete({ id: project.id })).rejects.toThrow(/workspace role/i); + + const unchanged = await prisma.project.findUniqueOrThrow({ where: { id: project.id } }); + expect(unchanged).toMatchObject({ name: "Authorization", archived: false, deletedAt: null }); + }); + + it("preserves project mutation access for members", async () => { + const setup = await fixture("PMA"); + const member = projectRouter.createCaller( + await buildContext(setup, { asUserId: setup.secondUser.id }), + ); + + const project = await member.create({ key: "MEM", name: "Member project" }); + await expect( + member.update({ id: project.id, name: "Updated by member" }), + ).resolves.toMatchObject({ name: "Updated by member" }); + }); + + it("does not archive a project from another workspace", async () => { + const ownerSetup = await fixture("POW"); + const outsiderSetup = await fixture("POX"); + const owner = projectRouter.createCaller(await buildContext(ownerSetup)); + const outsider = projectRouter.createCaller(await buildContext(outsiderSetup)); + const project = await owner.create({ key: "SAFE", name: "Tenant boundary" }); + + await expect(outsider.archive({ id: project.id })).rejects.toThrow(); + expect( + await getPrisma().project.findUniqueOrThrow({ where: { id: project.id } }), + ).toMatchObject({ archived: false, workspaceId: ownerSetup.workspace.id }); + }); +}); diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index 5602501d..a76fcb87 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -25,6 +25,7 @@ import { ssoRouter } from "./sso"; import { accessRouter } from "./access"; import { adminRouter } from "./admin"; import { attachmentRouter } from "./attachment"; +import { avatarRouter } from "./avatar"; import { labelRouter } from "./label"; import { issueTemplateRouter } from "./issue-template"; import { projectTemplateRouter } from "./project-template"; @@ -79,6 +80,7 @@ export const appRouter = router({ chat: chatRouter, analytics: analyticsRouter, attachment: attachmentRouter, + avatar: avatarRouter, comment: commentRouter, commandCenter: commandCenterRouter, contextSet: contextSetRouter, diff --git a/src/server/routers/avatar.ts b/src/server/routers/avatar.ts new file mode 100644 index 00000000..c26e6863 --- /dev/null +++ b/src/server/routers/avatar.ts @@ -0,0 +1,93 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { protectedProcedure, router, withRateLimit } from "@/server/trpc"; +import { + ALLOWED_AVATAR_MIME_TYPES, + InvalidAvatarError, + MAX_AVATAR_SIZE_BYTES, + finalizeUserAvatar, + getUserAvatarState, + presignUserAvatarUpload, + removeUserAvatar, +} from "@/server/services/user-avatar"; +import { StorageNotConfiguredError } from "@/server/services/storage"; + +function avatarError(error: unknown): TRPCError { + if (error instanceof StorageNotConfiguredError) { + return new TRPCError({ code: "PRECONDITION_FAILED", message: error.message }); + } + if (error instanceof InvalidAvatarError) { + return new TRPCError({ code: "BAD_REQUEST", message: error.message }); + } + return new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Avatar operation failed." }); +} + +const avatarWriteProcedure = protectedProcedure.use(withRateLimit(10, 60)); + +export const avatarRouter = router({ + me: protectedProcedure.query(({ ctx }) => getUserAvatarState(ctx.session.user.id)), + + initUpload: avatarWriteProcedure + .input( + z.object({ + contentType: z.enum(ALLOWED_AVATAR_MIME_TYPES), + sizeBytes: z.number().int().positive().max(MAX_AVATAR_SIZE_BYTES), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + return await presignUserAvatarUpload({ userId: ctx.session.user.id, ...input }); + } catch (error) { + throw avatarError(error); + } + }), + + finalize: avatarWriteProcedure + .input(z.object({ objectKey: z.string().min(1).max(512) })) + .mutation(async ({ ctx, input }) => { + try { + const avatar = await finalizeUserAvatar({ + userId: ctx.session.user.id, + objectKey: input.objectKey, + }); + await ctx.db.instanceAuditLog.create({ + data: { + actorId: ctx.session.user.id, + targetUserId: ctx.session.user.id, + action: "USER_AVATAR_UPDATED", + metadata: { contentType: avatar.contentType, sizeBytes: avatar.sizeBytes }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }); + return avatar; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw avatarError(error); + } + }), + + remove: avatarWriteProcedure.mutation(async ({ ctx }) => { + try { + const result = await removeUserAvatar(ctx.session.user.id); + if (result.removed) { + await ctx.db.instanceAuditLog.create({ + data: { + actorId: ctx.session.user.id, + targetUserId: ctx.session.user.id, + action: "USER_AVATAR_REMOVED", + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }); + } + return { + ...result, + fallback: await getUserAvatarState(ctx.session.user.id), + }; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw avatarError(error); + } + }), +}); diff --git a/src/server/routers/instance-admin.ts b/src/server/routers/instance-admin.ts index 989c9647..74bd7a17 100644 --- a/src/server/routers/instance-admin.ts +++ b/src/server/routers/instance-admin.ts @@ -1,17 +1,54 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { CycleStatus, InstanceRole, Role } from "@prisma/client"; +import { CycleStatus, InstanceRole, Role, UserActionTokenType } from "@prisma/client"; import { router, instanceAdminProcedure } from "@/server/trpc"; import { ensureWorkspaceBucket } from "@/server/services/storage"; import { runtimeConfigStatus } from "@/server/services/runtime-config"; import { deriveRuntimeHealthStatus } from "@/server/services/runtime-status"; import { summarizeRuntimeSelfTest } from "@/server/services/runtime-self-test"; import { summarizeRuntimeInfo } from "@/server/services/runtime-info"; -import { - resolveSubjectLabels, - subjectKey, -} from "@/server/services/subject-labels"; +import { resolveSubjectLabels, subjectKey } from "@/server/services/subject-labels"; import { forgeBuildIdentity } from "@/server/build-info"; +import { + createInvitedUser, + issueUserActionToken, + reactivateUser, + revokeUserSessions, + setUserInstanceRole, + softDeleteUser, + suspendUser, +} from "@/server/services/user-lifecycle"; +import { sendAccountSetupEmail, sendPasswordResetEmail } from "@/server/services/email"; + +function identityActionUrl(path: string): string { + const configured = process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.AUTH_URL?.trim(); + if (!configured) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Set NEXT_PUBLIC_APP_URL or AUTH_URL before sending account emails.", + }); + } + let origin: URL; + try { + origin = new URL(configured); + } catch { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "NEXT_PUBLIC_APP_URL or AUTH_URL must be a valid absolute URL.", + }); + } + if ( + origin.protocol !== "https:" && + origin.hostname !== "localhost" && + origin.hostname !== "127.0.0.1" + ) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Account email links require HTTPS outside local development.", + }); + } + return new URL(path, `${origin.origin}/`).toString(); +} const slugSchema = z .string() @@ -48,7 +85,11 @@ export const instanceAdminRouter = router({ avatarUrl: true, createdAt: true, _count: { select: { memberships: true, issues: true } }, - memberships: { where: { role: "OWNER" }, take: 1, select: { user: { select: { name: true, email: true } } } }, + memberships: { + where: { role: "OWNER" }, + take: 1, + select: { user: { select: { name: true, email: true } } }, + }, }, }); const since = new Date(Date.now() - 24 * 60 * 60 * 1000); @@ -63,7 +104,9 @@ export const instanceAdminRouter = router({ members: w._count.memberships, issues: w._count.issues, owner: w.memberships[0]?.user ?? null, - runsLast24: await ctx.db.agentRun.count({ where: { workspaceId: w.id, startedAt: { gte: since } } }), + runsLast24: await ctx.db.agentRun.count({ + where: { workspaceId: w.id, startedAt: { gte: since } }, + }), })), ); }), @@ -79,31 +122,269 @@ export const instanceAdminRouter = router({ handle: true, image: true, instanceRole: true, + status: true, + disabledAt: true, + deletedAt: true, + lastLoginAt: true, createdAt: true, - _count: { select: { memberships: true } }, + localCredential: { select: { passwordChangedAt: true, mustChangePassword: true } }, + accounts: { select: { provider: true } }, + _count: { select: { memberships: true, sessions: true, apiKeys: true, connections: true } }, }, }); - return users.map((u) => ({ ...u, workspaces: u._count.memberships })); + return users.map(({ _count, accounts, localCredential, ...user }) => ({ + ...user, + workspaces: _count.memberships, + counts: { + sessions: _count.sessions, + apiKeys: _count.apiKeys, + connections: _count.connections, + }, + loginMethods: { + password: Boolean(localCredential), + providers: [...new Set(accounts.map((account) => account.provider))], + }, + })); }), - /** Promote / demote a user's instance role. Cannot demote the last admin. */ - setInstanceRole: instanceAdminProcedure - .input(z.object({ userId: z.string().cuid(), role: z.nativeEnum(InstanceRole) })) + /** Full identity, access, and lifecycle detail for one user. */ + userDetail: instanceAdminProcedure + .input(z.object({ userId: z.string().cuid() })) + .query(async ({ ctx, input }) => { + const user = await ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { + id: true, + email: true, + normalizedEmail: true, + emailVerified: true, + name: true, + handle: true, + image: true, + instanceRole: true, + status: true, + authVersion: true, + lastLoginAt: true, + disabledAt: true, + deletedAt: true, + createdAt: true, + updatedAt: true, + localCredential: { + select: { + passwordChangedAt: true, + mustChangePassword: true, + failedAttempts: true, + lastFailedAt: true, + lockedUntil: true, + }, + }, + accounts: { + select: { id: true, provider: true, providerAccountId: true, type: true }, + orderBy: { provider: "asc" }, + }, + memberships: { + select: { + id: true, + role: true, + createdAt: true, + workspace: { select: { id: true, slug: true, name: true, key: true } }, + }, + orderBy: { createdAt: "asc" }, + }, + connections: { + select: { + id: true, + provider: true, + label: true, + account: true, + status: true, + scopes: true, + }, + orderBy: { createdAt: "asc" }, + }, + instanceAuditTargets: { + select: { + id: true, + action: true, + metadata: true, + ipAddress: true, + userAgent: true, + createdAt: true, + actor: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 50, + }, + _count: { select: { sessions: true, apiKeys: true, actionTokens: true } }, + }, + }); + if (!user) throw new TRPCError({ code: "NOT_FOUND", message: "User not found." }); + return user; + }), + + /** Instance-scoped identity and authentication security audit. */ + identityAudit: instanceAdminProcedure + .input( + z.object({ + limit: z.number().int().min(1).max(200).default(60), + cursor: z.string().cuid().optional(), + targetUserId: z.string().cuid().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const rows = await ctx.db.instanceAuditLog.findMany({ + where: input.targetUserId ? { targetUserId: input.targetUserId } : undefined, + orderBy: { createdAt: "desc" }, + take: input.limit + 1, + cursor: input.cursor ? { id: input.cursor } : undefined, + skip: input.cursor ? 1 : 0, + select: { + id: true, + action: true, + metadata: true, + ipAddress: true, + userAgent: true, + createdAt: true, + actor: { select: { id: true, name: true, email: true } }, + targetUser: { select: { id: true, name: true, email: true, status: true } }, + }, + }); + const nextCursor = rows.length > input.limit ? rows.pop()!.id : undefined; + return { items: rows, nextCursor }; + }), + + /** Create an invited principal and return its one-time account setup token. */ + createUser: instanceAdminProcedure + .input( + z.object({ + email: z.string().email(), + name: z.string().trim().min(1).max(80).optional(), + instanceRole: z.nativeEnum(InstanceRole).default(InstanceRole.MEMBER), + }), + ) .mutation(async ({ ctx, input }) => { - if (input.role !== "INSTANCE_ADMIN") { - const admins = await ctx.db.user.count({ where: { instanceRole: "INSTANCE_ADMIN" } }); - const target = await ctx.db.user.findUnique({ where: { id: input.userId }, select: { instanceRole: true } }); - if (target?.instanceRole === "INSTANCE_ADMIN" && admins <= 1) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Can't demote the last instance admin." }); - } - } - return ctx.db.user.update({ + const created = await createInvitedUser(ctx.db, { + ...input, + actorId: ctx.session.user.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + const messageId = await sendAccountSetupEmail({ + to: created.user.email, + name: created.user.name, + url: identityActionUrl(`/activate/${encodeURIComponent(created.setupToken)}`), + expiresAt: created.expiresAt, + }); + return { + user: created.user, + delivery: { messageId, expiresAt: created.expiresAt }, + }; + }), + + issueSetupToken: instanceAdminProcedure + .input(z.object({ userId: z.string().cuid() })) + .mutation(async ({ ctx, input }) => { + const user = await ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { email: true, name: true }, + }); + if (!user) throw new TRPCError({ code: "NOT_FOUND", message: "User not found." }); + const issued = await issueUserActionToken(ctx.db, { + ...input, + type: UserActionTokenType.ACCOUNT_SETUP, + actorId: ctx.session.user.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + const messageId = await sendAccountSetupEmail({ + to: user.email, + name: user.name, + url: identityActionUrl(`/activate/${encodeURIComponent(issued.token)}`), + expiresAt: issued.expiresAt, + }); + return { delivery: { messageId, expiresAt: issued.expiresAt } }; + }), + + issuePasswordResetToken: instanceAdminProcedure + .input(z.object({ userId: z.string().cuid() })) + .mutation(async ({ ctx, input }) => { + const user = await ctx.db.user.findUnique({ where: { id: input.userId }, - data: { instanceRole: input.role }, - select: { id: true, instanceRole: true }, + select: { email: true, name: true }, }); + if (!user) throw new TRPCError({ code: "NOT_FOUND", message: "User not found." }); + const issued = await issueUserActionToken(ctx.db, { + ...input, + type: UserActionTokenType.PASSWORD_RESET, + actorId: ctx.session.user.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + const messageId = await sendPasswordResetEmail({ + to: user.email, + name: user.name, + url: identityActionUrl(`/reset-password/${encodeURIComponent(issued.token)}`), + expiresAt: issued.expiresAt, + }); + return { delivery: { messageId, expiresAt: issued.expiresAt } }; }), + suspendUser: instanceAdminProcedure + .input(z.object({ userId: z.string().cuid(), reason: z.string().trim().max(500).optional() })) + .mutation(({ ctx, input }) => + suspendUser(ctx.db, { + ...input, + actorId: ctx.session.user.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }), + ), + + reactivateUser: instanceAdminProcedure + .input(z.object({ userId: z.string().cuid() })) + .mutation(({ ctx, input }) => + reactivateUser(ctx.db, { + ...input, + actorId: ctx.session.user.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }), + ), + + revokeUserSessions: instanceAdminProcedure + .input(z.object({ userId: z.string().cuid() })) + .mutation(({ ctx, input }) => + revokeUserSessions(ctx.db, { + ...input, + actorId: ctx.session.user.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }), + ), + + deleteUser: instanceAdminProcedure + .input(z.object({ userId: z.string().cuid(), reason: z.string().trim().max(500).optional() })) + .mutation(({ ctx, input }) => + softDeleteUser(ctx.db, { + ...input, + actorId: ctx.session.user.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }), + ), + + /** Promote / demote a user's instance role. Cannot demote the last admin. */ + setInstanceRole: instanceAdminProcedure + .input(z.object({ userId: z.string().cuid(), role: z.nativeEnum(InstanceRole) })) + .mutation(({ ctx, input }) => + setUserInstanceRole(ctx.db, { + ...input, + actorId: ctx.session.user.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }), + ), + /** Every runtime across the instance (not just the caller's). */ runtimes: instanceAdminProcedure.query(async ({ ctx }) => { const runtimes = await ctx.db.runtime.findMany({ @@ -152,7 +433,12 @@ export const instanceAdminRouter = router({ /** Cross-workspace audit feed for the instance audit page. */ audit: instanceAdminProcedure - .input(z.object({ limit: z.number().int().min(1).max(200).default(60), cursor: z.string().optional() })) + .input( + z.object({ + limit: z.number().int().min(1).max(200).default(60), + cursor: z.string().optional(), + }), + ) .query(async ({ ctx, input }) => { const rows = await ctx.db.activityEvent.findMany({ orderBy: { createdAt: "desc" }, @@ -285,11 +571,8 @@ export const instanceAdminRouter = router({ return workspace; }), - /** - * Invite a user by email. Authelia owns identity at the edge, so this - * upserts a shell `User` row keyed by email — first login binds to it. - * Membership is intentionally NOT created here (instance-level invite); - * workspace owners add members via `workspace.addMember`. Idempotent. + /** Compatibility alias used by the existing invite dialog. New callers use + * `createUser`; both create an INVITED principal and deliver setup by email. */ inviteUser: instanceAdminProcedure .input( @@ -300,23 +583,25 @@ export const instanceAdminRouter = router({ }), ) .mutation(async ({ ctx, input }) => { - const email = input.email.trim().toLowerCase(); - const role = input.instanceAdmin ? InstanceRole.INSTANCE_ADMIN : InstanceRole.MEMBER; - const existing = await ctx.db.user.findUnique({ - where: { email }, - select: { id: true }, + const created = await createInvitedUser(ctx.db, { + email: input.email, + name: input.name, + instanceRole: input.instanceAdmin ? InstanceRole.INSTANCE_ADMIN : InstanceRole.MEMBER, + actorId: ctx.session.user.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, }); - const user = await ctx.db.user.upsert({ - where: { email }, - update: { - // Only fill name if not already set; never clobber an existing one. - ...(input.name ? { name: input.name } : {}), - ...(input.instanceAdmin ? { instanceRole: role } : {}), - }, - create: { email, name: input.name, instanceRole: role }, - select: { id: true, email: true, name: true, instanceRole: true }, + const messageId = await sendAccountSetupEmail({ + to: created.user.email, + name: created.user.name, + url: identityActionUrl(`/activate/${encodeURIComponent(created.setupToken)}`), + expiresAt: created.expiresAt, }); - return { ...user, created: !existing }; + return { + ...created.user, + created: true, + delivery: { messageId, expiresAt: created.expiresAt }, + }; }), /** diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 75134ddc..9cc0185e 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -3,6 +3,7 @@ import { TRPCError } from "@trpc/server"; import { CompletionAutomation, EventKind, type StatusCategory } from "@prisma/client"; import { router, workspaceProcedure } from "@/server/trpc"; import { recordChange } from "@/server/audit"; +import { assertWorkspaceAction } from "@/server/services/authorization"; const cursorSchema = z.string().optional(); const projectKey = z @@ -461,6 +462,7 @@ export const projectRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + assertWorkspaceAction(ctx.membership.role, "CREATE_PROJECT"); if ( input.startDate && input.targetDate && @@ -514,6 +516,7 @@ export const projectRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + assertWorkspaceAction(ctx.membership.role, "MUTATE_PROJECT"); const { id, ...patch } = input; return ctx.db.$transaction(async (tx) => { const before = await tx.project.findFirstOrThrow({ @@ -550,16 +553,22 @@ export const projectRouter = router({ archive: workspaceProcedure .input(z.object({ id: z.string().cuid() })) - .mutation(async ({ ctx, input }) => - ctx.db.project.update({ - where: { id: input.id }, + .mutation(async ({ ctx, input }) => { + assertWorkspaceAction(ctx.membership.role, "MUTATE_PROJECT"); + const project = await ctx.db.project.findFirstOrThrow({ + where: { id: input.id, workspaceId: ctx.workspaceId, deletedAt: null }, + select: { id: true }, + }); + return ctx.db.project.update({ + where: { id: project.id }, data: { archived: true }, - }), - ), + }); + }), softDelete: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { + assertWorkspaceAction(ctx.membership.role, "MUTATE_PROJECT"); const p = await ctx.db.project.findFirstOrThrow({ where: { id: input.id, workspaceId: ctx.workspaceId }, }); diff --git a/src/server/routers/sso.ts b/src/server/routers/sso.ts index 4cd2eaec..06be8642 100644 --- a/src/server/routers/sso.ts +++ b/src/server/routers/sso.ts @@ -1,9 +1,20 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { SsoType } from "@prisma/client"; +import { + AuthenticationMode, + RegistrationMode, + SsoType, + type InstanceAuthPolicy, + type PrismaClient, +} from "@prisma/client"; import { router, instanceAdminProcedure } from "@/server/trpc"; import { encryptSecret } from "@/server/crypto"; import { bustSsoCache, providerIdFor } from "@/server/sso"; +import { + deriveAuthPresentation, + getInstanceAuthPolicy, + validateAuthPolicyTransition, +} from "@/server/services/auth-policy"; const typeEnum = z.nativeEnum(SsoType); @@ -19,13 +30,110 @@ function normalizeIssuer(raw: string): string { return trimmed; } +async function assertAdminRecoveryPath( + database: Pick, + policy: Pick, + providers: Array<{ id: string; type: SsoType; enabled: boolean; archivedAt: Date | null }>, +): Promise { + if ( + policy.breakGlassCredentialsEnabled && + process.env.ADMIN_EMAIL && + process.env.ADMIN_PASSWORD + ) { + return; + } + const providerKeys = providers + .filter((provider) => provider.enabled && !provider.archivedAt) + .map((provider) => providerIdFor(provider)); + const methodClauses = [ + ...(policy.mode !== "EXTERNAL_ONLY" ? [{ localCredential: { isNot: null } }] : []), + ...(policy.mode !== "LOCAL_ONLY" && providerKeys.length + ? [{ accounts: { some: { provider: { in: providerKeys } } } }] + : []), + ]; + if ( + methodClauses.length === 0 || + (await database.user.count({ + where: { + instanceRole: "INSTANCE_ADMIN", + status: "ACTIVE", + disabledAt: null, + deletedAt: null, + OR: methodClauses, + }, + })) === 0 + ) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Keep at least one active instance administrator with a usable sign-in method or configured break-glass credentials.", + }); + } +} + export const ssoRouter = router({ + policy: instanceAdminProcedure.query(async ({ ctx }) => { + const [policy, providers] = await Promise.all([ + getInstanceAuthPolicy(ctx.db), + ctx.db.ssoProvider.findMany({ + where: { archivedAt: null }, + select: { id: true, type: true, enabled: true, archivedAt: true }, + }), + ]); + return { + policy, + presentation: deriveAuthPresentation(policy, providers), + breakGlassConfigured: Boolean(process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD), + }; + }), + + updatePolicy: instanceAdminProcedure + .input( + z.object({ + mode: z.nativeEnum(AuthenticationMode), + registrationMode: z.nativeEnum(RegistrationMode), + breakGlassCredentialsEnabled: z.boolean(), + autoRedirectProviderId: z.string().cuid().nullable(), + passwordMinLength: z.number().int().min(8).max(128), + passwordResetTtlMinutes: z.number().int().min(5).max(1440), + lockoutThreshold: z.number().int().min(3).max(100), + lockoutMinutes: z.number().int().min(1).max(1440), + }), + ) + .mutation(async ({ ctx, input }) => { + const providers = await ctx.db.ssoProvider.findMany({ + where: { archivedAt: null }, + select: { id: true, type: true, enabled: true, archivedAt: true }, + }); + validateAuthPolicyTransition({ id: "default", ...input }, providers, { + breakGlassConfigured: Boolean(process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD), + }); + await assertAdminRecoveryPath(ctx.db, input, providers); + const policy = await ctx.db.instanceAuthPolicy.upsert({ + where: { id: "default" }, + update: input, + create: { id: "default", ...input }, + }); + await ctx.db.instanceAuditLog.create({ + data: { + actorId: ctx.session.user.id, + action: "AUTH_POLICY_UPDATED", + metadata: input, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }); + bustSsoCache(); + return policy; + }), + /** * Full provider inventory for the admin UI. Secrets are never returned — * only a `hasSecret` flag so the form can show "configured" / "replace". */ list: instanceAdminProcedure.query(async ({ ctx }) => { const rows = await ctx.db.ssoProvider.findMany({ + where: { archivedAt: null }, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], }); return rows.map((r) => ({ @@ -60,7 +168,10 @@ export const ssoRouter = router({ ) .mutation(async ({ ctx, input }) => { if (input.type === "OIDC" && !input.issuer) { - throw new TRPCError({ code: "BAD_REQUEST", message: "OIDC providers require an issuer URL." }); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "OIDC providers require an issuer URL.", + }); } // GitHub/Google map onto fixed NextAuth provider ids, so only one of // each can exist (two would collide on the same callback URL). @@ -118,9 +229,7 @@ export const ssoRouter = router({ ...(existing.type === "OIDC" && input.issuer !== undefined ? { issuer: normalizeIssuer(input.issuer) } : {}), - ...(input.clientSecret - ? { clientSecret: encryptSecret(input.clientSecret) } - : {}), + ...(input.clientSecret ? { clientSecret: encryptSecret(input.clientSecret) } : {}), }, }); bustSsoCache(); @@ -130,9 +239,44 @@ export const ssoRouter = router({ setEnabled: instanceAdminProcedure .input(z.object({ id: z.string(), enabled: z.boolean() })) .mutation(async ({ ctx, input }) => { - await ctx.db.ssoProvider.update({ - where: { id: input.id }, - data: { enabled: input.enabled }, + const [policy, providers] = await Promise.all([ + getInstanceAuthPolicy(ctx.db), + ctx.db.ssoProvider.findMany({ + where: { archivedAt: null }, + select: { id: true, type: true, enabled: true, archivedAt: true }, + }), + ]); + const nextProviders = providers.map((provider) => + provider.id === input.id ? { ...provider, enabled: input.enabled } : provider, + ); + const nextPolicy = { + ...policy, + autoRedirectProviderId: + !input.enabled && policy.autoRedirectProviderId === input.id + ? null + : policy.autoRedirectProviderId, + }; + validateAuthPolicyTransition(nextPolicy, nextProviders, { + breakGlassConfigured: Boolean(process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD), + }); + await assertAdminRecoveryPath(ctx.db, nextPolicy, nextProviders); + await ctx.db.$transaction(async (tx) => { + await tx.ssoProvider.update({ where: { id: input.id }, data: { enabled: input.enabled } }); + if (nextPolicy.autoRedirectProviderId !== policy.autoRedirectProviderId) { + await tx.instanceAuthPolicy.update({ + where: { id: policy.id }, + data: { autoRedirectProviderId: null }, + }); + } + await tx.instanceAuditLog.create({ + data: { + actorId: ctx.session.user.id, + action: input.enabled ? "SSO_PROVIDER_ENABLED" : "SSO_PROVIDER_DISABLED", + metadata: { providerId: input.id }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }); }); bustSsoCache(); return { ok: true }; @@ -141,7 +285,46 @@ export const ssoRouter = router({ remove: instanceAdminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - await ctx.db.ssoProvider.delete({ where: { id: input.id } }); + const [policy, providers] = await Promise.all([ + getInstanceAuthPolicy(ctx.db), + ctx.db.ssoProvider.findMany({ + where: { archivedAt: null }, + select: { id: true, type: true, enabled: true, archivedAt: true }, + }), + ]); + const nextProviders = providers + .filter((provider) => provider.id !== input.id) + .map((provider) => ({ ...provider })); + const nextPolicy = { + ...policy, + autoRedirectProviderId: + policy.autoRedirectProviderId === input.id ? null : policy.autoRedirectProviderId, + }; + validateAuthPolicyTransition(nextPolicy, nextProviders, { + breakGlassConfigured: Boolean(process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD), + }); + await assertAdminRecoveryPath(ctx.db, nextPolicy, nextProviders); + await ctx.db.$transaction(async (tx) => { + await tx.ssoProvider.update({ + where: { id: input.id }, + data: { enabled: false, archivedAt: new Date() }, + }); + if (nextPolicy.autoRedirectProviderId !== policy.autoRedirectProviderId) { + await tx.instanceAuthPolicy.update({ + where: { id: policy.id }, + data: { autoRedirectProviderId: null }, + }); + } + await tx.instanceAuditLog.create({ + data: { + actorId: ctx.session.user.id, + action: "SSO_PROVIDER_ARCHIVED", + metadata: { providerId: input.id }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }); + }); bustSsoCache(); return { ok: true }; }), @@ -162,7 +345,8 @@ export const ssoRouter = router({ return { ok: false as const, error: `Discovery returned HTTP ${res.status}.` }; } const doc = (await res.json()) as Record; - const authz = typeof doc.authorization_endpoint === "string" ? doc.authorization_endpoint : null; + const authz = + typeof doc.authorization_endpoint === "string" ? doc.authorization_endpoint : null; const token = typeof doc.token_endpoint === "string" ? doc.token_endpoint : null; if (!authz || !token) { return { ok: false as const, error: "Discovery doc is missing required endpoints." }; @@ -176,7 +360,8 @@ export const ssoRouter = router({ } catch (err) { return { ok: false as const, - error: err instanceof Error ? `Could not reach issuer: ${err.message}` : "Discovery failed.", + error: + err instanceof Error ? `Could not reach issuer: ${err.message}` : "Discovery failed.", }; } }), diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index c7a961d1..61f26982 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -2,6 +2,10 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc"; import { refreshTodayZone } from "@/server/services/today-zone"; +import { getInstanceAuthPolicy } from "@/server/services/auth-policy"; +import { hashPassword, verifyPassword } from "@/server/services/local-credentials"; +import { sendPasswordChangedEmail } from "@/server/services/email"; +import { providerIdFor } from "@/server/sso"; /** * Account-level user router. @@ -20,7 +24,10 @@ const ME_SELECT = { id: true, name: true, email: true, + emailVerified: true, image: true, + status: true, + lastLoginAt: true, theme: true, timezone: true, locale: true, @@ -65,6 +72,12 @@ const DASHBOARD_PREFS = z.object({ // accept arbitrary step ids from the wire. const SKIPPABLE_STEP = z.enum(["member"]); +function usableProviderKeys( + providers: Array<{ id: string; type: "OIDC" | "GITHUB" | "GOOGLE" }>, +): Set { + return new Set(providers.map((provider) => providerIdFor(provider))); +} + export const userRouter = router({ me: protectedProcedure.query(async ({ ctx }) => { return ctx.db.user.findUniqueOrThrow({ @@ -94,6 +107,229 @@ export const userRouter = router({ }); }), + security: protectedProcedure.query(async ({ ctx }) => { + const [user, policy, providers] = await Promise.all([ + ctx.db.user.findUniqueOrThrow({ + where: { id: ctx.session.user.id }, + select: { + id: true, + email: true, + emailVerified: true, + status: true, + authVersion: true, + lastLoginAt: true, + localCredential: { + select: { + passwordChangedAt: true, + mustChangePassword: true, + lastUsedAt: true, + lockedUntil: true, + }, + }, + accounts: { + select: { id: true, provider: true, providerAccountId: true, type: true }, + orderBy: { provider: "asc" }, + }, + }, + }), + getInstanceAuthPolicy(ctx.db), + ctx.db.ssoProvider.findMany({ + where: { enabled: true, archivedAt: null }, + select: { id: true, type: true, name: true }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + }), + ]); + return { user, policy, providers }; + }), + + setPassword: protectedProcedure + .input( + z.object({ + currentPassword: z.string().max(4096).optional(), + newPassword: z.string().min(8).max(4096), + }), + ) + .mutation(async ({ ctx, input }) => { + const [policy, user] = await Promise.all([ + getInstanceAuthPolicy(ctx.db), + ctx.db.user.findUniqueOrThrow({ + where: { id: ctx.session.user.id }, + include: { localCredential: true }, + }), + ]); + if (input.newPassword.length < policy.passwordMinLength) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Password must be at least ${policy.passwordMinLength} characters.`, + }); + } + if (user.localCredential) { + if (!input.currentPassword) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Current password is required." }); + } + if (!(await verifyPassword(input.currentPassword, user.localCredential.passwordHash))) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Current password is incorrect." }); + } + } + const passwordHash = await hashPassword(input.newPassword); + const changedAt = new Date(); + await ctx.db.$transaction(async (tx) => { + await tx.localCredential.upsert({ + where: { userId: user.id }, + update: { + passwordHash, + passwordChangedAt: changedAt, + mustChangePassword: false, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + }, + create: { userId: user.id, passwordHash, passwordChangedAt: changedAt }, + }); + await tx.user.update({ + where: { id: user.id }, + data: { authVersion: { increment: 1 }, status: "ACTIVE", disabledAt: null }, + }); + await tx.session.deleteMany({ where: { userId: user.id } }); + await tx.userActionToken.updateMany({ + where: { userId: user.id, usedAt: null }, + data: { usedAt: changedAt }, + }); + await tx.instanceAuditLog.create({ + data: { + actorId: user.id, + targetUserId: user.id, + action: user.localCredential ? "PASSWORD_CHANGED" : "PASSWORD_ADDED", + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }); + }); + void sendPasswordChangedEmail({ + to: user.email, + name: user.name, + changedAt, + }).catch(() => undefined); + return { ok: true, sessionsRevoked: true }; + }), + + removePassword: protectedProcedure + .input(z.object({ currentPassword: z.string().min(1).max(4096) })) + .mutation(async ({ ctx, input }) => { + const [policy, user, providers] = await Promise.all([ + getInstanceAuthPolicy(ctx.db), + ctx.db.user.findUniqueOrThrow({ + where: { id: ctx.session.user.id }, + include: { + localCredential: true, + accounts: { select: { id: true, provider: true } }, + }, + }), + ctx.db.ssoProvider.findMany({ + where: { enabled: true, archivedAt: null }, + select: { id: true, type: true }, + }), + ]); + if (policy.mode === "LOCAL_ONLY") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "A password is required while this instance uses local-only authentication.", + }); + } + if (!user.localCredential) return { ok: true }; + const enabledProviders = usableProviderKeys(providers); + if (!user.accounts.some((account) => enabledProviders.has(account.provider))) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Link an enabled external sign-in method before removing your password.", + }); + } + if (!(await verifyPassword(input.currentPassword, user.localCredential.passwordHash))) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Current password is incorrect." }); + } + await ctx.db.$transaction(async (tx) => { + await tx.localCredential.delete({ where: { userId: user.id } }); + await tx.user.update({ where: { id: user.id }, data: { authVersion: { increment: 1 } } }); + await tx.session.deleteMany({ where: { userId: user.id } }); + await tx.instanceAuditLog.create({ + data: { + actorId: user.id, + targetUserId: user.id, + action: "PASSWORD_REMOVED", + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }); + }); + return { ok: true, sessionsRevoked: true }; + }), + + unlinkIdentity: protectedProcedure + .input(z.object({ accountId: z.string().cuid() })) + .mutation(async ({ ctx, input }) => { + const [policy, user, providers] = await Promise.all([ + getInstanceAuthPolicy(ctx.db), + ctx.db.user.findUniqueOrThrow({ + where: { id: ctx.session.user.id }, + include: { localCredential: { select: { userId: true } }, accounts: true }, + }), + ctx.db.ssoProvider.findMany({ + where: { enabled: true, archivedAt: null }, + select: { id: true, type: true }, + }), + ]); + const account = user.accounts.find((candidate) => candidate.id === input.accountId); + if (!account) throw new TRPCError({ code: "NOT_FOUND" }); + const enabledProviders = usableProviderKeys(providers); + const remainingExternal = user.accounts.filter( + (candidate) => candidate.id !== account.id && enabledProviders.has(candidate.provider), + ).length; + const remainingMethods = + (user.localCredential && policy.mode !== "EXTERNAL_ONLY" ? 1 : 0) + + (policy.mode !== "LOCAL_ONLY" ? remainingExternal : 0); + if (remainingMethods < 1) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "You cannot remove your final sign-in method.", + }); + } + await ctx.db.$transaction(async (tx) => { + await tx.account.delete({ where: { id: account.id } }); + await tx.user.update({ where: { id: user.id }, data: { authVersion: { increment: 1 } } }); + await tx.session.deleteMany({ where: { userId: user.id } }); + await tx.instanceAuditLog.create({ + data: { + actorId: user.id, + targetUserId: user.id, + action: "LOGIN_IDENTITY_UNLINKED", + metadata: { provider: account.provider }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }); + }); + return { ok: true, sessionsRevoked: true }; + }), + + revokeSessions: protectedProcedure.mutation(async ({ ctx }) => { + const user = await ctx.db.user.update({ + where: { id: ctx.session.user.id }, + data: { authVersion: { increment: 1 } }, + select: { id: true, authVersion: true }, + }); + await ctx.db.session.deleteMany({ where: { userId: user.id } }); + await ctx.db.instanceAuditLog.create({ + data: { + actorId: user.id, + targetUserId: user.id, + action: "SESSIONS_REVOKED", + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }); + return { ok: true, authVersion: user.authVersion }; + }), + setDashboardView: protectedProcedure .input(z.object({ view: z.enum(["list", "canvas"]).nullable() })) .mutation(async ({ ctx, input }) => { diff --git a/src/server/services/__tests__/local-invitation-registration.test.ts b/src/server/services/__tests__/local-invitation-registration.test.ts new file mode 100644 index 00000000..35912090 --- /dev/null +++ b/src/server/services/__tests__/local-invitation-registration.test.ts @@ -0,0 +1,96 @@ +import { InvitationStatus, Role } from "@prisma/client"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; +import { verifyPassword } from "@/server/services/local-credentials"; +import { + invitationTokenHash, + newInvitationToken, + registerLocalAccountFromInvitation, +} from "@/server/services/workspace-invitations"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => disconnectPrisma()); + +async function invitation(email: string) { + const fixture = await createWorkspaceFixture({ keyPrefix: "LI" }); + fixtures.push(fixture); + const token = newInvitationToken(); + const row = await getPrisma().workspaceInvitation.create({ + data: { + workspaceId: fixture.workspace.id, + email, + role: Role.MEMBER, + tokenHash: invitationTokenHash(token), + invitedById: fixture.user.id, + expiresAt: new Date(Date.now() + 60 * 60_000), + }, + }); + return { fixture, token, row }; +} + +describe("local account registration from workspace invitations", () => { + it("creates one canonical user, password, membership, and consumes the invite atomically", async () => { + const email = `local-invite-${Date.now()}@example.com`; + const { fixture, token, row } = await invitation(email); + const password = "workspace invitation local password"; + + await expect( + registerLocalAccountFromInvitation({ token, name: "Local Invite", password }), + ).resolves.toMatchObject({ state: "CREATED", workspaceSlug: fixture.workspace.slug }); + + const user = await getPrisma().user.findUniqueOrThrow({ + where: { normalizedEmail: email }, + include: { localCredential: true, memberships: true }, + }); + expect(user.emailVerified).toBeTruthy(); + expect(user.memberships).toEqual( + expect.arrayContaining([ + expect.objectContaining({ workspaceId: fixture.workspace.id, role: Role.MEMBER }), + ]), + ); + await expect(verifyPassword(password, user.localCredential!.passwordHash)).resolves.toBe(true); + await expect( + getPrisma().workspaceInvitation.findUniqueOrThrow({ where: { id: row.id } }), + ).resolves.toMatchObject({ status: InvitationStatus.ACCEPTED, acceptedById: user.id }); + }); + + it("does not add a password or consume an invitation for an existing account", async () => { + const { fixture, token, row } = await invitation(fixtureEmail()); + const existing = await getPrisma().user.update({ + where: { id: fixture.secondUser.id }, + data: { normalizedEmail: fixture.secondUser.email.toLowerCase() }, + }); + await getPrisma().workspaceInvitation.update({ + where: { id: row.id }, + data: { email: existing.email.toLowerCase() }, + }); + + await expect( + registerLocalAccountFromInvitation({ + token, + name: "Collision", + password: "should never become a credential", + }), + ).resolves.toMatchObject({ state: "EXISTING_ACCOUNT" }); + await expect( + getPrisma().localCredential.findUnique({ where: { userId: existing.id } }), + ).resolves.toBeNull(); + await expect( + getPrisma().workspaceInvitation.findUniqueOrThrow({ where: { id: row.id } }), + ).resolves.toMatchObject({ status: InvitationStatus.PENDING, acceptedById: null }); + }); +}); + +function fixtureEmail(): string { + return `placeholder-${Date.now()}@example.com`; +} diff --git a/src/server/services/__tests__/user-avatar.test.ts b/src/server/services/__tests__/user-avatar.test.ts new file mode 100644 index 00000000..14758b0d --- /dev/null +++ b/src/server/services/__tests__/user-avatar.test.ts @@ -0,0 +1,121 @@ +import { afterAll, afterEach, beforeAll, expect, it } from "vitest"; +import { + finalizeUserAvatar, + getUserAvatarState, + presignUserAvatarUpload, + readUserAvatar, + removeUserAvatar, +} from "@/server/services/user-avatar"; +import { _resetS3ClientForTests } from "@/server/services/storage"; +import { + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; +import { describeIfMinio } from "@/server/routers/__tests__/minio-probe"; + +const fixtures: TestFixture[] = []; +const { describe } = await describeIfMinio(); + +beforeAll(() => { + process.env.S3_ENDPOINT = process.env.S3_ENDPOINT ?? "http://localhost:9000"; + process.env.S3_REGION = "us-east-1"; + process.env.S3_ACCESS_KEY = process.env.S3_ACCESS_KEY ?? "forge_minio_admin"; + process.env.S3_SECRET_KEY = + process.env.S3_SECRET_KEY ?? "c3ac4bd95c05c7d809f9b0e97a800d6d4c60f06c"; + process.env.S3_FORCE_PATH_STYLE = "true"; + process.env.S3_GLOBAL_BUCKET = "forge-test-global"; + _resetS3ClientForTests(); +}); + +afterEach(async () => { + while (fixtures.length > 0) { + const fixture = fixtures.pop()!; + await removeUserAvatar(fixture.user.id).catch(() => undefined); + await removeUserAvatar(fixture.secondUser.id).catch(() => undefined); + await fixture.cleanup(); + } +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +async function fixture(): Promise { + const next = await createWorkspaceFixture({ keyPrefix: "AV" }); + fixtures.push(next); + return next; +} + +async function put(url: string, bytes: Uint8Array, contentType: string): Promise { + const response = await fetch(url, { + method: "PUT", + headers: { "content-type": contentType }, + body: Buffer.from(bytes), + }); + if (!response.ok) + throw new Error(`Avatar PUT failed: ${response.status} ${await response.text()}`); +} + +describe("user avatar service", () => { + it("uploads, validates, serves, and removes a global user avatar", async () => { + const current = await fixture(); + const providerImage = "https://avatars.example.com/provider-user.png"; + await getPrisma().user.update({ + where: { id: current.user.id }, + data: { image: providerImage }, + }); + const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const init = await presignUserAvatarUpload({ + userId: current.user.id, + contentType: "image/png", + sizeBytes: bytes.byteLength, + }); + expect(init.objectKey).toMatch(new RegExp(`^avatar-uploads/${current.user.id}/`)); + await put(init.uploadUrl, bytes, init.headers["Content-Type"]); + + const finalized = await finalizeUserAvatar({ + userId: current.user.id, + objectKey: init.objectKey, + }); + expect(finalized.url).toContain(`/api/avatar/${current.user.id}`); + + const state = await getUserAvatarState(current.user.id); + expect(state.hasLocalAvatar).toBe(true); + expect(state.resolvedImage).toBe(state.avatarUrl); + expect(state.fallbackImage).toBe(providerImage); + await expect( + getPrisma().user.findUniqueOrThrow({ + where: { id: current.user.id }, + select: { image: true }, + }), + ).resolves.toEqual({ image: `/api/avatar/${current.user.id}` }); + const stored = await readUserAvatar(current.user.id); + expect(stored?.contentType).toBe("image/png"); + expect(stored?.bytes).toEqual(bytes); + + await expect(removeUserAvatar(current.user.id)).resolves.toEqual({ removed: true }); + const fallback = await getUserAvatarState(current.user.id); + expect(fallback.hasLocalAvatar).toBe(false); + expect(fallback.resolvedImage).toBe(providerImage); + }); + + it("rejects spoofed image content and a different user's upload key", async () => { + const current = await fixture(); + const spoof = Buffer.from("not really a png"); + const init = await presignUserAvatarUpload({ + userId: current.user.id, + contentType: "image/png", + sizeBytes: spoof.byteLength, + }); + await put(init.uploadUrl, spoof, init.headers["Content-Type"]); + + await expect( + finalizeUserAvatar({ userId: current.user.id, objectKey: init.objectKey }), + ).rejects.toThrow(/does not match/i); + await expect( + finalizeUserAvatar({ userId: current.secondUser.id, objectKey: init.objectKey }), + ).rejects.toThrow(/does not belong/i); + }); +}); diff --git a/src/server/services/__tests__/user-lifecycle.test.ts b/src/server/services/__tests__/user-lifecycle.test.ts new file mode 100644 index 00000000..a9eb77c1 --- /dev/null +++ b/src/server/services/__tests__/user-lifecycle.test.ts @@ -0,0 +1,296 @@ +import { + ConnectionProvider, + ConnectionStatus, + InstanceRole, + PluginScope, + Role, + UserActionTokenType, + UserStatus, +} from "@prisma/client"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { instanceAdminRouter } from "@/server/routers/instance-admin"; +import { + buildContext, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; +import { inspectUserActionToken } from "@/server/services/auth-tokens"; +import { verifyPassword } from "@/server/services/local-credentials"; +import { + completeAccountSetup, + createInvitedUser, + issueUserActionToken, + reactivateUser, + softDeleteUser, + suspendUser, +} from "@/server/services/user-lifecycle"; + +const fixtures: TestFixture[] = []; +const extraUserIds: string[] = []; + +afterEach(async () => { + const db = getPrisma(); + while (fixtures.length) await fixtures.pop()!.cleanup(); + if (extraUserIds.length) { + await db.user.deleteMany({ where: { id: { in: extraUserIds.splice(0) } } }).catch(() => {}); + } + vi.unstubAllEnvs(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +async function setupAdmin() { + const fixture = await createWorkspaceFixture({ keyPrefix: "UL" }); + fixtures.push(fixture); + await getPrisma().user.update({ + where: { id: fixture.user.id }, + data: { instanceRole: InstanceRole.INSTANCE_ADMIN }, + }); + return fixture; +} + +describe("user lifecycle", () => { + it("creates an invited principal and consumes a domain-separated setup token once", async () => { + const fixture = await setupAdmin(); + const db = getPrisma(); + const email = `invited-${Date.now()}@example.com`; + const invited = await createInvitedUser(db, { + actorId: fixture.user.id, + email, + name: "Invited User", + }); + extraUserIds.push(invited.user.id); + + await expect( + inspectUserActionToken({ + rawToken: invited.setupToken, + type: UserActionTokenType.ACCOUNT_SETUP, + }), + ).resolves.toMatchObject({ state: "VALID" }); + const stored = await db.userActionToken.findFirstOrThrow({ + where: { userId: invited.user.id, type: UserActionTokenType.ACCOUNT_SETUP }, + }); + expect(stored.tokenHash).not.toBe(invited.setupToken); + + const completed = await completeAccountSetup(db, { + token: invited.setupToken, + password: "correct horse battery staple", + name: "Activated User", + }); + expect(completed.authVersion).toBe(1); + + const user = await db.user.findUniqueOrThrow({ + where: { id: invited.user.id }, + include: { localCredential: true }, + }); + expect(user).toMatchObject({ + status: UserStatus.ACTIVE, + name: "Activated User", + authVersion: 1, + }); + expect(user.emailVerified).toBeTruthy(); + expect(user.localCredential).toBeTruthy(); + await expect( + verifyPassword("correct horse battery staple", user.localCredential!.passwordHash), + ).resolves.toBe(true); + await expect( + completeAccountSetup(db, { + token: invited.setupToken, + password: "another correct horse password", + name: "Replay", + }), + ).rejects.toThrow(/invalid or expired/i); + }); + + it("rotates outstanding action tokens of the same purpose", async () => { + const fixture = await setupAdmin(); + const db = getPrisma(); + const invited = await createInvitedUser(db, { + actorId: fixture.user.id, + email: `rotate-${Date.now()}@example.com`, + }); + extraUserIds.push(invited.user.id); + const next = await issueUserActionToken(db, { + actorId: fixture.user.id, + userId: invited.user.id, + type: UserActionTokenType.ACCOUNT_SETUP, + }); + + await expect( + inspectUserActionToken({ + rawToken: invited.setupToken, + type: UserActionTokenType.ACCOUNT_SETUP, + }), + ).resolves.toMatchObject({ state: "USED" }); + await expect( + inspectUserActionToken({ rawToken: next.token, type: UserActionTokenType.ACCOUNT_SETUP }), + ).resolves.toMatchObject({ state: "VALID" }); + }); + + it("blocks suspension of the last active instance admin and last workspace owner", async () => { + const fixture = await setupAdmin(); + const db = getPrisma(); + + await expect( + suspendUser(db, { actorId: fixture.user.id, userId: fixture.user.id }), + ).rejects.toThrow(/last active instance admin/i); + + await db.user.update({ + where: { id: fixture.secondUser.id }, + data: { instanceRole: InstanceRole.INSTANCE_ADMIN }, + }); + await expect( + suspendUser(db, { actorId: fixture.secondUser.id, userId: fixture.user.id }), + ).rejects.toThrow(/transfer ownership/i); + }); + + it("suspends access atomically and reactivation does not restore revoked credentials", async () => { + const fixture = await setupAdmin(); + const db = getPrisma(); + await db.user.update({ + where: { id: fixture.secondUser.id }, + data: { instanceRole: InstanceRole.INSTANCE_ADMIN }, + }); + await db.membership.update({ + where: { + userId_workspaceId: { userId: fixture.secondUser.id, workspaceId: fixture.workspace.id }, + }, + data: { role: Role.OWNER }, + }); + await db.session.create({ + data: { + userId: fixture.user.id, + sessionToken: `session-${Date.now()}`, + expires: new Date(Date.now() + 86_400_000), + }, + }); + const key = await db.apiKey.create({ + data: { + workspaceId: fixture.workspace.id, + userId: fixture.user.id, + name: "Lifecycle test", + hashedKey: `hash-${Date.now()}`, + prefix: "frg_test", + scopes: [PluginScope.READ_ISSUES], + }, + }); + const connection = await db.connection.create({ + data: { + ownerId: fixture.user.id, + provider: ConnectionProvider.GITHUB, + label: "Lifecycle GitHub", + status: ConnectionStatus.CONNECTED, + tokenEnc: "encrypted-test-token", + mappings: { + create: { + workspaceId: fixture.workspace.id, + kind: "repo", + target: "example/repo", + }, + }, + }, + include: { mappings: true }, + }); + + const suspended = await suspendUser(db, { + actorId: fixture.secondUser.id, + userId: fixture.user.id, + reason: "Security review", + }); + expect(suspended.status).toBe(UserStatus.SUSPENDED); + expect(suspended.revoked).toMatchObject({ + sessions: 1, + apiKeys: 1, + connections: 1, + mappings: 1, + }); + await expect(db.session.count({ where: { userId: fixture.user.id } })).resolves.toBe(0); + await expect(db.apiKey.findUniqueOrThrow({ where: { id: key.id } })).resolves.toMatchObject({ + revokedAt: expect.any(Date), + }); + await expect( + db.connection.findUniqueOrThrow({ where: { id: connection.id } }), + ).resolves.toMatchObject({ + status: ConnectionStatus.DISCONNECTED, + tokenEnc: null, + }); + await expect( + db.connectionMapping.findUniqueOrThrow({ where: { id: connection.mappings[0]!.id } }), + ).resolves.toMatchObject({ status: "paused" }); + + const active = await reactivateUser(db, { + actorId: fixture.secondUser.id, + userId: fixture.user.id, + }); + expect(active).toMatchObject({ status: UserStatus.ACTIVE, disabledAt: null, authVersion: 2 }); + await expect(db.apiKey.findUniqueOrThrow({ where: { id: key.id } })).resolves.toMatchObject({ + revokedAt: expect.any(Date), + }); + }); + + it("soft deletes to a tombstone while preserving the user row and audit target", async () => { + const fixture = await setupAdmin(); + const db = getPrisma(); + await db.localCredential.create({ + data: { userId: fixture.secondUser.id, passwordHash: "test-hash" }, + }); + await db.account.create({ + data: { + userId: fixture.secondUser.id, + type: "oidc", + provider: "test-provider", + providerAccountId: `subject-${Date.now()}`, + }, + }); + + const deleted = await softDeleteUser(db, { + actorId: fixture.user.id, + userId: fixture.secondUser.id, + reason: "Requested deletion", + }); + expect(deleted.status).toBe(UserStatus.DELETED); + expect(deleted.avatarCleanup).toEqual({ ok: true, removed: false }); + const tombstone = await db.user.findUniqueOrThrow({ where: { id: fixture.secondUser.id } }); + expect(tombstone.email).toBe(`deleted+${fixture.secondUser.id}@invalid.local`); + expect(tombstone).toMatchObject({ + name: "Deleted user", + handle: null, + image: null, + status: UserStatus.DELETED, + instanceRole: InstanceRole.MEMBER, + }); + await expect(db.membership.count({ where: { userId: fixture.secondUser.id } })).resolves.toBe( + 0, + ); + await expect( + db.localCredential.count({ where: { userId: fixture.secondUser.id } }), + ).resolves.toBe(0); + await expect(db.account.count({ where: { userId: fixture.secondUser.id } })).resolves.toBe(0); + await expect( + db.instanceAuditLog.findFirstOrThrow({ + where: { targetUserId: fixture.secondUser.id, action: "USER_SOFT_DELETED" }, + }), + ).resolves.toBeTruthy(); + }); + + it("admin delivery procedures never return raw setup tokens", async () => { + const fixture = await setupAdmin(); + vi.stubEnv("AUTH_URL", "https://forge.example"); + const caller = instanceAdminRouter.createCaller(await buildContext(fixture)); + const result = await caller.createUser({ + email: `router-invite-${Date.now()}@example.com`, + name: "Router Invite", + }); + extraUserIds.push(result.user.id); + expect(result).toMatchObject({ + user: { status: UserStatus.INVITED }, + delivery: { messageId: "test-account-setup", expiresAt: expect.any(Date) }, + }); + expect(JSON.stringify(result)).not.toContain("setupToken"); + expect(JSON.stringify(result)).not.toContain("tokenHash"); + }); +}); diff --git a/src/server/services/auth-policy.ts b/src/server/services/auth-policy.ts new file mode 100644 index 00000000..a7e4b624 --- /dev/null +++ b/src/server/services/auth-policy.ts @@ -0,0 +1,156 @@ +import "server-only"; +import type { + AuthenticationMode, + InstanceAuthPolicy, + RegistrationMode, + SsoProvider, +} from "@prisma/client"; +import { db } from "@/server/db"; + +export const INSTANCE_AUTH_POLICY_ID = "default"; + +export type AuthPolicyConfig = Pick< + InstanceAuthPolicy, + | "id" + | "mode" + | "registrationMode" + | "breakGlassCredentialsEnabled" + | "autoRedirectProviderId" + | "passwordMinLength" + | "passwordResetTtlMinutes" + | "lockoutThreshold" + | "lockoutMinutes" +>; + +export const DEFAULT_AUTH_POLICY: AuthPolicyConfig = { + id: INSTANCE_AUTH_POLICY_ID, + mode: "HYBRID", + registrationMode: "INVITE_ONLY", + breakGlassCredentialsEnabled: true, + autoRedirectProviderId: null, + passwordMinLength: 12, + passwordResetTtlMinutes: 30, + lockoutThreshold: 10, + lockoutMinutes: 15, +}; + +type PolicyDatabase = Pick; + +/** + * Resolve the singleton policy, self-healing an empty development/test DB. + * Production migrations seed this row, so the create branch is normally a + * no-op. + */ +export async function getInstanceAuthPolicy( + database: PolicyDatabase = db, +): Promise { + return database.instanceAuthPolicy.upsert({ + where: { id: INSTANCE_AUTH_POLICY_ID }, + update: {}, + create: DEFAULT_AUTH_POLICY, + }); +} + +export type AuthProviderSummary = Pick; + +export type AuthPresentation = { + mode: AuthenticationMode; + registrationMode: RegistrationMode; + localCredentialsEnabled: boolean; + externalProvidersEnabled: boolean; + breakGlassCredentialsEnabled: boolean; + providerSelectionEnabled: boolean; + autoRedirectProviderId: string | null; + enabledProviderIds: string[]; +}; + +function enabledProviderIds(providers: readonly AuthProviderSummary[]): string[] { + return providers.filter((provider) => provider.enabled && !provider.archivedAt).map((p) => p.id); +} + +/** Pure presentation derivation shared by the sign-in page and auth config. */ +export function deriveAuthPresentation( + policy: AuthPolicyConfig, + providers: readonly AuthProviderSummary[], +): AuthPresentation { + const availableProviderIds = enabledProviderIds(providers); + const localCredentialsEnabled = policy.mode !== "EXTERNAL_ONLY"; + const externalProvidersEnabled = policy.mode !== "LOCAL_ONLY" && availableProviderIds.length > 0; + const configuredRedirect = + externalProvidersEnabled && + policy.autoRedirectProviderId && + availableProviderIds.includes(policy.autoRedirectProviderId) + ? policy.autoRedirectProviderId + : null; + + return { + mode: policy.mode, + registrationMode: policy.registrationMode, + localCredentialsEnabled, + externalProvidersEnabled, + breakGlassCredentialsEnabled: policy.breakGlassCredentialsEnabled, + providerSelectionEnabled: externalProvidersEnabled && !configuredRedirect, + autoRedirectProviderId: configuredRedirect, + enabledProviderIds: availableProviderIds, + }; +} + +export class AuthPolicyValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthPolicyValidationError"; + } +} + +/** + * Validate a proposed policy before persistence. These checks prevent an + * operator from selecting a dead redirect or an external-only configuration + * with no usable provider. + */ +export function validateAuthPolicyTransition( + policy: AuthPolicyConfig, + providers: readonly AuthProviderSummary[], + options: { breakGlassConfigured?: boolean } = {}, +): AuthPresentation { + const availableProviderIds = enabledProviderIds(providers); + + if (policy.mode === "EXTERNAL_ONLY" && availableProviderIds.length === 0) { + throw new AuthPolicyValidationError( + "External-only authentication requires at least one enabled provider.", + ); + } + if (policy.mode === "LOCAL_ONLY" && policy.autoRedirectProviderId) { + throw new AuthPolicyValidationError( + "Local-only authentication cannot auto-redirect to an external provider.", + ); + } + if ( + policy.autoRedirectProviderId && + !availableProviderIds.includes(policy.autoRedirectProviderId) + ) { + throw new AuthPolicyValidationError( + "The automatic redirect provider must be enabled and not archived.", + ); + } + if (policy.breakGlassCredentialsEnabled && options.breakGlassConfigured === false) { + throw new AuthPolicyValidationError( + "Break-glass credentials cannot be enabled until an operator credential is configured.", + ); + } + if (policy.passwordMinLength < 8 || policy.passwordMinLength > 128) { + throw new AuthPolicyValidationError("Password minimum length must be between 8 and 128."); + } + if (policy.passwordResetTtlMinutes < 5 || policy.passwordResetTtlMinutes > 1440) { + throw new AuthPolicyValidationError( + "Password reset expiry must be between 5 minutes and 24 hours.", + ); + } + if (policy.lockoutThreshold < 3 || policy.lockoutThreshold > 100) { + throw new AuthPolicyValidationError("Lockout threshold must be between 3 and 100."); + } + if (policy.lockoutMinutes < 1 || policy.lockoutMinutes > 1440) { + throw new AuthPolicyValidationError("Lockout duration must be between 1 minute and 24 hours."); + } + + return deriveAuthPresentation(policy, providers); +} diff --git a/src/server/services/auth-tokens.ts b/src/server/services/auth-tokens.ts new file mode 100644 index 00000000..15bb5955 --- /dev/null +++ b/src/server/services/auth-tokens.ts @@ -0,0 +1,156 @@ +import "server-only"; +import { createHash, randomBytes } from "node:crypto"; +import type { Prisma, UserActionToken, UserActionTokenType } from "@prisma/client"; +import { db } from "@/server/db"; + +const TOKEN_BYTES = 32; +const TOKEN_HASH_DOMAIN = "forge:user-action:v1:"; +const MAX_TOKEN_TTL_MINUTES = 30 * 24 * 60; + +type ActionTokenDatabase = Pick; +type TransactionDatabase = Pick; + +export function normalizeAuthEmail(email: string): string { + return email.trim().toLowerCase(); +} + +/** Hash a raw bearer token with a purpose-specific domain separator. */ +export function hashUserActionToken(rawToken: string): string { + return createHash("sha256").update(TOKEN_HASH_DOMAIN).update(rawToken).digest("hex"); +} + +/** Generate the bearer value returned once and the digest persisted in DB. */ +export function newUserActionToken(): { rawToken: string; tokenHash: string } { + const rawToken = randomBytes(TOKEN_BYTES).toString("base64url"); + return { rawToken, tokenHash: hashUserActionToken(rawToken) }; +} + +function expiresAfter(ttlMinutes: number, now: Date): Date { + if (!Number.isInteger(ttlMinutes) || ttlMinutes < 1 || ttlMinutes > MAX_TOKEN_TTL_MINUTES) { + throw new Error("Action token expiry must be between 1 minute and 30 days."); + } + return new Date(now.getTime() + ttlMinutes * 60_000); +} + +export type IssueUserActionTokenInput = { + userId: string; + type: UserActionTokenType; + emailSnapshot: string; + ttlMinutes: number; + now?: Date; +}; + +/** + * Rotate outstanding tokens of the same type, then return the new raw token + * once. Rotation and creation share a transaction when called with the root DB. + */ +export async function issueUserActionToken( + input: IssueUserActionTokenInput, + database: ActionTokenDatabase | typeof db = db, +): Promise<{ rawToken: string; token: UserActionToken }> { + const now = input.now ?? new Date(); + const expiresAt = expiresAfter(input.ttlMinutes, now); + const generated = newUserActionToken(); + + const issue = async (tx: ActionTokenDatabase) => { + await tx.userActionToken.updateMany({ + where: { userId: input.userId, type: input.type, usedAt: null }, + data: { usedAt: now }, + }); + return tx.userActionToken.create({ + data: { + userId: input.userId, + type: input.type, + tokenHash: generated.tokenHash, + emailSnapshot: normalizeAuthEmail(input.emailSnapshot), + expiresAt, + createdAt: now, + }, + }); + }; + + const token = + "$transaction" in database + ? await (database as typeof db).$transaction((tx) => issue(tx)) + : await issue(database as ActionTokenDatabase); + return { rawToken: generated.rawToken, token }; +} + +const TOKEN_USER_SELECT = { + id: true, + email: true, + normalizedEmail: true, + status: true, + authVersion: true, +} as const; + +export type UserActionTokenInspection = + | { state: "INVALID" | "EXPIRED" | "USED" } + | { + state: "VALID"; + token: UserActionToken & { + user: { + id: string; + email: string; + normalizedEmail: string | null; + status: "INVITED" | "ACTIVE" | "SUSPENDED" | "DELETED"; + authVersion: number; + }; + }; + }; + +/** Read-only token inspection for rendering setup/reset pages. */ +export async function inspectUserActionToken( + input: { rawToken: string; type: UserActionTokenType; now?: Date }, + database: ActionTokenDatabase = db, +): Promise { + const token = await database.userActionToken.findUnique({ + where: { tokenHash: hashUserActionToken(input.rawToken) }, + include: { user: { select: TOKEN_USER_SELECT } }, + }); + if (!token || token.type !== input.type) return { state: "INVALID" }; + if (token.usedAt) return { state: "USED" }; + if (token.expiresAt <= (input.now ?? new Date())) return { state: "EXPIRED" }; + return { state: "VALID", token }; +} + +export type ConsumeUserActionTokenResult = + | { state: "INVALID" | "EXPIRED" | "USED" } + | { state: "CONSUMED"; token: UserActionToken; value: T }; + +/** + * Claim a token with a conditional write. An optional mutation runs inside the + * same transaction, so password/account changes roll back together with the + * claim when they fail. + */ +export async function consumeUserActionToken( + input: { rawToken: string; type: UserActionTokenType; now?: Date }, + onConsume?: (tx: Prisma.TransactionClient, token: UserActionToken) => Promise, + database: TransactionDatabase = db, +): Promise> { + const now = input.now ?? new Date(); + const tokenHash = hashUserActionToken(input.rawToken); + + return database.$transaction(async (tx) => { + const token = await tx.userActionToken.findUnique({ where: { tokenHash } }); + if (!token || token.type !== input.type) return { state: "INVALID" as const }; + if (token.usedAt) return { state: "USED" as const }; + if (token.expiresAt <= now) return { state: "EXPIRED" as const }; + + const claimed = await tx.userActionToken.updateMany({ + where: { + id: token.id, + type: input.type, + tokenHash, + usedAt: null, + expiresAt: { gt: now }, + }, + data: { usedAt: now }, + }); + if (claimed.count !== 1) return { state: "USED" as const }; + + const consumed = { ...token, usedAt: now }; + const value = onConsume ? await onConsume(tx, consumed) : undefined; + return { state: "CONSUMED" as const, token: consumed, value }; + }); +} diff --git a/src/server/services/authorization.ts b/src/server/services/authorization.ts new file mode 100644 index 00000000..622c0901 --- /dev/null +++ b/src/server/services/authorization.ts @@ -0,0 +1,117 @@ +import type { Role } from "@prisma/client"; +import { TRPCError } from "@trpc/server"; + +/** + * Workspace actions that can be decided from Membership.role alone. + * + * Resource-specific authorization (restricted projects and external + * integrations) is evaluated separately below. Keeping those decisions out of + * tRPC procedure selection prevents a membership check from being mistaken for + * permission to mutate every resource in a workspace. + */ +export type WorkspaceAction = + | "READ_WORKSPACE" + | "CREATE_PROJECT" + | "MUTATE_PROJECT" + | "MANAGE_WORKSPACE"; + +const WORKSPACE_ACTION_ROLES: Record = { + READ_WORKSPACE: ["OWNER", "ADMIN", "MEMBER", "GUEST"], + CREATE_PROJECT: ["OWNER", "ADMIN", "MEMBER"], + MUTATE_PROJECT: ["OWNER", "ADMIN", "MEMBER"], + MANAGE_WORKSPACE: ["OWNER", "ADMIN"], +}; + +export function isWorkspaceAdmin(role: Role): boolean { + return role === "OWNER" || role === "ADMIN"; +} + +export function canPerformWorkspaceAction(role: Role, action: WorkspaceAction): boolean { + return WORKSPACE_ACTION_ROLES[action].includes(role); +} + +export function assertWorkspaceAction(role: Role, action: WorkspaceAction): void { + if (!canPerformWorkspaceAction(role, action)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Your workspace role does not permit this action.", + }); + } +} + +/** + * Schema-independent policy inputs for the future ProjectAccess model. + * These string unions deliberately do not claim that persistence exists yet; + * callers pass the resolved grant (or null) after loading it. + */ +export type ProjectVisibility = "WORKSPACE" | "RESTRICTED"; +export type ProjectAccessRole = "VIEWER" | "CONTRIBUTOR" | "MANAGER"; +export type ProjectAction = "READ" | "CONTRIBUTE" | "MANAGE"; + +const PROJECT_ROLE_RANK: Record = { + VIEWER: 1, + CONTRIBUTOR: 2, + MANAGER: 3, +}; + +const PROJECT_ACTION_RANK: Record = { + READ: 1, + CONTRIBUTE: 2, + MANAGE: 3, +}; + +export function canPerformProjectAction(params: { + membershipRole: Role; + visibility: ProjectVisibility; + accessRole: ProjectAccessRole | null; + action: ProjectAction; +}): boolean { + if (isWorkspaceAdmin(params.membershipRole)) return true; + + if (params.accessRole) { + return PROJECT_ROLE_RANK[params.accessRole] >= PROJECT_ACTION_RANK[params.action]; + } + + // Members retain the existing collaborative behavior for workspace-visible + // projects. Guests never inherit project access, and restricted projects + // always require an explicit grant. + return ( + params.membershipRole === "MEMBER" && + params.visibility === "WORKSPACE" && + params.action !== "MANAGE" + ); +} + +/** + * External credentials are a separate authorization layer. A Forge role or + * project grant never implies permission to use a GitHub/OAuth credential; + * the resolved integration grant must explicitly contain the capability too. + */ +export type IntegrationCapability = "READ" | "IMPORT" | "LINK" | "SYNC" | "WRITE" | "ADMIN"; +export type IntegrationAction = IntegrationCapability; + +const INTEGRATION_PROJECT_ACTION: Record = { + READ: "READ", + IMPORT: "CONTRIBUTE", + LINK: "CONTRIBUTE", + SYNC: "CONTRIBUTE", + WRITE: "CONTRIBUTE", + ADMIN: "MANAGE", +}; + +export function canPerformIntegrationAction(params: { + membershipRole: Role; + projectVisibility: ProjectVisibility; + projectAccessRole: ProjectAccessRole | null; + grantedCapabilities: readonly IntegrationCapability[]; + action: IntegrationAction; +}): boolean { + if (!params.grantedCapabilities.includes(params.action)) return false; + + return canPerformProjectAction({ + membershipRole: params.membershipRole, + visibility: params.projectVisibility, + accessRole: params.projectAccessRole, + action: INTEGRATION_PROJECT_ACTION[params.action], + }); +} diff --git a/src/server/services/email.ts b/src/server/services/email.ts index dbfbe877..4d4df8a1 100644 --- a/src/server/services/email.ts +++ b/src/server/services/email.ts @@ -1,6 +1,27 @@ import "server-only"; import nodemailer from "nodemailer"; +export type TransactionalEmailKind = + | "workspace-invite" + | "account-setup" + | "password-reset" + | "password-changed"; + +export type TransactionalEmail = { + kind: TransactionalEmailKind; + to: string; + subject: string; + text: string; + html: string; +}; + +export type TransactionalEmailTransport = (message: TransactionalEmail) => Promise; + +export type EmailDeliveryOptions = { + /** Narrow injection seam for template/delivery tests; production omits it. */ + transport?: TransactionalEmailTransport; +}; + export type WorkspaceInviteEmail = { to: string; inviteUrl: string; @@ -10,6 +31,26 @@ export type WorkspaceInviteEmail = { note?: string | null; }; +export type AccountSetupEmail = { + to: string; + url: string; + expiresAt: Date; + name?: string | null; +}; + +export type PasswordResetEmail = { + to: string; + url: string; + expiresAt: Date; + name?: string | null; +}; + +export type PasswordChangedEmail = { + to: string; + changedAt: Date; + name?: string | null; +}; + function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => { const entities: Record = { @@ -23,6 +64,10 @@ function escapeHtml(value: string): string { }); } +function headerText(value: string): string { + return value.replace(/[\r\n]+/g, " ").trim(); +} + function fromAddress(): string { return process.env.EMAIL_FROM?.trim() || "Forge "; } @@ -48,42 +93,39 @@ function smtpTransport() { }); } +function testFailureMessage(kind: TransactionalEmailKind): string { + return kind === "workspace-invite" + ? "Forced invitation delivery failure." + : `Forced ${kind.replaceAll("-", " ")} delivery failure.`; +} + /** - * Deliver a workspace invitation through SMTP or Resend. Tests use a - * deterministic no-network transport; production fails closed when no provider - * is configured so the UI never claims an email was sent when it was not. + * Deliver one fully rendered transactional email through an injected test + * transport, SMTP, or Resend. Test mode is deterministic and network-free; + * non-test environments fail closed when no provider is configured. */ -export async function sendWorkspaceInviteEmail(input: WorkspaceInviteEmail): Promise { - if (process.env.NODE_ENV === "test") { +export async function sendTransactionalEmail( + message: TransactionalEmail, + options: EmailDeliveryOptions = {}, +): Promise { + if (options.transport) return options.transport(message); + + if (process.env.NODE_ENV === "test" || process.env.FORGE_E2E === "1") { if (process.env.FORGE_EMAIL_TEST_FAILURE === "1") { - throw new Error("Forced invitation delivery failure."); + throw new Error(testFailureMessage(message.kind)); } - return "test-workspace-invite"; + return `test-${message.kind}`; } - const subject = `${input.inviterName} invited you to ${input.workspaceName} on Forge`; - const note = input.note?.trim(); - const text = [ - `${input.inviterName} invited you to join ${input.workspaceName} on Forge.`, - note ? `\n${note}` : "", - `\nAccept the invitation: ${input.inviteUrl}`, - `\nThis secure link expires ${input.expiresAt.toISOString()}.`, - "If you already use Forge, sign in with that account. Otherwise, continue with your configured identity provider to create an account.", - ] - .filter(Boolean) - .join("\n"); - const html = ` -
-

Join ${escapeHtml(input.workspaceName)} on Forge

-

${escapeHtml(input.inviterName)} invited you to this workspace.

- ${note ? `
${escapeHtml(note).replace(/\n/g, "
")}
` : ""} -

Accept invitation

-

This single-use link expires ${escapeHtml(input.expiresAt.toUTCString())}. Sign in with an existing Forge account, or continue with your configured identity provider to create one.

-
`; - const smtp = smtpTransport(); if (smtp) { - const result = await smtp.sendMail({ from: fromAddress(), to: input.to, subject, text, html }); + const result = await smtp.sendMail({ + from: fromAddress(), + to: message.to, + subject: message.subject, + text: message.text, + html: message.html, + }); return result.messageId; } @@ -95,10 +137,16 @@ export async function sendWorkspaceInviteEmail(input: WorkspaceInviteEmail): Pro Authorization: `Bearer ${resendKey}`, "Content-Type": "application/json", }, - body: JSON.stringify({ from: fromAddress(), to: [input.to], subject, text, html }), + body: JSON.stringify({ + from: fromAddress(), + to: [message.to], + subject: message.subject, + text: message.text, + html: message.html, + }), }); if (!response.ok) { - throw new Error(`Resend rejected the invitation email (${response.status}).`); + throw new Error(`Resend rejected the email (${response.status}).`); } const body = (await response.json()) as { id?: string }; return body.id ?? "resend-accepted"; @@ -108,3 +156,115 @@ export async function sendWorkspaceInviteEmail(input: WorkspaceInviteEmail): Pro "Outgoing email is not configured. Set EMAIL_SERVER or SMTP_HOST (and SMTP credentials), or RESEND_API_KEY, plus EMAIL_FROM.", ); } + +export function buildWorkspaceInviteEmail(input: WorkspaceInviteEmail): TransactionalEmail { + const note = input.note?.trim(); + const subject = `${headerText(input.inviterName)} invited you to ${headerText(input.workspaceName)} on Forge`; + const text = [ + `${input.inviterName} invited you to join ${input.workspaceName} on Forge.`, + note ? `\n${note}` : "", + `\nAccept the invitation: ${input.inviteUrl}`, + `\nThis secure link expires ${input.expiresAt.toISOString()}.`, + "If you already use Forge, sign in with that account. Otherwise, use an identity method enabled by the instance administrator.", + ] + .filter(Boolean) + .join("\n"); + const html = ` +
+

Join ${escapeHtml(input.workspaceName)} on Forge

+

${escapeHtml(input.inviterName)} invited you to this workspace.

+ ${note ? `
${escapeHtml(note).replace(/\n/g, "
")}
` : ""} +

Accept invitation

+

This single-use link expires ${escapeHtml(input.expiresAt.toUTCString())}. Sign in with an existing Forge account, or use an identity method enabled by the instance administrator.

+
`; + return { kind: "workspace-invite", to: input.to, subject, text, html }; +} + +export function buildAccountSetupEmail(input: AccountSetupEmail): TransactionalEmail { + const greeting = input.name?.trim() ? `Hi ${input.name.trim()},` : "Hello,"; + const subject = "Set up your Forge account"; + const text = [ + greeting, + "An instance administrator created a Forge account for this email address.", + `Set your password: ${input.url}`, + `This single-use link expires ${input.expiresAt.toISOString()}.`, + "If you were not expecting this invitation, you can ignore this email.", + ].join("\n\n"); + const html = ` +
+

Set up your Forge account

+

${escapeHtml(greeting)}

+

An instance administrator created a Forge account for this email address.

+

Set password

+

This single-use link expires ${escapeHtml(input.expiresAt.toUTCString())}. If you were not expecting this invitation, you can ignore this email.

+
`; + return { kind: "account-setup", to: input.to, subject, text, html }; +} + +export function buildPasswordResetEmail(input: PasswordResetEmail): TransactionalEmail { + const greeting = input.name?.trim() ? `Hi ${input.name.trim()},` : "Hello,"; + const subject = "Reset your Forge password"; + const text = [ + greeting, + "A password reset was requested for your Forge account.", + `Reset your password: ${input.url}`, + `This single-use link expires ${input.expiresAt.toISOString()}.`, + "If you did not request this reset, you can ignore this email. Your password has not changed.", + ].join("\n\n"); + const html = ` +
+

Reset your Forge password

+

${escapeHtml(greeting)}

+

A password reset was requested for your Forge account.

+

Reset password

+

This single-use link expires ${escapeHtml(input.expiresAt.toUTCString())}. If you did not request this reset, you can ignore this email. Your password has not changed.

+
`; + return { kind: "password-reset", to: input.to, subject, text, html }; +} + +export function buildPasswordChangedEmail(input: PasswordChangedEmail): TransactionalEmail { + const greeting = input.name?.trim() ? `Hi ${input.name.trim()},` : "Hello,"; + const subject = "Your Forge password was changed"; + const changedAt = input.changedAt.toISOString(); + const text = [ + greeting, + `The password for your Forge account was changed at ${changedAt}.`, + "If you did not make this change, contact your Forge instance administrator immediately.", + ].join("\n\n"); + const html = ` +
+

Your Forge password was changed

+

${escapeHtml(greeting)}

+

The password for your Forge account was changed at ${escapeHtml(input.changedAt.toUTCString())}.

+

If you did not make this change, contact your Forge instance administrator immediately.

+
`; + return { kind: "password-changed", to: input.to, subject, text, html }; +} + +export function sendWorkspaceInviteEmail( + input: WorkspaceInviteEmail, + options?: EmailDeliveryOptions, +): Promise { + return sendTransactionalEmail(buildWorkspaceInviteEmail(input), options); +} + +export function sendAccountSetupEmail( + input: AccountSetupEmail, + options?: EmailDeliveryOptions, +): Promise { + return sendTransactionalEmail(buildAccountSetupEmail(input), options); +} + +export function sendPasswordResetEmail( + input: PasswordResetEmail, + options?: EmailDeliveryOptions, +): Promise { + return sendTransactionalEmail(buildPasswordResetEmail(input), options); +} + +export function sendPasswordChangedEmail( + input: PasswordChangedEmail, + options?: EmailDeliveryOptions, +): Promise { + return sendTransactionalEmail(buildPasswordChangedEmail(input), options); +} diff --git a/src/server/services/local-credentials.ts b/src/server/services/local-credentials.ts new file mode 100644 index 00000000..caca8df2 --- /dev/null +++ b/src/server/services/local-credentials.ts @@ -0,0 +1,161 @@ +import { scrypt, randomBytes, timingSafeEqual } from "node:crypto"; + +export const PASSWORD_HASH_PREFIX = "$forge$scrypt$"; + +const VERSION = 1; +const KEY_LENGTH = 32; +const SALT_LENGTH = 16; +const SCRYPT_N = 32_768; +const SCRYPT_R = 8; +// OWASP's balanced scrypt profile for interactive authentication: +// N=2^15, r=8, p=3 (~32 MiB per derivation with additional CPU work). +const SCRYPT_P = 3; +const SCRYPT_MAXMEM = 64 * 1024 * 1024; +const MAX_PASSWORD_BYTES = 4096; + +type ScryptParameters = { n: number; r: number; p: number }; + +const CURRENT_PARAMETERS: ScryptParameters = { + n: SCRYPT_N, + r: SCRYPT_R, + p: SCRYPT_P, +}; + +function passwordBytes(password: string): number { + return Buffer.byteLength(password, "utf8"); +} + +function assertHashablePassword(password: string): void { + const bytes = passwordBytes(password); + if (bytes === 0) throw new Error("Password must not be empty."); + if (bytes > MAX_PASSWORD_BYTES) throw new Error("Password is too long."); +} + +function deriveKey(password: string, salt: Buffer, parameters: ScryptParameters): Promise { + return new Promise((resolve, reject) => { + scrypt( + password, + salt, + KEY_LENGTH, + { + N: parameters.n, + r: parameters.r, + p: parameters.p, + maxmem: SCRYPT_MAXMEM, + }, + (error, derivedKey) => { + if (error) reject(error); + else resolve(derivedKey); + }, + ); + }); +} + +function encodeHash(salt: Buffer, hash: Buffer, parameters: ScryptParameters): string { + return [ + "", + "forge", + "scrypt", + `v=${VERSION}`, + `n=${parameters.n},r=${parameters.r},p=${parameters.p}`, + salt.toString("base64url"), + hash.toString("base64url"), + ].join("$"); +} + +type ParsedPasswordHash = { + parameters: ScryptParameters; + salt: Buffer; + hash: Buffer; +}; + +function parseHash(encoded: string): ParsedPasswordHash | null { + const parts = encoded.split("$"); + if ( + parts.length !== 7 || + parts[0] !== "" || + parts[1] !== "forge" || + parts[2] !== "scrypt" || + parts[3] !== `v=${VERSION}` + ) { + return null; + } + const match = /^n=(\d+),r=(\d+),p=(\d+)$/.exec(parts[4] ?? ""); + if (!match) return null; + const parameters = { + n: Number(match[1]), + r: Number(match[2]), + p: Number(match[3]), + }; + // Bound attacker-controlled parameters before handing them to scrypt. + if ( + parameters.n < 2 || + parameters.n > SCRYPT_N || + (parameters.n & (parameters.n - 1)) !== 0 || + parameters.r < 1 || + parameters.r > SCRYPT_R || + parameters.p < 1 || + parameters.p > SCRYPT_P + ) { + return null; + } + try { + const salt = Buffer.from(parts[5] ?? "", "base64url"); + const hash = Buffer.from(parts[6] ?? "", "base64url"); + if (salt.length < 16 || hash.length !== KEY_LENGTH) return null; + return { parameters, salt, hash }; + } catch { + return null; + } +} + +/** Hash a password with a fresh salt and the current versioned scrypt profile. */ +export async function hashPassword(password: string): Promise { + assertHashablePassword(password); + const salt = randomBytes(SALT_LENGTH); + const hash = await deriveKey(password, salt, CURRENT_PARAMETERS); + return encodeHash(salt, hash, CURRENT_PARAMETERS); +} + +/** Verify a password against a Forge scrypt record. Malformed records fail closed. */ +export async function verifyPassword(password: string, encoded: string): Promise { + if (passwordBytes(password) > MAX_PASSWORD_BYTES) return false; + const parsed = parseHash(encoded); + if (!parsed) return false; + const actual = await deriveKey(password, parsed.salt, parsed.parameters); + return timingSafeEqual(actual, parsed.hash); +} + +const DUMMY_SALT = Buffer.from("forge-auth-dummy-salt-v1", "utf8"); +let dummyHashPromise: Promise | null = null; + +function dummyPasswordHash(): Promise { + dummyHashPromise ??= deriveKey("forge-invalid-password", DUMMY_SALT, CURRENT_PARAMETERS).then( + (hash) => encodeHash(DUMMY_SALT, hash, CURRENT_PARAMETERS), + ); + return dummyHashPromise; +} + +/** + * Always execute one valid scrypt derivation, even when no credential exists. + * Callers should use this for sign-in to avoid account-enumeration timing. + */ +export async function verifyPasswordOrDummy( + password: string, + encoded: string | null | undefined, +): Promise { + const candidate = encoded && parseHash(encoded) ? encoded : await dummyPasswordHash(); + const matches = await verifyPassword(password, candidate); + return Boolean(encoded) && matches; +} + +/** True when a valid record uses a superseded parameter profile. */ +export function needsPasswordRehash(encoded: string): boolean { + const parsed = parseHash(encoded); + return ( + !parsed || + parsed.parameters.n !== CURRENT_PARAMETERS.n || + parsed.parameters.r !== CURRENT_PARAMETERS.r || + parsed.parameters.p !== CURRENT_PARAMETERS.p + ); +} diff --git a/src/server/services/user-avatar.ts b/src/server/services/user-avatar.ts new file mode 100644 index 00000000..b919fff5 --- /dev/null +++ b/src/server/services/user-avatar.ts @@ -0,0 +1,367 @@ +import "server-only"; + +import { + CreateBucketCommand, + CopyObjectCommand, + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + HeadObjectCommand, + PutBucketCorsCommand, + PutObjectCommand, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { randomUUID } from "node:crypto"; +import { db } from "@/server/db"; +import { + getPresignClient, + getS3Client, + isStorageConfigured, + StorageNotConfiguredError, + UPLOAD_URL_TTL_SECONDS, +} from "@/server/services/storage"; + +export const MAX_AVATAR_SIZE_BYTES = 5 * 1024 * 1024; +export const ALLOWED_AVATAR_MIME_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +] as const; + +export type AvatarMimeType = (typeof ALLOWED_AVATAR_MIME_TYPES)[number]; + +const ALLOWED_AVATAR_MIME_SET = new Set(ALLOWED_AVATAR_MIME_TYPES); +const DEFAULT_GLOBAL_BUCKET = "forge-global"; +const AVATAR_UPLOAD_KEY_PATTERN = /^avatar-uploads\/([^/]+)\/([0-9a-f-]{36})$/; + +export class InvalidAvatarError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidAvatarError"; + } +} + +export function globalStorageBucket(): string { + const bucket = process.env.S3_GLOBAL_BUCKET?.trim() || DEFAULT_GLOBAL_BUCKET; + if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket)) { + throw new StorageNotConfiguredError("S3_GLOBAL_BUCKET is not a valid S3 bucket name."); + } + return bucket; +} + +function avatarObjectKey(userId: string): string { + return `avatar-uploads/${userId}/${randomUUID()}`; +} + +function canonicalAvatarKey(userId: string, uploadKey: string): string { + const match = AVATAR_UPLOAD_KEY_PATTERN.exec(uploadKey); + if (!match || match[1] !== userId) { + throw new InvalidAvatarError("Avatar upload does not belong to this account."); + } + return `avatars/${userId}/${match[2]}`; +} + +export function detectAvatarMimeType(bytes: Uint8Array): AvatarMimeType | null { + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return "image/png"; + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return "image/jpeg"; + } + if (bytes.length >= 6) { + const header = String.fromCharCode(...bytes.subarray(0, 6)); + if (header === "GIF87a" || header === "GIF89a") return "image/gif"; + } + if ( + bytes.length >= 12 && + String.fromCharCode(...bytes.subarray(0, 4)) === "RIFF" && + String.fromCharCode(...bytes.subarray(8, 12)) === "WEBP" + ) { + return "image/webp"; + } + return null; +} + +function assertAvatarShape( + contentType: string, + sizeBytes: number, +): asserts contentType is AvatarMimeType { + if (!ALLOWED_AVATAR_MIME_SET.has(contentType)) { + throw new InvalidAvatarError("Avatar must be a PNG, JPEG, GIF, or WebP image."); + } + if (!Number.isInteger(sizeBytes) || sizeBytes <= 0 || sizeBytes > MAX_AVATAR_SIZE_BYTES) { + throw new InvalidAvatarError( + `Avatar must be between 1 byte and ${MAX_AVATAR_SIZE_BYTES} bytes.`, + ); + } +} + +async function applyGlobalBucketCors(bucket: string): Promise { + const raw = process.env.S3_CORS_ALLOWED_ORIGINS ?? process.env.NEXT_PUBLIC_APP_URL ?? "*"; + const origins = raw + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + await getS3Client().send( + new PutBucketCorsCommand({ + Bucket: bucket, + CORSConfiguration: { + CORSRules: [ + { + AllowedOrigins: origins.length > 0 ? origins : ["*"], + AllowedMethods: ["GET", "PUT", "HEAD"], + AllowedHeaders: ["*"], + ExposeHeaders: ["ETag", "Content-Length", "Content-Type"], + MaxAgeSeconds: 3600, + }, + ], + }, + }), + ); +} + +export async function ensureGlobalStorageBucket(): Promise { + if (!isStorageConfigured()) throw new StorageNotConfiguredError(); + const bucket = globalStorageBucket(); + const s3 = getS3Client(); + try { + await s3.send(new HeadBucketCommand({ Bucket: bucket })); + return bucket; + } catch (error) { + const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode; + const name = (error as { name?: string }).name; + if (![undefined, 301, 403, 404].includes(status) && name !== "NotFound") throw error; + } + try { + await s3.send(new CreateBucketCommand({ Bucket: bucket })); + } catch (error) { + const name = (error as { name?: string }).name; + if (name !== "BucketAlreadyExists" && name !== "BucketAlreadyOwnedByYou") throw error; + } + try { + await applyGlobalBucketCors(bucket); + } catch (error) { + console.warn("[user-avatar] Failed to configure global bucket CORS:", (error as Error).message); + } + return bucket; +} + +export async function presignUserAvatarUpload(input: { + userId: string; + contentType: string; + sizeBytes: number; +}): Promise<{ + uploadUrl: string; + objectKey: string; + headers: { "Content-Type": AvatarMimeType }; + expiresInSeconds: number; +}> { + assertAvatarShape(input.contentType, input.sizeBytes); + const bucket = await ensureGlobalStorageBucket(); + const objectKey = avatarObjectKey(input.userId); + const command = new PutObjectCommand({ + Bucket: bucket, + Key: objectKey, + ContentType: input.contentType, + ContentLength: input.sizeBytes, + }); + const uploadUrl = await getSignedUrl(getPresignClient(), command, { + expiresIn: UPLOAD_URL_TTL_SECONDS, + }); + return { + uploadUrl, + objectKey, + headers: { "Content-Type": input.contentType }, + expiresInSeconds: UPLOAD_URL_TTL_SECONDS, + }; +} + +async function readObjectBytes(bucket: string, objectKey: string): Promise { + const object = await getS3Client().send(new GetObjectCommand({ Bucket: bucket, Key: objectKey })); + if (!object.Body) throw new InvalidAvatarError("Uploaded avatar has no content."); + return ( + object.Body as { transformToByteArray: () => Promise } + ).transformToByteArray(); +} + +async function removeObjectQuietly(bucket: string, objectKey: string): Promise { + try { + await getS3Client().send(new DeleteObjectCommand({ Bucket: bucket, Key: objectKey })); + } catch { + // The database pointer is authoritative. Orphan cleanup can be retried; + // account removal and avatar replacement should still complete. + } +} + +export async function finalizeUserAvatar(input: { + userId: string; + objectKey: string; +}): Promise<{ url: string; contentType: AvatarMimeType; sizeBytes: number; updatedAt: Date }> { + const canonicalKey = canonicalAvatarKey(input.userId, input.objectKey); + const bucket = globalStorageBucket(); + const s3 = getS3Client(); + let contentType: string; + let sizeBytes: number; + try { + const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: input.objectKey })); + contentType = head.ContentType ?? ""; + sizeBytes = head.ContentLength ?? 0; + assertAvatarShape(contentType, sizeBytes); + const bytes = await readObjectBytes(bucket, input.objectKey); + if (bytes.byteLength !== sizeBytes || bytes.byteLength > MAX_AVATAR_SIZE_BYTES) { + throw new InvalidAvatarError("Uploaded avatar size does not match its declared size."); + } + const detectedType = detectAvatarMimeType(bytes); + if (!detectedType || detectedType !== contentType) { + throw new InvalidAvatarError( + "Uploaded avatar content does not match its declared image type.", + ); + } + } catch (error) { + if (error instanceof InvalidAvatarError) { + await removeObjectQuietly(bucket, input.objectKey); + } + throw error; + } + + let etag: string | null = null; + try { + const copied = await s3.send( + new CopyObjectCommand({ + Bucket: bucket, + Key: canonicalKey, + CopySource: `${bucket}/${input.objectKey}`, + ContentType: contentType, + MetadataDirective: "REPLACE", + }), + ); + etag = copied.CopyObjectResult?.ETag?.replace(/^"|"$/g, "") ?? null; + } finally { + // A presigned staging key remains writable until its URL expires. Move + // validated bytes to a server-only key before storing the DB pointer. + await removeObjectQuietly(bucket, input.objectKey); + } + + const stableUrl = `/api/avatar/${input.userId}`; + const [previous, currentUser] = await Promise.all([ + db.userAvatar.findUnique({ where: { userId: input.userId } }), + db.user.findUniqueOrThrow({ where: { id: input.userId }, select: { image: true } }), + ]); + const fallbackImage = + previous?.fallbackImage ?? + (currentUser.image && currentUser.image !== stableUrl ? currentUser.image : null); + let avatar; + try { + avatar = await db.$transaction(async (tx) => { + const updated = await tx.userAvatar.upsert({ + where: { userId: input.userId }, + update: { objectKey: canonicalKey, contentType, sizeBytes, etag, fallbackImage }, + create: { + userId: input.userId, + objectKey: canonicalKey, + contentType, + sizeBytes, + etag, + fallbackImage, + }, + }); + // User.image remains the global read contract across the existing UI. + // Preserve its provider value on UserAvatar so removal can restore it. + await tx.user.update({ where: { id: input.userId }, data: { image: stableUrl } }); + return updated; + }); + } catch (error) { + await removeObjectQuietly(bucket, canonicalKey); + throw error; + } + if (previous && previous.objectKey !== canonicalKey) { + await removeObjectQuietly(bucket, previous.objectKey); + } + return { + url: stableUrl, + contentType: contentType as AvatarMimeType, + sizeBytes, + updatedAt: avatar.updatedAt, + }; +} + +export async function removeUserAvatar( + userId: string, + options: { restoreFallback?: boolean } = {}, +): Promise<{ removed: boolean }> { + const avatar = await db.userAvatar.findUnique({ where: { userId } }); + if (!avatar) return { removed: false }; + if (options.restoreFallback === false) { + await db.userAvatar.delete({ where: { userId } }); + } else { + await db.$transaction([ + db.user.update({ where: { id: userId }, data: { image: avatar.fallbackImage } }), + db.userAvatar.delete({ where: { userId } }), + ]); + } + try { + await removeObjectQuietly(globalStorageBucket(), avatar.objectKey); + } catch { + // Invalid/missing storage configuration must not resurrect a DB pointer. + // Object cleanup remains a safe operational retry. + } + return { removed: true }; +} + +export async function getUserAvatarState(userId: string): Promise<{ + hasLocalAvatar: boolean; + avatarUrl: string | null; + fallbackImage: string | null; + resolvedImage: string | null; + updatedAt: Date | null; +}> { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { + image: true, + avatar: { select: { updatedAt: true, fallbackImage: true } }, + }, + }); + if (!user) throw new InvalidAvatarError("User not found."); + const avatarUrl = user.avatar ? `/api/avatar/${userId}` : null; + return { + hasLocalAvatar: Boolean(user.avatar), + avatarUrl, + fallbackImage: user.avatar?.fallbackImage ?? user.image, + resolvedImage: avatarUrl ?? user.image, + updatedAt: user.avatar?.updatedAt ?? null, + }; +} + +export async function readUserAvatar(userId: string): Promise<{ + bytes: Uint8Array; + contentType: string; + etag: string | null; + updatedAt: Date; +} | null> { + const avatar = await db.userAvatar.findFirst({ + where: { userId, user: { deletedAt: null } }, + }); + if (!avatar) return null; + const bytes = await readObjectBytes(globalStorageBucket(), avatar.objectKey); + if (bytes.byteLength !== avatar.sizeBytes || detectAvatarMimeType(bytes) !== avatar.contentType) { + throw new InvalidAvatarError("Stored avatar failed integrity validation."); + } + return { + bytes, + contentType: avatar.contentType, + etag: avatar.etag, + updatedAt: avatar.updatedAt, + }; +} diff --git a/src/server/services/user-lifecycle.ts b/src/server/services/user-lifecycle.ts new file mode 100644 index 00000000..48859515 --- /dev/null +++ b/src/server/services/user-lifecycle.ts @@ -0,0 +1,634 @@ +import "server-only"; + +import { + ConnectionStatus, + InstanceRole, + Prisma, + type PrismaClient, + UserActionTokenType, + UserStatus, +} from "@prisma/client"; +import { TRPCError } from "@trpc/server"; +import { hashPassword } from "@/server/services/local-credentials"; +import { + consumeUserActionToken, + issueUserActionToken as issueAuthActionToken, +} from "@/server/services/auth-tokens"; +import { removeUserAvatar } from "@/server/services/user-avatar"; + +type DatabaseClient = PrismaClient | Prisma.TransactionClient; + +const SERIALIZABLE_RETRIES = 3; + +function transactionConflict(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2034"; +} + +async function serializable( + db: PrismaClient, + fn: (tx: Prisma.TransactionClient) => Promise, +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return await db.$transaction(fn, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }); + } catch (error) { + if (!transactionConflict(error) || attempt >= SERIALIZABLE_RETRIES - 1) throw error; + } + } +} + +async function writeInstanceAudit( + tx: DatabaseClient, + input: { + actorId?: string | null; + targetUserId?: string | null; + action: string; + metadata?: Prisma.InputJsonValue; + ipAddress?: string | null; + userAgent?: string | null; + }, +): Promise { + await tx.instanceAuditLog.create({ + data: { + actorId: input.actorId, + targetUserId: input.targetUserId ?? null, + action: input.action, + metadata: input.metadata ?? {}, + ipAddress: input.ipAddress ?? null, + userAgent: input.userAgent ?? null, + }, + }); +} + +async function actionTokenTtlMinutes(tx: DatabaseClient): Promise { + const policy = await tx.instanceAuthPolicy.findUnique({ + where: { id: "default" }, + select: { passwordResetTtlMinutes: true }, + }); + return policy?.passwordResetTtlMinutes ?? 60; +} + +async function createActionToken( + tx: DatabaseClient, + input: { + userId: string; + type: UserActionTokenType; + emailSnapshot: string; + }, +): Promise<{ token: string; expiresAt: Date }> { + const now = new Date(); + const ttlMinutes = await actionTokenTtlMinutes(tx); + const issued = await issueAuthActionToken( + { + userId: input.userId, + type: input.type, + emailSnapshot: input.emailSnapshot, + ttlMinutes, + now, + }, + tx, + ); + return { token: issued.rawToken, expiresAt: issued.token.expiresAt }; +} + +export type LifecycleActor = { + actorId?: string | null; + ipAddress?: string | null; + userAgent?: string | null; +}; + +type CompletedCredentialAction = { userId: string; authVersion: number }; + +async function passwordPolicy(db: DatabaseClient): Promise<{ minLength: number }> { + const policy = await db.instanceAuthPolicy.findUnique({ + where: { id: "default" }, + select: { passwordMinLength: true }, + }); + return { minLength: policy?.passwordMinLength ?? 12 }; +} + +function assertPasswordLength(password: string, minLength: number): void { + if (password.length < minLength) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Password must be at least ${minLength} characters.`, + }); + } +} + +export async function createInvitedUser( + db: PrismaClient, + input: LifecycleActor & { email: string; name?: string | null; instanceRole?: InstanceRole }, +) { + const email = input.email.trim().toLowerCase(); + return serializable(db, async (tx) => { + const existing = await tx.user.findFirst({ + where: { + OR: [{ normalizedEmail: email }, { email: { equals: email, mode: "insensitive" } }], + }, + select: { id: true }, + }); + if (existing) { + throw new TRPCError({ code: "CONFLICT", message: "A user with that email already exists." }); + } + + const user = await tx.user.create({ + data: { + email, + normalizedEmail: email, + name: input.name?.trim() || null, + instanceRole: input.instanceRole ?? InstanceRole.MEMBER, + status: UserStatus.INVITED, + }, + select: { + id: true, + email: true, + normalizedEmail: true, + name: true, + instanceRole: true, + status: true, + createdAt: true, + }, + }); + const setup = await createActionToken(tx, { + userId: user.id, + type: UserActionTokenType.ACCOUNT_SETUP, + emailSnapshot: user.normalizedEmail ?? user.email.trim().toLowerCase(), + }); + await writeInstanceAudit(tx, { + ...input, + targetUserId: user.id, + action: "USER_INVITED", + metadata: { email: user.normalizedEmail, instanceRole: user.instanceRole }, + }); + return { user, setupToken: setup.token, expiresAt: setup.expiresAt }; + }); +} + +export async function setUserInstanceRole( + db: PrismaClient, + input: LifecycleActor & { userId: string; role: InstanceRole }, +) { + return serializable(db, async (tx) => { + const target = await tx.user.findUnique({ + where: { id: input.userId }, + select: { id: true, instanceRole: true, status: true }, + }); + if (!target || target.status === UserStatus.DELETED) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found." }); + } + if (target.instanceRole === input.role) { + const current = await tx.user.findUniqueOrThrow({ + where: { id: target.id }, + select: { id: true, instanceRole: true, authVersion: true }, + }); + return current; + } + if ( + target.instanceRole === InstanceRole.INSTANCE_ADMIN && + input.role !== InstanceRole.INSTANCE_ADMIN && + target.status === UserStatus.ACTIVE + ) { + const otherActiveAdmins = await tx.user.count({ + where: { + id: { not: target.id }, + instanceRole: InstanceRole.INSTANCE_ADMIN, + status: UserStatus.ACTIVE, + disabledAt: null, + deletedAt: null, + }, + }); + if (otherActiveAdmins === 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Can't demote the last active instance admin.", + }); + } + } + const updated = await tx.user.update({ + where: { id: target.id }, + data: { instanceRole: input.role, authVersion: { increment: 1 } }, + select: { id: true, instanceRole: true, authVersion: true }, + }); + await tx.session.deleteMany({ where: { userId: target.id } }); + await writeInstanceAudit(tx, { + ...input, + targetUserId: target.id, + action: "USER_INSTANCE_ROLE_CHANGED", + metadata: { before: target.instanceRole, after: updated.instanceRole }, + }); + return updated; + }); +} + +export async function issueUserActionToken( + db: PrismaClient, + input: LifecycleActor & { userId: string; type: UserActionTokenType }, +) { + return serializable(db, async (tx) => { + const user = await tx.user.findUnique({ + where: { id: input.userId }, + select: { + id: true, + email: true, + normalizedEmail: true, + status: true, + localCredential: { select: { userId: true } }, + }, + }); + if (!user || user.status === UserStatus.DELETED) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found." }); + } + if (input.type === UserActionTokenType.ACCOUNT_SETUP && user.status !== UserStatus.INVITED) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Account setup is only available for invited users.", + }); + } + if ( + input.type === UserActionTokenType.PASSWORD_RESET && + (!user.localCredential || + (user.status !== UserStatus.ACTIVE && user.status !== UserStatus.SUSPENDED)) + ) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Password reset is only available for users with a local password.", + }); + } + const issued = await createActionToken(tx, { + userId: user.id, + type: input.type, + emailSnapshot: user.normalizedEmail ?? user.email.trim().toLowerCase(), + }); + await writeInstanceAudit(tx, { + ...input, + targetUserId: user.id, + action: + input.type === UserActionTokenType.ACCOUNT_SETUP + ? "ACCOUNT_SETUP_TOKEN_ISSUED" + : "PASSWORD_RESET_TOKEN_ISSUED", + metadata: { expiresAt: issued.expiresAt.toISOString() }, + }); + return issued; + }); +} + +/** + * Resolve an eligible local account and rotate its reset token. Callers must + * always render the same response whether this returns a delivery or null. + */ +export async function requestPasswordReset( + db: PrismaClient, + input: { email: string; ipAddress?: string | null; userAgent?: string | null }, +): Promise<{ userId: string; email: string; token: string; expiresAt: Date } | null> { + const normalizedEmail = input.email.trim().toLowerCase(); + return serializable(db, async (tx) => { + const user = await tx.user.findFirst({ + where: { + OR: [{ normalizedEmail }, { email: { equals: normalizedEmail, mode: "insensitive" } }], + status: { in: [UserStatus.ACTIVE, UserStatus.SUSPENDED] }, + deletedAt: null, + localCredential: { isNot: null }, + }, + select: { id: true, email: true, normalizedEmail: true }, + }); + if (!user) return null; + const issued = await createActionToken(tx, { + userId: user.id, + type: UserActionTokenType.PASSWORD_RESET, + emailSnapshot: user.normalizedEmail ?? user.email.trim().toLowerCase(), + }); + await writeInstanceAudit(tx, { + actorId: null, + targetUserId: user.id, + action: "PASSWORD_RESET_REQUESTED", + metadata: { expiresAt: issued.expiresAt.toISOString() }, + ipAddress: input.ipAddress, + userAgent: input.userAgent, + }); + return { userId: user.id, email: user.email, ...issued }; + }); +} + +async function completeCredentialAction( + db: PrismaClient, + input: { + token: string; + password: string; + type: UserActionTokenType; + name?: string | null; + ipAddress?: string | null; + userAgent?: string | null; + }, +): Promise { + const policy = await passwordPolicy(db); + assertPasswordLength(input.password, policy.minLength); + const passwordHash = await hashPassword(input.password); + const consumed = await consumeUserActionToken( + { rawToken: input.token, type: input.type }, + async (tx, token) => { + const now = new Date(); + const tokenUser = await tx.user.findUnique({ + where: { id: token.userId }, + select: { id: true, email: true, normalizedEmail: true, status: true }, + }); + const validStatus = + input.type === UserActionTokenType.ACCOUNT_SETUP + ? tokenUser?.status === UserStatus.INVITED + : tokenUser?.status === UserStatus.ACTIVE || tokenUser?.status === UserStatus.SUSPENDED; + const currentEmail = tokenUser?.normalizedEmail ?? tokenUser?.email.trim().toLowerCase(); + if (!tokenUser || !validStatus || token.emailSnapshot !== currentEmail) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This account link is invalid or expired.", + }); + } + + await tx.localCredential.upsert({ + where: { userId: tokenUser.id }, + create: { userId: tokenUser.id, passwordHash, passwordChangedAt: now }, + update: { + passwordHash, + passwordChangedAt: now, + mustChangePassword: false, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + }, + }); + await tx.userActionToken.updateMany({ + where: { userId: tokenUser.id, id: { not: token.id }, usedAt: null }, + data: { usedAt: now }, + }); + await tx.session.deleteMany({ where: { userId: tokenUser.id } }); + const user = await tx.user.update({ + where: { id: tokenUser.id }, + data: { + ...(input.type === UserActionTokenType.ACCOUNT_SETUP + ? { + status: UserStatus.ACTIVE, + disabledAt: null, + emailVerified: now, + ...(input.name?.trim() ? { name: input.name.trim() } : {}), + } + : {}), + authVersion: { increment: 1 }, + }, + select: { id: true, authVersion: true }, + }); + await writeInstanceAudit(tx, { + actorId: user.id, + targetUserId: user.id, + action: + input.type === UserActionTokenType.ACCOUNT_SETUP + ? "ACCOUNT_SETUP_COMPLETED" + : "PASSWORD_RESET_COMPLETED", + ipAddress: input.ipAddress, + userAgent: input.userAgent, + }); + return { userId: user.id, authVersion: user.authVersion }; + }, + db, + ); + if (consumed.state !== "CONSUMED" || !consumed.value) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This account link is invalid or expired.", + }); + } + return consumed.value; +} + +export function completePasswordReset( + db: PrismaClient, + input: { token: string; password: string; ipAddress?: string | null; userAgent?: string | null }, +): Promise { + return completeCredentialAction(db, { ...input, type: UserActionTokenType.PASSWORD_RESET }); +} + +export function completeAccountSetup( + db: PrismaClient, + input: { + token: string; + password: string; + name?: string | null; + ipAddress?: string | null; + userAgent?: string | null; + }, +): Promise { + return completeCredentialAction(db, { ...input, type: UserActionTokenType.ACCOUNT_SETUP }); +} + +async function assertLifecycleQuorum(tx: Prisma.TransactionClient, userId: string): Promise { + const user = await tx.user.findUnique({ + where: { id: userId }, + select: { instanceRole: true, status: true }, + }); + if (!user || user.status === UserStatus.DELETED) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found." }); + } + + if (user.instanceRole === InstanceRole.INSTANCE_ADMIN && user.status === UserStatus.ACTIVE) { + const otherAdmins = await tx.user.count({ + where: { + id: { not: userId }, + instanceRole: InstanceRole.INSTANCE_ADMIN, + status: UserStatus.ACTIVE, + disabledAt: null, + deletedAt: null, + }, + }); + if (otherAdmins === 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Can't disable or delete the last active instance admin.", + }); + } + } + + const owned = await tx.membership.findMany({ + where: { userId, role: "OWNER", workspace: { deletedAt: null } }, + select: { workspaceId: true, workspace: { select: { name: true } } }, + }); + for (const membership of owned) { + const otherOwners = await tx.membership.count({ + where: { + workspaceId: membership.workspaceId, + userId: { not: userId }, + role: "OWNER", + user: { status: UserStatus.ACTIVE, disabledAt: null, deletedAt: null }, + }, + }); + if (otherOwners === 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Transfer ownership of ${membership.workspace.name} before disabling or deleting this user.`, + }); + } + } +} + +async function revokeUserAccess( + tx: Prisma.TransactionClient, + userId: string, + now: Date, + reason: string, +): Promise<{ sessions: number; apiKeys: number; connections: number; mappings: number }> { + const connections = await tx.connection.findMany({ + where: { ownerId: userId }, + select: { id: true }, + }); + const connectionIds = connections.map((connection) => connection.id); + const [sessions, apiKeys, actionTokens, mappings] = await Promise.all([ + tx.session.deleteMany({ where: { userId } }), + tx.apiKey.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }), + tx.userActionToken.updateMany({ where: { userId, usedAt: null }, data: { usedAt: now } }), + connectionIds.length + ? tx.connectionMapping.updateMany({ + where: { connectionId: { in: connectionIds } }, + data: { status: "paused" }, + }) + : Promise.resolve({ count: 0 }), + ]); + if (connectionIds.length) { + await tx.connection.updateMany({ + where: { id: { in: connectionIds } }, + data: { tokenEnc: null, status: ConnectionStatus.DISCONNECTED, error: reason }, + }); + } + void actionTokens; + return { + sessions: sessions.count, + apiKeys: apiKeys.count, + connections: connections.length, + mappings: mappings.count, + }; +} + +export async function revokeUserSessions( + db: PrismaClient, + input: LifecycleActor & { userId: string }, +) { + return serializable(db, async (tx) => { + const user = await tx.user.update({ + where: { id: input.userId }, + data: { authVersion: { increment: 1 } }, + select: { id: true, authVersion: true }, + }); + const sessions = await tx.session.deleteMany({ where: { userId: user.id } }); + await writeInstanceAudit(tx, { + ...input, + targetUserId: user.id, + action: "USER_SESSIONS_REVOKED", + metadata: { deletedDatabaseSessions: sessions.count, authVersion: user.authVersion }, + }); + return { authVersion: user.authVersion, revokedSessions: sessions.count }; + }); +} + +export async function suspendUser( + db: PrismaClient, + input: LifecycleActor & { userId: string; reason?: string | null }, +) { + return serializable(db, async (tx) => { + await assertLifecycleQuorum(tx, input.userId); + const now = new Date(); + const user = await tx.user.update({ + where: { id: input.userId }, + data: { status: UserStatus.SUSPENDED, disabledAt: now, authVersion: { increment: 1 } }, + select: { id: true, status: true, disabledAt: true, authVersion: true }, + }); + const revoked = await revokeUserAccess(tx, user.id, now, "Owner account suspended."); + await writeInstanceAudit(tx, { + ...input, + targetUserId: user.id, + action: "USER_SUSPENDED", + metadata: { reason: input.reason ?? null, ...revoked, authVersion: user.authVersion }, + }); + return { ...user, revoked }; + }); +} + +export async function reactivateUser(db: PrismaClient, input: LifecycleActor & { userId: string }) { + return serializable(db, async (tx) => { + const existing = await tx.user.findUnique({ + where: { id: input.userId }, + select: { status: true }, + }); + if (!existing || existing.status === UserStatus.DELETED) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found." }); + } + if (existing.status !== UserStatus.SUSPENDED) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Only suspended users can be reactivated.", + }); + } + const user = await tx.user.update({ + where: { id: input.userId }, + data: { status: UserStatus.ACTIVE, disabledAt: null, authVersion: { increment: 1 } }, + select: { id: true, status: true, disabledAt: true, authVersion: true }, + }); + await writeInstanceAudit(tx, { ...input, targetUserId: user.id, action: "USER_REACTIVATED" }); + return user; + }); +} + +export async function softDeleteUser( + db: PrismaClient, + input: LifecycleActor & { userId: string; reason?: string | null }, +) { + const deleted = await serializable(db, async (tx) => { + await assertLifecycleQuorum(tx, input.userId); + const now = new Date(); + const tombstoneEmail = `deleted+${input.userId}@invalid.local`; + const revoked = await revokeUserAccess(tx, input.userId, now, "Owner account deleted."); + + await Promise.all([ + tx.localCredential.deleteMany({ where: { userId: input.userId } }), + tx.account.deleteMany({ where: { userId: input.userId } }), + tx.workspace.updateMany({ + where: { defaultIssueAssigneeUserId: input.userId }, + data: { defaultIssueAssigneeMode: "NONE", defaultIssueAssigneeUserId: null }, + }), + tx.membership.deleteMany({ where: { userId: input.userId } }), + ]); + + const user = await tx.user.update({ + where: { id: input.userId }, + data: { + email: tombstoneEmail, + normalizedEmail: tombstoneEmail, + name: "Deleted user", + handle: null, + image: null, + status: UserStatus.DELETED, + instanceRole: InstanceRole.MEMBER, + disabledAt: now, + deletedAt: now, + authVersion: { increment: 1 }, + }, + select: { id: true, status: true, deletedAt: true, authVersion: true }, + }); + await writeInstanceAudit(tx, { + ...input, + targetUserId: user.id, + action: "USER_SOFT_DELETED", + metadata: { reason: input.reason ?? null, ...revoked, authVersion: user.authVersion }, + }); + return { ...user, revoked }; + }); + try { + const avatarCleanup = await removeUserAvatar(input.userId, { restoreFallback: false }); + return { ...deleted, avatarCleanup: { ok: true as const, ...avatarCleanup } }; + } catch (error) { + // The durable access revocation and tombstone have already committed. Do + // not resurrect the account because object storage is unavailable; expose + // and log the orphan so maintenance can retry cleanup safely. + const message = error instanceof Error ? error.message : "Avatar cleanup failed."; + console.error(`[user-lifecycle] avatar cleanup failed for ${input.userId}: ${message}`); + return { ...deleted, avatarCleanup: { ok: false as const, error: message } }; + } +} diff --git a/src/server/services/workspace-invitations.ts b/src/server/services/workspace-invitations.ts index 031be503..f9ba2081 100644 --- a/src/server/services/workspace-invitations.ts +++ b/src/server/services/workspace-invitations.ts @@ -1,9 +1,10 @@ import "server-only"; import { createHash, randomBytes } from "node:crypto"; -import { EventKind, InvitationStatus, type Role } from "@prisma/client"; +import { EventKind, InvitationStatus, UserStatus, type Role } from "@prisma/client"; import { db } from "@/server/db"; import { recordChange } from "@/server/audit"; import { sendWorkspaceInviteEmail } from "@/server/services/email"; +import { hashPassword } from "@/server/services/local-credentials"; export function invitationTokenHash(token: string): string { return createHash("sha256").update(token).digest("hex"); @@ -18,7 +19,11 @@ export function invitationExpiry(hours: number, now = new Date()): Date { } export function invitationPublicUrl(token: string): string { - const origin = (process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || "http://localhost:3000") + const origin = ( + process.env.NEXT_PUBLIC_APP_URL || + process.env.AUTH_URL || + "http://localhost:3000" + ) .trim() .replace(/\/+$/, ""); return `${origin}/invite/${encodeURIComponent(token)}`; @@ -89,7 +94,14 @@ export async function inspectWorkspaceInvitation(token: string): Promise { + const policy = await db.instanceAuthPolicy.findUnique({ where: { id: "default" } }); + if (policy?.mode === "EXTERNAL_ONLY" || policy?.registrationMode === "DISABLED") { + return { state: "LOCAL_DISABLED" }; + } + const minimum = policy?.passwordMinLength ?? 12; + if (params.password.length < minimum) { + throw new Error(`Password must be at least ${minimum} characters.`); + } + const passwordHash = await hashPassword(params.password); + const tokenHash = invitationTokenHash(params.token); + + return db.$transaction(async (tx) => { + const invitation = await tx.workspaceInvitation.findUnique({ + where: { tokenHash }, + include: { workspace: { select: { name: true, slug: true } } }, + }); + if (!invitation) return { state: "INVALID" } as const; + const base = { + workspaceSlug: invitation.workspace.slug, + workspaceName: invitation.workspace.name, + }; + if (invitation.status === InvitationStatus.ACCEPTED) { + return { state: "ALREADY_ACCEPTED", ...base } as const; + } + if (invitation.status === InvitationStatus.REVOKED) { + return { state: "REVOKED", ...base } as const; + } + if (invitation.status === InvitationStatus.EXPIRED || invitation.expiresAt <= new Date()) { + await tx.workspaceInvitation.updateMany({ + where: { id: invitation.id, status: InvitationStatus.PENDING, tokenHash }, + data: { status: InvitationStatus.EXPIRED }, + }); + return { state: "EXPIRED", ...base } as const; + } + + const email = invitation.email.trim().toLowerCase(); + const existing = await tx.user.findFirst({ + where: { + OR: [{ normalizedEmail: email }, { email: { equals: email, mode: "insensitive" } }], + }, + select: { id: true }, + }); + if (existing) return { state: "EXISTING_ACCOUNT", ...base } as const; + + const claimed = await tx.workspaceInvitation.updateMany({ + where: { + id: invitation.id, + status: InvitationStatus.PENDING, + tokenHash, + expiresAt: { gt: new Date() }, + }, + data: { status: InvitationStatus.ACCEPTED, acceptedAt: new Date(), lastSendError: null }, + }); + if (claimed.count !== 1) return { state: "INVALID", ...base } as const; + + const user = await tx.user.create({ + data: { + email, + normalizedEmail: email, + emailVerified: new Date(), + name: params.name.trim(), + status: UserStatus.ACTIVE, + localCredential: { create: { passwordHash } }, + memberships: { + create: { workspaceId: invitation.workspaceId, role: invitation.role }, + }, + }, + select: { + id: true, + memberships: { + where: { workspaceId: invitation.workspaceId }, + select: { id: true }, + take: 1, + }, + }, + }); + await tx.workspaceInvitation.update({ + where: { id: invitation.id }, + data: { acceptedById: user.id }, + }); + const membershipId = user.memberships[0]?.id; + if (membershipId) { + await recordChange(tx, { + workspaceId: invitation.workspaceId, + actorId: user.id, + entity: "Membership", + entityId: membershipId, + action: "create", + after: { userId: user.id, role: invitation.role, email }, + eventKind: EventKind.MEMBERSHIP_CREATED, + subjectType: "membership", + subjectId: membershipId, + payload: { userId: user.id, email, role: invitation.role }, + }); + } + await recordChange(tx, { + workspaceId: invitation.workspaceId, + actorId: user.id, + entity: "WorkspaceInvitation", + entityId: invitation.id, + action: "accept-local-registration", + after: { email, role: invitation.role }, + eventKind: EventKind.INVITATION_ACCEPTED, + subjectType: "invitation", + subjectId: invitation.id, + payload: { email, role: invitation.role, localRegistration: true }, + }); + return { state: "CREATED", userId: user.id, ...base } as const; + }); +} + export async function deliverWorkspaceInvitation(input: { invitationId: string; token: string; diff --git a/src/server/sso.ts b/src/server/sso.ts index 141a42ae..4582329f 100644 --- a/src/server/sso.ts +++ b/src/server/sso.ts @@ -32,7 +32,7 @@ export async function getEnabledSsoRows(): Promise { const now = Date.now(); if (cache && now - cache.at < TTL_MS) return cache.rows; const rows = await db.ssoProvider.findMany({ - where: { enabled: true }, + where: { enabled: true, archivedAt: null }, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], }); cache = { rows, at: now }; diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 1f0892cc..170e5805 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -3,20 +3,18 @@ import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; /** - * Sign in once as the seeded owner (credentials provider keyed off - * ADMIN_EMAIL/ADMIN_PASSWORD, which `scripts/e2e-web.sh` sets to the seed's - * owner) and persist the JWT session as a storageState the whole suite reuses. - * Replaces the old "sign-in handled out-of-band" hand-wave. + * Sign in once as the seeded local owner and persist the JWT session as a + * storageState the whole suite reuses. Dedicated E2E variables win when set; + * ADMIN_* remains a compatibility fallback for older harnesses and the + * protected bootstrap credential. */ const STORAGE = "tests/e2e/.auth/owner.json"; export default async function globalSetup(config: FullConfig) { const baseURL = - config.projects[0]?.use?.baseURL ?? - process.env.PLAYWRIGHT_BASE_URL ?? - "http://localhost:3200"; - const email = process.env.ADMIN_EMAIL ?? "owner@forge.local"; - const password = process.env.ADMIN_PASSWORD ?? "forge-dev"; + config.projects[0]?.use?.baseURL ?? process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3200"; + const email = process.env.E2E_OWNER_EMAIL ?? process.env.ADMIN_EMAIL ?? "owner@forge.local"; + const password = process.env.E2E_OWNER_PASSWORD ?? process.env.ADMIN_PASSWORD ?? "forge-dev"; mkdirSync(dirname(STORAGE), { recursive: true }); diff --git a/tests/e2e/identity-auth.spec.ts b/tests/e2e/identity-auth.spec.ts new file mode 100644 index 00000000..70c683f7 --- /dev/null +++ b/tests/e2e/identity-auth.spec.ts @@ -0,0 +1,159 @@ +import { createHash, randomBytes, scrypt } from "node:crypto"; +import { PrismaClient, UserActionTokenType, UserStatus } from "@prisma/client"; +import { expect, test } from "@playwright/test"; + +const localE2eDatabase = "postgresql://forge:forge@localhost:55432/forge_e2e?schema=public"; +const prisma = new PrismaClient({ + datasourceUrl: + process.env.E2E_DATABASE_URL ?? + (process.env.E2E_MANAGE_STACK === "0" ? process.env.DATABASE_URL : localE2eDatabase), +}); +const initialPassword = "forge identity initial password"; +const resetPassword = "forge identity reset password"; +const email = `identity-e2e-${Date.now()}@forge.local`; + +async function passwordHash(password: string): Promise { + const salt = randomBytes(16); + const key = await new Promise((resolve, reject) => { + scrypt( + password, + salt, + 32, + { N: 32_768, r: 8, p: 3, maxmem: 64 * 1024 * 1024 }, + (error, derived) => (error ? reject(error) : resolve(derived)), + ); + }); + return `$forge$scrypt$v=1$n=32768,r=8,p=3$${salt.toString("base64url")}$${key.toString("base64url")}`; +} + +function resetTokenHash(raw: string): string { + return createHash("sha256").update("forge:user-action:v1:").update(raw).digest("hex"); +} + +function invitationTokenHash(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); +} + +test.describe("local identity lifecycle", () => { + test.describe.configure({ mode: "serial" }); + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeAll(async () => { + const user = await prisma.user.create({ + data: { + email, + normalizedEmail: email, + emailVerified: new Date(), + name: "Identity E2E", + status: UserStatus.ACTIVE, + }, + }); + await prisma.localCredential.create({ + data: { userId: user.id, passwordHash: await passwordHash(initialPassword) }, + }); + }); + + test.afterAll(async () => { + await prisma.user.deleteMany({ where: { normalizedEmail: email } }); + await prisma.$disconnect(); + }); + + test("signs in with a durable local credential", async ({ page }) => { + await page.goto("/signin?manual=1&callbackUrl=/settings/security"); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(initialPassword); + await page.getByRole("button", { name: /^sign in/i }).click(); + await expect(page).toHaveURL((url) => url.pathname === "/settings/security"); + await expect(page.getByRole("heading", { name: "Security & sign-in" })).toBeVisible(); + }); + + test("keeps forgotten-password requests enumeration safe", async ({ page }) => { + await page.goto("/forgot-password"); + await page.getByLabel("Email").fill(`missing-${email}`); + await page.getByRole("button", { name: /send reset link/i }).click(); + await expect(page).toHaveURL(/sent=1/); + await expect(page.getByText(/if an eligible local account exists/i)).toBeVisible(); + }); + + test("consumes one reset token, revokes the old password, and accepts the new one", async ({ + page, + }) => { + const user = await prisma.user.findUniqueOrThrow({ where: { normalizedEmail: email } }); + const rawToken = randomBytes(32).toString("base64url"); + await prisma.userActionToken.create({ + data: { + userId: user.id, + type: UserActionTokenType.PASSWORD_RESET, + tokenHash: resetTokenHash(rawToken), + emailSnapshot: email, + expiresAt: new Date(Date.now() + 30 * 60_000), + }, + }); + + await page.goto(`/reset-password/${rawToken}`); + await page.getByLabel("New password").fill(resetPassword); + await page.getByLabel("Confirm password").fill(resetPassword); + await page.getByRole("button", { name: /save new password/i }).click(); + await expect(page).toHaveURL(/\/signin\/local/); + + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(initialPassword); + await page.getByRole("button", { name: /^sign in/i }).click(); + await expect(page).toHaveURL(/error=/); + + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(resetPassword); + await page.getByRole("button", { name: /^sign in/i }).click(); + await expect(page).not.toHaveURL(/\/signin/); + + const consumed = await prisma.userActionToken.findUniqueOrThrow({ + where: { tokenHash: resetTokenHash(rawToken) }, + }); + expect(consumed.usedAt).not.toBeNull(); + }); + + test("creates a local account directly from a workspace invitation", async ({ page }) => { + const invitedEmail = `workspace-local-${Date.now()}@forge.local`; + const invitedPassword = "workspace invitation password"; + const token = randomBytes(32).toString("base64url"); + const workspace = await prisma.workspace.findUniqueOrThrow({ where: { slug: "forge" } }); + const owner = await prisma.membership.findFirstOrThrow({ + where: { workspaceId: workspace.id, role: "OWNER" }, + select: { userId: true }, + }); + await prisma.workspaceInvitation.create({ + data: { + workspaceId: workspace.id, + email: invitedEmail, + tokenHash: invitationTokenHash(token), + invitedById: owner.userId, + expiresAt: new Date(Date.now() + 60 * 60_000), + }, + }); + + await page.goto(`/invite/${token}/local`); + await page.getByLabel("Name").fill("Workspace Local User"); + await page.getByLabel("Password", { exact: true }).fill(invitedPassword); + await page.getByLabel("Confirm password").fill(invitedPassword); + await page.getByRole("button", { name: /create account and join/i }).click(); + await expect(page).toHaveURL(/\/signin\/local/); + + await page.getByLabel("Email").fill(invitedEmail); + await page.getByLabel("Password").fill(invitedPassword); + await page.getByRole("button", { name: /^sign in/i }).click(); + await expect(page).toHaveURL((url) => url.pathname === "/w/forge/dashboard"); + + await expect( + prisma.user.findUniqueOrThrow({ + where: { normalizedEmail: invitedEmail }, + include: { localCredential: true, memberships: true }, + }), + ).resolves.toMatchObject({ + status: "ACTIVE", + localCredential: expect.objectContaining({ userId: expect.any(String) }), + memberships: expect.arrayContaining([ + expect.objectContaining({ workspaceId: workspace.id, role: "MEMBER" }), + ]), + }); + }); +}); diff --git a/tests/unit/auth-callback.test.ts b/tests/unit/auth-callback.test.ts new file mode 100644 index 00000000..ea5951fc --- /dev/null +++ b/tests/unit/auth-callback.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { authPath, safeAuthCallbackUrl } from "@/lib/auth-callback"; + +describe("safeAuthCallbackUrl", () => { + it("preserves local paths, queries, and fragments", () => { + expect(safeAuthCallbackUrl("/invite/example?source=email#accept")).toBe( + "/invite/example?source=email#accept", + ); + }); + + it.each([ + "https://attacker.example/steal", + "//attacker.example/steal", + "javascript:alert(1)", + "dashboard", + ])("rejects a non-local callback: %s", (value) => { + expect(safeAuthCallbackUrl(value)).toBe("/dashboard"); + }); + + it("uses the dashboard for missing or malformed callbacks", () => { + expect(safeAuthCallbackUrl(undefined)).toBe("/dashboard"); + expect(safeAuthCallbackUrl("/%")).toBe("/%"); + }); + + it("builds an encoded local auth path", () => { + expect(authPath("/signin/local", "/w/forge/dashboard?from=invite")).toBe( + "/signin/local?callbackUrl=%2Fw%2Fforge%2Fdashboard%3Ffrom%3Dinvite", + ); + }); +}); diff --git a/tests/unit/auth-policy.test.ts b/tests/unit/auth-policy.test.ts new file mode 100644 index 00000000..f59f62a4 --- /dev/null +++ b/tests/unit/auth-policy.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + AuthPolicyValidationError, + DEFAULT_AUTH_POLICY, + deriveAuthPresentation, + validateAuthPolicyTransition, +} from "@/server/services/auth-policy"; + +const github = { id: "github", enabled: true, archivedAt: null }; +const oidc = { id: "company-oidc", enabled: true, archivedAt: null }; + +describe("instance authentication policy", () => { + it("derives clean local-only, external-only, and hybrid presentation", () => { + expect( + deriveAuthPresentation({ ...DEFAULT_AUTH_POLICY, mode: "LOCAL_ONLY" }, [github]), + ).toMatchObject({ + localCredentialsEnabled: true, + externalProvidersEnabled: false, + providerSelectionEnabled: false, + }); + + expect( + deriveAuthPresentation({ ...DEFAULT_AUTH_POLICY, mode: "EXTERNAL_ONLY" }, [github]), + ).toMatchObject({ + localCredentialsEnabled: false, + externalProvidersEnabled: true, + providerSelectionEnabled: true, + }); + + expect(deriveAuthPresentation(DEFAULT_AUTH_POLICY, [github, oidc])).toMatchObject({ + localCredentialsEnabled: true, + externalProvidersEnabled: true, + providerSelectionEnabled: true, + enabledProviderIds: ["github", "company-oidc"], + }); + }); + + it("auto-redirects only to an enabled, non-archived provider", () => { + const presentation = validateAuthPolicyTransition( + { ...DEFAULT_AUTH_POLICY, autoRedirectProviderId: oidc.id }, + [github, oidc], + ); + expect(presentation.autoRedirectProviderId).toBe(oidc.id); + expect(presentation.providerSelectionEnabled).toBe(false); + + expect(() => + validateAuthPolicyTransition({ ...DEFAULT_AUTH_POLICY, autoRedirectProviderId: oidc.id }, [ + { ...oidc, archivedAt: new Date() }, + ]), + ).toThrow(AuthPolicyValidationError); + }); + + it("prevents external-only lockout and unconfigured break glass", () => { + expect(() => + validateAuthPolicyTransition({ ...DEFAULT_AUTH_POLICY, mode: "EXTERNAL_ONLY" }, []), + ).toThrow(/at least one enabled provider/i); + + expect(() => + validateAuthPolicyTransition(DEFAULT_AUTH_POLICY, [github], { + breakGlassConfigured: false, + }), + ).toThrow(/break-glass credentials/i); + }); +}); diff --git a/tests/unit/auth-tokens.test.ts b/tests/unit/auth-tokens.test.ts new file mode 100644 index 00000000..5098548f --- /dev/null +++ b/tests/unit/auth-tokens.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import { + consumeUserActionToken, + hashUserActionToken, + inspectUserActionToken, + issueUserActionToken, + newUserActionToken, + normalizeAuthEmail, +} from "@/server/services/auth-tokens"; + +describe("user action tokens", () => { + it("generates random bearer values and persists only stable digests", () => { + const first = newUserActionToken(); + const second = newUserActionToken(); + expect(first.rawToken).not.toBe(second.rawToken); + expect(first.rawToken).not.toContain(first.tokenHash); + expect(first.tokenHash).toMatch(/^[a-f0-9]{64}$/); + expect(hashUserActionToken(first.rawToken)).toBe(first.tokenHash); + expect(normalizeAuthEmail(" Bailey@Example.COM ")).toBe("bailey@example.com"); + }); + + it("rotates an outstanding token before issuing its replacement", async () => { + const now = new Date("2026-08-25T12:00:00.000Z"); + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const create = vi + .fn() + .mockImplementation(({ data }) => Promise.resolve({ id: "token-1", usedAt: null, ...data })); + const database = { userActionToken: { updateMany, create } }; + + const result = await issueUserActionToken( + { + userId: "user-1", + type: "PASSWORD_RESET", + emailSnapshot: " Bailey@Example.com ", + ttlMinutes: 30, + now, + }, + database as never, + ); + + expect(updateMany).toHaveBeenCalledWith({ + where: { userId: "user-1", type: "PASSWORD_RESET", usedAt: null }, + data: { usedAt: now }, + }); + expect(create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tokenHash: hashUserActionToken(result.rawToken), + emailSnapshot: "bailey@example.com", + expiresAt: new Date("2026-08-25T12:30:00.000Z"), + }), + }); + }); + + it("distinguishes invalid, used, and expired inspection without exposing a secret", async () => { + const now = new Date("2026-08-25T12:00:00.000Z"); + const findUnique = vi.fn(); + const database = { userActionToken: { findUnique } } as never; + + findUnique.mockResolvedValueOnce(null); + await expect( + inspectUserActionToken({ rawToken: "missing", type: "PASSWORD_RESET", now }, database), + ).resolves.toEqual({ state: "INVALID" }); + + findUnique.mockResolvedValueOnce({ + type: "PASSWORD_RESET", + usedAt: now, + expiresAt: new Date("2026-08-25T13:00:00.000Z"), + }); + await expect( + inspectUserActionToken({ rawToken: "used", type: "PASSWORD_RESET", now }, database), + ).resolves.toEqual({ state: "USED" }); + + findUnique.mockResolvedValueOnce({ + type: "PASSWORD_RESET", + usedAt: null, + expiresAt: new Date("2026-08-25T11:59:59.000Z"), + }); + await expect( + inspectUserActionToken({ rawToken: "expired", type: "PASSWORD_RESET", now }, database), + ).resolves.toEqual({ state: "EXPIRED" }); + }); + + it("claims once and runs the credential mutation in the same transaction", async () => { + const now = new Date("2026-08-25T12:00:00.000Z"); + const token = { + id: "token-1", + userId: "user-1", + type: "PASSWORD_RESET" as const, + tokenHash: hashUserActionToken("raw"), + emailSnapshot: "bailey@example.com", + expiresAt: new Date("2026-08-25T13:00:00.000Z"), + usedAt: null, + createdAt: now, + }; + const tx = { + userActionToken: { + findUnique: vi.fn().mockResolvedValue(token), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + }; + const database = { + $transaction: vi.fn((callback: (client: typeof tx) => unknown) => callback(tx)), + } as never; + const mutate = vi.fn().mockResolvedValue("password-updated"); + + const result = await consumeUserActionToken( + { rawToken: "raw", type: "PASSWORD_RESET", now }, + mutate, + database, + ); + + expect(result).toMatchObject({ state: "CONSUMED", value: "password-updated" }); + expect(tx.userActionToken.updateMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ id: token.id, usedAt: null, expiresAt: { gt: now } }), + data: { usedAt: now }, + }); + expect(mutate).toHaveBeenCalledWith(tx, expect.objectContaining({ usedAt: now })); + }); +}); diff --git a/tests/unit/authorization.test.ts b/tests/unit/authorization.test.ts new file mode 100644 index 00000000..1ae80312 --- /dev/null +++ b/tests/unit/authorization.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { + assertWorkspaceAction, + canPerformIntegrationAction, + canPerformProjectAction, + canPerformWorkspaceAction, +} from "@/server/services/authorization"; + +describe("workspace authorization", () => { + it("allows every member role to read the workspace", () => { + for (const role of ["OWNER", "ADMIN", "MEMBER", "GUEST"] as const) { + expect(canPerformWorkspaceAction(role, "READ_WORKSPACE")).toBe(true); + } + }); + + it("does not let guests create or mutate projects", () => { + expect(canPerformWorkspaceAction("GUEST", "CREATE_PROJECT")).toBe(false); + expect(canPerformWorkspaceAction("GUEST", "MUTATE_PROJECT")).toBe(false); + expect(() => assertWorkspaceAction("GUEST", "MUTATE_PROJECT")).toThrow(/workspace role/i); + }); + + it("preserves project mutation access for members and workspace admins", () => { + for (const role of ["OWNER", "ADMIN", "MEMBER"] as const) { + expect(canPerformWorkspaceAction(role, "CREATE_PROJECT")).toBe(true); + expect(canPerformWorkspaceAction(role, "MUTATE_PROJECT")).toBe(true); + } + expect(canPerformWorkspaceAction("MEMBER", "MANAGE_WORKSPACE")).toBe(false); + }); +}); + +describe("project authorization policy", () => { + it("gives members implicit read and contribution on workspace-visible projects", () => { + expect( + canPerformProjectAction({ + membershipRole: "MEMBER", + visibility: "WORKSPACE", + accessRole: null, + action: "CONTRIBUTE", + }), + ).toBe(true); + expect( + canPerformProjectAction({ + membershipRole: "MEMBER", + visibility: "WORKSPACE", + accessRole: null, + action: "MANAGE", + }), + ).toBe(false); + }); + + it("requires an explicit sufficient grant for guests and restricted projects", () => { + expect( + canPerformProjectAction({ + membershipRole: "GUEST", + visibility: "WORKSPACE", + accessRole: null, + action: "READ", + }), + ).toBe(false); + expect( + canPerformProjectAction({ + membershipRole: "MEMBER", + visibility: "RESTRICTED", + accessRole: null, + action: "READ", + }), + ).toBe(false); + expect( + canPerformProjectAction({ + membershipRole: "GUEST", + visibility: "RESTRICTED", + accessRole: "CONTRIBUTOR", + action: "CONTRIBUTE", + }), + ).toBe(true); + expect( + canPerformProjectAction({ + membershipRole: "GUEST", + visibility: "RESTRICTED", + accessRole: "VIEWER", + action: "CONTRIBUTE", + }), + ).toBe(false); + }); + + it("lets workspace admins manage any project", () => { + expect( + canPerformProjectAction({ + membershipRole: "ADMIN", + visibility: "RESTRICTED", + accessRole: null, + action: "MANAGE", + }), + ).toBe(true); + }); +}); + +describe("integration authorization policy", () => { + it("requires both project authority and the exact external capability", () => { + const base = { + membershipRole: "MEMBER" as const, + projectVisibility: "WORKSPACE" as const, + projectAccessRole: null, + grantedCapabilities: ["READ", "LINK"] as const, + }; + expect(canPerformIntegrationAction({ ...base, action: "LINK" })).toBe(true); + expect(canPerformIntegrationAction({ ...base, action: "SYNC" })).toBe(false); + expect(canPerformIntegrationAction({ ...base, action: "ADMIN" })).toBe(false); + }); + + it("does not let workspace admin status replace an integration grant", () => { + expect( + canPerformIntegrationAction({ + membershipRole: "OWNER", + projectVisibility: "RESTRICTED", + projectAccessRole: null, + grantedCapabilities: [], + action: "READ", + }), + ).toBe(false); + }); + + it("requires project access as well as a credential grant", () => { + expect( + canPerformIntegrationAction({ + membershipRole: "GUEST", + projectVisibility: "RESTRICTED", + projectAccessRole: null, + grantedCapabilities: ["READ"], + action: "READ", + }), + ).toBe(false); + }); +}); diff --git a/tests/unit/email.test.ts b/tests/unit/email.test.ts new file mode 100644 index 00000000..03df7751 --- /dev/null +++ b/tests/unit/email.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + buildAccountSetupEmail, + buildPasswordChangedEmail, + buildPasswordResetEmail, + sendPasswordResetEmail, + sendWorkspaceInviteEmail, + type TransactionalEmail, +} from "@/server/services/email"; + +const expiresAt = new Date("2026-08-25T20:00:00.000Z"); + +afterEach(() => { + delete process.env.FORGE_EMAIL_TEST_FAILURE; +}); + +describe("transactional email", () => { + it("renders account setup links and escapes user-controlled HTML", () => { + const email = buildAccountSetupEmail({ + to: "new@example.com", + name: "", + url: "https://forge.example/setup?token=a&next=b", + expiresAt, + }); + + expect(email.kind).toBe("account-setup"); + expect(email.text).toContain("https://forge.example/setup?token=a&next=b"); + expect(email.html).toContain("<Bailey>"); + expect(email.html).toContain("token=a&next=b"); + expect(email.html).not.toContain(""); + }); + + it("removes line breaks from invitation subject fields", async () => { + let delivered: TransactionalEmail | undefined; + await sendWorkspaceInviteEmail( + { + to: "member@example.com", + inviteUrl: "https://forge.example/invite/token", + workspaceName: "Forge\r\nBcc: attacker@example.com", + inviterName: "Bailey\nInjected", + expiresAt, + }, + { + transport: async (message) => { + delivered = message; + return "captured-message"; + }, + }, + ); + + expect(delivered?.subject).toBe( + "Bailey Injected invited you to Forge Bcc: attacker@example.com on Forge", + ); + expect(delivered?.subject).not.toMatch(/[\r\n]/); + }); + + it("renders a reset message without claiming the password already changed", () => { + const email = buildPasswordResetEmail({ + to: "member@example.com", + url: "https://forge.example/reset/token", + expiresAt, + }); + + expect(email.subject).toBe("Reset your Forge password"); + expect(email.text).toContain("single-use link"); + expect(email.text).toContain("Your password has not changed"); + }); + + it("renders a password-change security notification without a secret link", () => { + const email = buildPasswordChangedEmail({ + to: "member@example.com", + changedAt: new Date("2026-08-25T19:00:00.000Z"), + }); + + expect(email.kind).toBe("password-changed"); + expect(email.text).toContain("2026-08-25T19:00:00.000Z"); + expect(email.text).toContain("contact your Forge instance administrator immediately"); + expect(email.text).not.toMatch(/https?:\/\//); + }); + + it("passes the fully rendered message to an injected transport", async () => { + let delivered: TransactionalEmail | undefined; + const messageId = await sendPasswordResetEmail( + { + to: "member@example.com", + url: "https://forge.example/reset/token", + expiresAt, + }, + { + transport: async (message) => { + delivered = message; + return "captured-message"; + }, + }, + ); + + expect(messageId).toBe("captured-message"); + expect(delivered).toMatchObject({ kind: "password-reset", to: "member@example.com" }); + }); + + it("keeps existing invitation test delivery behavior", async () => { + const messageId = await sendWorkspaceInviteEmail({ + to: "member@example.com", + inviteUrl: "https://forge.example/invite/token", + workspaceName: "Forge", + inviterName: "Bailey", + expiresAt, + }); + + expect(messageId).toBe("test-workspace-invite"); + }); + + it("keeps the invitation failure contract used by integration tests", async () => { + process.env.FORGE_EMAIL_TEST_FAILURE = "1"; + + await expect( + sendWorkspaceInviteEmail({ + to: "member@example.com", + inviteUrl: "https://forge.example/invite/token", + workspaceName: "Forge", + inviterName: "Bailey", + expiresAt, + }), + ).rejects.toThrow("Forced invitation delivery failure."); + }); +}); diff --git a/tests/unit/local-credentials.test.ts b/tests/unit/local-credentials.test.ts new file mode 100644 index 00000000..10b5e785 --- /dev/null +++ b/tests/unit/local-credentials.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + PASSWORD_HASH_PREFIX, + hashPassword, + needsPasswordRehash, + verifyPassword, + verifyPasswordOrDummy, +} from "@/server/services/local-credentials"; + +describe("local credential hashing", () => { + it("creates salted, versioned scrypt hashes and verifies the right password", async () => { + const first = await hashPassword("correct horse battery staple"); + const second = await hashPassword("correct horse battery staple"); + + expect(first.startsWith(PASSWORD_HASH_PREFIX)).toBe(true); + expect(first).toContain("$n=32768,r=8,p=3$"); + expect(second).not.toBe(first); + await expect(verifyPassword("correct horse battery staple", first)).resolves.toBe(true); + await expect(verifyPassword("wrong password", first)).resolves.toBe(false); + expect(needsPasswordRehash(first)).toBe(false); + expect(needsPasswordRehash(first.replace("p=3", "p=1"))).toBe(true); + }); + + it("fails closed for malformed hashes and executes the dummy path", async () => { + await expect(verifyPassword("password", "not-a-forge-hash")).resolves.toBe(false); + await expect(verifyPasswordOrDummy("password", null)).resolves.toBe(false); + await expect(verifyPasswordOrDummy("password", "not-a-forge-hash")).resolves.toBe(false); + expect(needsPasswordRehash("not-a-forge-hash")).toBe(true); + }); + + it("rejects empty and unreasonably large password inputs", async () => { + await expect(hashPassword("")).rejects.toThrow(/must not be empty/i); + await expect(hashPassword("x".repeat(5000))).rejects.toThrow(/too long/i); + }); +}); diff --git a/tests/unit/user-avatar.test.ts b/tests/unit/user-avatar.test.ts new file mode 100644 index 00000000..e5712b52 --- /dev/null +++ b/tests/unit/user-avatar.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + detectAvatarMimeType, + globalStorageBucket, + MAX_AVATAR_SIZE_BYTES, +} from "@/server/services/user-avatar"; + +describe("user avatar validation", () => { + it.each([ + ["PNG", [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], "image/png"], + ["JPEG", [0xff, 0xd8, 0xff, 0xe0], "image/jpeg"], + ["GIF87a", [...Buffer.from("GIF87a")], "image/gif"], + ["GIF89a", [...Buffer.from("GIF89a")], "image/gif"], + ["WebP", [...Buffer.from("RIFF0000WEBP")], "image/webp"], + ])("detects %s signatures", (_label, bytes, expected) => { + expect(detectAvatarMimeType(Uint8Array.from(bytes as number[]))).toBe(expected); + }); + + it("rejects executable, SVG, and truncated content", () => { + expect(detectAvatarMimeType(Buffer.from(""))).toBeNull(); + expect(detectAvatarMimeType(Buffer.from("MZ executable"))).toBeNull(); + expect(detectAvatarMimeType(Uint8Array.from([0x89, 0x50]))).toBeNull(); + }); + + it("uses a dedicated configurable global bucket", () => { + const previous = process.env.S3_GLOBAL_BUCKET; + try { + delete process.env.S3_GLOBAL_BUCKET; + expect(globalStorageBucket()).toBe("forge-global"); + process.env.S3_GLOBAL_BUCKET = "forge-identity-assets"; + expect(globalStorageBucket()).toBe("forge-identity-assets"); + process.env.S3_GLOBAL_BUCKET = "INVALID_BUCKET"; + expect(() => globalStorageBucket()).toThrow(/valid S3 bucket/i); + } finally { + if (previous === undefined) delete process.env.S3_GLOBAL_BUCKET; + else process.env.S3_GLOBAL_BUCKET = previous; + } + }); + + it("keeps the upload cap bounded to five MiB", () => { + expect(MAX_AVATAR_SIZE_BYTES).toBe(5 * 1024 * 1024); + }); +}); From cc5df0caba35ad16c3b639a2217fca0328733c0f Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 25 Aug 2026 20:11:56 -0400 Subject: [PATCH 2/2] test: serialize command center issue fixtures --- .../__tests__/command-center-action-requests.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/server/routers/__tests__/command-center-action-requests.test.ts b/src/server/routers/__tests__/command-center-action-requests.test.ts index b1b9a544..b2ddff91 100644 --- a/src/server/routers/__tests__/command-center-action-requests.test.ts +++ b/src/server/routers/__tests__/command-center-action-requests.test.ts @@ -27,10 +27,12 @@ describe("commandCenterRouter — action requests", () => { const fixture = await createWorkspaceFixture({ keyPrefix: "CA" }); fixtures.push(fixture); const prisma = getPrisma(); - const issues = await Promise.all([ - createIssue(fixture, { title: "Quiet delivery" }), - createIssue(fixture, { title: "Stale delivery" }), - ]); + // createIssue allocates max(number) + 1 for a fixture. Keep these + // sequential so the test does not manufacture a duplicate issue number. + const issues = [ + await createIssue(fixture, { title: "Quiet delivery" }), + await createIssue(fixture, { title: "Stale delivery" }), + ]; const sessions = await Promise.all( ["quiet", "stale"].map((suffix, index) => prisma.workSession.create({