diff --git a/.claude/commands/audit-deps.md b/.claude/commands/audit-deps.md deleted file mode 100644 index b7a96b2..0000000 --- a/.claude/commands/audit-deps.md +++ /dev/null @@ -1,58 +0,0 @@ -# Dependency Audit Command - -Review package.json for security vulnerabilities and available updates. - -## Instructions - -Perform a comprehensive security and update audit of the project's dependencies: - -### 1. Vulnerability Analysis -- Run `npm audit` to identify known vulnerabilities -- Search the web for recent CVEs affecting major dependencies (Next.js, React, auth libraries, ORMs) -- Check Snyk and NVD databases for any critical issues -- Classify vulnerabilities by severity (Critical, High, Moderate, Low) - -### 2. Update Analysis -- Run `npm outdated` to identify available updates -- Categorize updates as: - - **Safe updates**: Patch and minor versions that can be applied immediately - - **Major updates**: Require evaluation of breaking changes and migration effort - -### 3. Use Context7 -- Query Context7 for migration guides and breaking changes for any major version updates -- Check official documentation for upgrade paths - -### 4. Generate Report -Provide a structured report with: - -#### Security Issues (by severity) -- Critical/High: Immediate action required with specific fix commands -- Moderate/Low: Assessment of risk and recommended timeline - -#### Recommended Updates -| Package | Current | Latest | Risk Level | Notes | -|---------|---------|--------|------------|-------| - -#### Action Plan -1. **Urgent**: Commands to fix critical vulnerabilities -2. **Soon**: Safe updates to apply -3. **Plan for**: Major version upgrades requiring testing - -### 5. Optional Execution -If the user requests, execute the recommended action plan: -- Apply safe updates -- Run `npm run build` to verify no breaking changes -- Run `npm run lint` to check for issues -- Report final status - -## Key Dependencies to Always Check -- next / eslint-config-next (framework security) -- react / react-dom (core framework) -- better-auth (auth/session) -- zod (validation — v4 API differs from v3) -- @grapesjs/react / grapesjs / grapesjs-mjml / mjml (template editor) -- docx / docxtemplater / docxtemplater-image-module-free / pizzip (Word merge) -- openai (LLM client, if used by tools) -- @react-pdf/renderer (PDF output) -- vitest / @vitest/coverage-v8 / @vitejs/plugin-react (test runner) -- typescript (toolchain) diff --git a/.claude/commands/branch-commit.md b/.claude/commands/branch-commit.md deleted file mode 100644 index db65f4a..0000000 --- a/.claude/commands/branch-commit.md +++ /dev/null @@ -1,85 +0,0 @@ -# Branch and Commit Command - -Create a new branch from the current branch and commit all staged/unstaged changes with detailed notes. Optionally link to a GitHub issue. - -## Instructions - -1. First, check the current git status to see what changes exist -2. If a GitHub issue ID is provided (e.g., `#123` or just `123`): - - Fetch the issue details using `gh issue view --json title,body,labels` - - Use the issue title and details to auto-generate: - - Branch name: `feature/issue--` or `fix/issue--` - - Commit message referencing the issue (e.g., "Fix #123: Issue title") - - Include issue context in commit notes -3. If no issue ID provided, ask the user for: - - Branch name (suggest a name based on the changes if possible) - - Commit message summary (1 line) - - Detailed notes for the commit body (optional) -4. Create the new branch from the current branch -5. Stage all changes (both tracked and untracked files) -6. Create the commit with the provided message and notes -7. Push the branch to the remote repository -8. Show the user the result including: - - The new branch name - - The commit hash - - A summary of files changed - - Link to the GitHub issue (if applicable) - - Confirmation that the branch was pushed - -## Arguments - -- `$ARGUMENTS` - Optional arguments in any of these formats: - - `#123` or `123` - GitHub issue ID (will fetch issue details and auto-generate branch/commit) - - `branch-name: commit message` - Manual branch name and commit message - - `#123 branch-name: commit message` - Issue ID with custom branch/commit (issue will be referenced) - -## Workflow - -### With GitHub Issue -``` -gh issue view --json title,body,labels,number -git status -git checkout -b feature/issue-- -git add -A -git commit -m "Fix #: " -m "" -git push -u origin feature/issue-- -git log -1 --stat -``` - -### Without GitHub Issue -``` -git status -git checkout -b -git add -A -git commit -m "" -m "" -git push -u origin -git log -1 --stat -``` - -## Branch Naming Convention - -When using a GitHub issue, the branch name will be auto-generated: -- `fix/issue--` - For bug fixes (issues with "bug" label) -- `feature/issue--` - For features/enhancements -- `` is derived from the issue title (lowercase, hyphens, max 50 chars) - -## Commit Message Format - -When referencing a GitHub issue: -``` - #: - - - -Closes # - -Co-Authored-By: Claude Opus 4.7 (1M context) -``` - -## Notes - -- Always push the new branch to remote with `-u` flag to set upstream tracking -- Follow conventional commit format if the project uses it -- Include "Co-Authored-By: Claude Opus 4.7 (1M context) " in commits -- Use "Closes #" in commit body to auto-close the issue when merged -- If `gh` CLI is not available or issue fetch fails, fall back to manual input diff --git a/.claude/commands/pr.md b/.claude/commands/pr.md deleted file mode 100644 index 5afe1bd..0000000 --- a/.claude/commands/pr.md +++ /dev/null @@ -1,136 +0,0 @@ -# Pull Request Command - -Create a pull request for the current branch after validating all prerequisites are met. - -## Instructions - -1. **Validate branch state:** - - Check current branch name - must NOT be `main`, `master`, or `dev` - - If on main/master/dev, abort and inform the user to create/switch to a feature branch first - -2. **Check for uncommitted changes:** - - Run `git status` to check for staged or unstaged changes - - If uncommitted changes exist, ask the user if they want to: - - Commit them first (offer to run /branch-commit) - - Stash them and continue - - Abort the PR creation - -3. **Check if branch is pushed to origin:** - - Run `git ls-remote --heads origin ` to verify branch exists on remote - - If not pushed, offer to push with `git push -u origin ` - - Verify local branch is up to date with remote (no unpushed commits) - -4. **Check if PR already exists:** - - Run `gh pr list --head --state open --json number,title,url` - - If PR exists, show the existing PR details and ask if user wants to view it - - Abort PR creation if one already exists - -5. **Gather PR information:** - - Get the base branch (usually `main`) - confirm with user if needed - - Run `git log ..HEAD --oneline` to see commits being included - - Run `git diff ...HEAD --stat` to see files changed - - Look for linked GitHub issues in commit messages (patterns like `#123`, `Fix #123`, `Closes #123`) - -6. **Generate PR content:** - - If a linked issue is found, fetch issue details with `gh issue view --json title,body,labels` - - Generate a title based on: - - Linked issue title (if available) - - Or summarize from commit messages - - Generate body with: - - Summary section (2-3 bullet points describing changes) - - Test plan section (checklist of testing items) - - Link to issue (if applicable) - - Footer with Claude Code attribution - -7. **Create the PR:** - - Use `gh pr create --title "" --body "<body>" --base <base-branch>` - - Show the created PR URL to the user - -8. **Post-creation:** - - Display the PR URL - - Show summary of what was included - -## Arguments - -- `$ARGUMENTS` - Optional arguments: - - `--base <branch>` - Specify base branch (default: main) - - `--draft` - Create as draft PR - - `--title "<title>"` - Override auto-generated title - - `#123` or `123` - Link to specific GitHub issue (overrides auto-detection) - -## Validation Checklist - -Before creating PR, ensure all checks pass: -- [ ] Not on main/master/dev branch -- [ ] No uncommitted changes (or handled) -- [ ] Branch exists on origin -- [ ] No unpushed commits -- [ ] No existing open PR for this branch - -## PR Format - -```markdown -## Summary -<2-3 bullet points describing the changes> - -## Test plan -- [ ] <Testing item 1> -- [ ] <Testing item 2> -- [ ] <Testing item 3> - ---- -Closes #<issue-id> (if applicable) - -Generated with [Claude Code](https://claude.ai/code) -``` - -## Example Workflow - -```bash -# 1. Check current branch -git branch --show-current - -# 2. Check for uncommitted changes -git status --porcelain - -# 3. Check if branch exists on remote -git ls-remote --heads origin $(git branch --show-current) - -# 4. Check for unpushed commits -git log origin/$(git branch --show-current)..HEAD --oneline 2>/dev/null - -# 5. Check for existing PR -gh pr list --head $(git branch --show-current) --state open --json number,title,url - -# 6. Get commits for this branch -git log main..HEAD --oneline - -# 7. Get diff stats -git diff main...HEAD --stat - -# 8. Create the PR -gh pr create --title "Title" --body "$(cat <<'EOF' -## Summary -- Change 1 -- Change 2 - -## Test plan -- [ ] Test item - -Generated with [Claude Code](https://claude.ai/code) -EOF -)" -``` - -## Error Handling - -- If `gh` CLI is not installed or not authenticated, provide instructions for setup -- If any validation fails, clearly explain what needs to be fixed -- Always show the user what commands would be run before executing them - -## Notes - -- Always confirm the base branch with the user if it's not `main` -- Include "Generated with [Claude Code](https://claude.ai/code)" in PR body -- Use HEREDOC for PR body to handle multiline content and special characters -- If commits reference an issue with "Fix #X" or "Closes #X", include that reference in the PR diff --git a/.claude/references/GLOSSARY.md b/.claude/references/GLOSSARY.md index e67fcfe..cfb0817 100644 --- a/.claude/references/GLOSSARY.md +++ b/.claude/references/GLOSSARY.md @@ -142,7 +142,7 @@ Helper that escapes `'` -> `''`, `%` -> `[%]`, `_` -> `[_]` before interpolating ## genericOAuth (aliases: generic OAuth plugin) -Better Auth plugin wiring arbitrary OIDC providers; configured with `providerId: "ministry-platform"`, OIDC discovery URL, `offline_access` + MP all scope, `pkce: false`, and `realm=realm` authorization param. Callback URL: `/api/auth/oauth2/callback/{providerId}`. +Better Auth plugin wiring arbitrary OIDC providers; configured with `providerId: "ministryplatform"`, OIDC discovery URL, `offline_access` + MP all scope, `pkce: false` (MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`), `disableIdTokenNonceBinding: true` (MP omits the claim), and `realm=realm` authorization param. As of Better Auth 1.7 it registers providers as first-class **social** providers, so the callback URL is the core `/api/auth/callback/{providerId}` — not the former `/api/auth/oauth2/callback/{providerId}`. **Not to be confused with:** `client credentials flow` (server-to-server, no user). **Defined in:** `src/lib/auth.ts:32` diff --git a/.claude/references/auth/oauth-flow.md b/.claude/references/auth/oauth-flow.md index 856cf1a..7bff4a6 100644 --- a/.claude/references/auth/oauth-flow.md +++ b/.claude/references/auth/oauth-flow.md @@ -12,7 +12,7 @@ last_verified: 2026-04-17 End-to-end OAuth2/OIDC flow against Ministry Platform: sign-in, token exchange, profile mapping, and OIDC-style logout via `endsession`. ## Files -- `src/lib/auth.ts` — `genericOAuth` provider config (`providerId: "ministry-platform"`) +- `src/lib/auth.ts` — `genericOAuth` provider config, exported as `ministryPlatformProviderConfig` (`providerId: "ministryplatform"`) - `src/app/signin/page.tsx` — client page that calls `authClient.signIn.oauth2(...)` - `src/app/api/auth/[...all]/route.ts` — Better Auth route handler via `toNextJsHandler(auth)` - `src/components/user-menu/actions.ts` — `handleSignOut()` server action for OIDC logout @@ -20,13 +20,15 @@ End-to-end OAuth2/OIDC flow against Ministry Platform: sign-in, token exchange, - `src/components/user-menu/actions.test.ts` — tests for `handleSignOut` ## Key concepts -- Provider is registered under **`providerId: "ministry-platform"`** (`src/lib/auth.ts:35`). This string is the key for both the OAuth callback URL and `signIn.oauth2({ providerId })`. +- Provider is registered under **`providerId: "ministryplatform"`** (exported as `ministryPlatformProviderConfig` in `src/lib/auth.ts`). This string is the key for the OAuth callback URL, the deny-by-default allowlist in `src/app/api/auth/[...all]/route.ts`, and `signIn.social({ provider })`. All three must agree; `src/auth.test.ts` pins it. - **OIDC discovery** is used — no hand-wired endpoints. `discoveryUrl` points at MP's well-known config. -- **PKCE is disabled by design** (`pkce: false`, `src/lib/auth.ts:44`) — MP's OIDC provider requires the `realm=realm` authorization-URL workaround (`authorizationUrlParams`, `src/lib/auth.ts:45-47`) and does not support PKCE. This is a permanent constraint, not a TODO. +- **PKCE is DISABLED** (`pkce: false`) — and this is a permanent constraint, not a TODO. **MP's discovery document advertises `code_challenge_methods_supported: ["plain", "S256"]`, but MP does not honour the verifier at the token endpoint.** With PKCE on, the authorize leg succeeds and returns a code, then the exchange fails with `invalid_grant` (400) and the user lands on `/auth-error?error=invalid_code`. Verified against a live tenant on 2026-09-13. Do not re-enable this on the strength of the discovery document; `src/auth.test.ts` pins it off. +- **id_token nonce binding is DISABLED** (`disableIdTokenNonceBinding: true`). As of Better Auth 1.7 any provider with a `discoveryUrl` publishing a JWKS binds the id_token to the authorization request by default; **MP does not echo the `nonce` claim**, so leaving it on fails every sign-in with `unable_to_get_user_info`. See `../security/README.md`. - MP requires a **`realm=realm`** authorization URL param (`authorizationUrlParams`, `src/lib/auth.ts:45-47`). -- **`getUserInfo`** fetches `${MP_BASE_URL}/oauth/connect/userinfo` with the access token and returns `{ id: profile.sub, email, name, image: undefined, emailVerified: true }`. -- **`mapProfileToUser`** persists the OIDC `sub` claim as the custom `userGuid` field. See `user-identity.md`. -- **OAuth callback URL (convention):** `${APP_URL}/api/auth/oauth2/callback/ministry-platform` — Better Auth's `genericOAuth` pattern `/api/auth/oauth2/callback/{providerId}`. Must be registered on the MP OAuth client. +- **`getUserInfo`** fetches `${MP_BASE_URL}/oauth/connect/userinfo` with the access token and returns `{ sub, email: <synthetic>, mpEmail, name, image: undefined, emailVerified: <from claim> }`. It returns `null` (never throws) on a bad response or an unusable `sub`. **The key must be `sub`, not `id`** — Better Auth 1.7 derives the provider account key from `accountSubject`, and the 1.6 `id` shape fails with `OAUTH_ACCOUNT_SUBJECT_INVALID` after a successful token exchange. +- **`accountSubject`** is declared explicitly as `({ profile }) => String(profile.sub ?? "")`, so the account key never depends on the boot-time discovery fetch inferring `isOidc`. +- **`mapProfileToUser`** persists the OIDC `sub` as `userGuid`, and sets `email` to a **synthetic** `<sub>@mp.invalid` address so Better Auth cannot key two MP users onto one record via a shared household email. The real address is kept as `mpEmail`. See `user-identity.md` and `../security/README.md#identity`. +- **OAuth callback URL:** `${APP_URL}/api/auth/callback/ministryplatform` — the **core** `/api/auth/callback/{providerId}` pattern. Must be registered on the MP OAuth client. **This path changed in Better Auth 1.7**: genericOAuth no longer mounts its own `/api/auth/oauth2/callback/{providerId}` endpoint. - **Sign-out is a two-step flow:** clear local Better Auth session via `auth.api.signOut(...)`, then `redirect(...)` the browser to MP's `/oauth/connect/endsession?post_logout_redirect_uri=...`. - `id_token_hint` is **not** passed to `endsession` (optional in OIDC). `post_logout_redirect_uri` must be pre-registered on the MP OAuth client. @@ -34,15 +36,16 @@ End-to-end OAuth2/OIDC flow against Ministry Platform: sign-in, token exchange, | Setting | Value | Source | |---|---|---| -| `providerId` | `"ministry-platform"` | `auth.ts:35` | +| `providerId` | `"ministryplatform"` | `ministryPlatformProviderConfig` | | `discoveryUrl` | `${MP_BASE_URL}/oauth/.well-known/openid-configuration` | `auth.ts:36` | | `clientId` | `process.env.MINISTRY_PLATFORM_CLIENT_ID!` | `auth.ts:37` | | `clientSecret` | `process.env.MINISTRY_PLATFORM_CLIENT_SECRET!` | `auth.ts:38` | | `scopes` | `["openid", "offline_access", "http://www.thinkministry.com/dataplatform/scopes/all"]` | `auth.ts:39-43` | -| `pkce` | `false` | `auth.ts:44` | +| `pkce` | `false` (MP advertises S256 but rejects the exchange) | `ministryPlatformProviderConfig` | +| `disableIdTokenNonceBinding` | `true` (MP omits the claim) | `ministryPlatformProviderConfig` | | `authorizationUrlParams` | `{ realm: "realm" }` | `auth.ts:45-47` | | `getUserInfo` | custom `fetch(${MP_BASE_URL}/oauth/connect/userinfo)` | `auth.ts:48-76` | -| `mapProfileToUser` | `(profile) => ({ userGuid: profile.id }) as Record<string, unknown>` | `auth.ts:82-86` | +| `mapProfileToUser` | `{ userGuid, email: synthetic, mpEmail }` | `ministryPlatformProviderConfig` | ## `getUserInfo` implementation (verbatim) @@ -103,14 +106,15 @@ mapProfileToUser: (profile) => { 2. src/proxy.ts → no session cookie → 302 /signin?callbackUrl=<original> 3. /signin (src/app/signin/page.tsx): - authClient.getSession() — if already signed in, redirect to callbackUrl - - Else authClient.signIn.oauth2({ providerId: "ministry-platform", callbackURL }) + - Else authClient.signIn.social({ provider: "ministryplatform", callbackURL }) 4. Browser → MP /oauth/connect/authorize?... (with realm=realm, scopes) 5. User authenticates at MP -6. MP → ${APP_URL}/api/auth/oauth2/callback/ministry-platform?code=... +6. MP → ${APP_URL}/api/auth/callback/ministryplatform?code=... 7. Better Auth (via toNextJsHandler): a. Exchanges code for tokens - b. getUserInfo(tokens) → { id: sub, email, name, image, emailVerified: true } - c. mapProfileToUser(profile) → { userGuid: profile.id } + b. getUserInfo(tokens) → { sub, email: <sub>@mp.invalid, mpEmail, name, emailVerified: <claim> } + b2. accountSubject({ profile }) → sub (the provider account key) + c. mapProfileToUser(profile) → { userGuid: sub, email: <sub>@mp.invalid, mpEmail } d. Creates user (id=generated, userGuid=sub, email, name) e. Creates account (accountId=sub, tokens) — storeAccountCookie: true f. Creates session → sets JWT cookie (cookieCache) @@ -121,13 +125,19 @@ mapProfileToUser: (profile) => { ## Sign-in entry (verbatim from `src/app/signin/page.tsx`) ```typescript -// src/app/signin/page.tsx:25-28 -authClient.signIn.oauth2({ - providerId: "ministry-platform", - callbackURL: callbackUrl, +// src/app/signin/sign-in-content.tsx +authClient.signIn.social({ + provider: "ministryplatform", + callbackURL: sanitizeCallbackUrl(searchParams?.get("callbackUrl")), }); ``` +Better Auth 1.7 removed `signIn.oauth2` along with the `genericOAuthClient` +plugin; generic providers now go through core `signIn.social`, and the field is +`provider`, not `providerId`. The page body lives in `sign-in-content.tsx` +because `page.tsx` must stay a server component to opt out of prerendering — +see `../security/README.md#nonces-force-dynamic-rendering`. + ## Sign-out flow (verbatim from `src/components/user-menu/actions.ts`) ```typescript @@ -167,12 +177,13 @@ export async function handleSignOut() { ## MP OAuth client setup Register these URLs on the MP OAuth client: -- **Redirect URI:** `${APP_URL}/api/auth/oauth2/callback/ministry-platform` +- **Redirect URI:** `${APP_URL}/api/auth/callback/ministryplatform` - **Post-logout redirect URI:** value of `BETTER_AUTH_URL` (or `NEXTAUTH_URL`) ## Gotchas - `user.id` ≠ `userGuid`. See `user-identity.md`. -- `emailVerified` is always `true` — no client-side re-verification. `src/lib/auth.ts:74` +- `emailVerified` now reports the provider's actual `email_verified` claim (it used to be hardcoded `true`, which unconditionally satisfied Better Auth's implicit account-linking condition). +- `session.user.email` is a synthetic `<guid>@mp.invalid` address, NOT the user's real one. Read `mpEmail`, or `MPUserProfile.Email_Address` from `UserService`. - PKCE disabled by design — `pkce: false` at `src/lib/auth.ts:44`. MP's OIDC provider does not support PKCE and requires the `realm=realm` workaround instead (see Key concepts above). - No `id_token_hint` on `endsession` — `post_logout_redirect_uri` **must** be pre-registered on MP. - `handleSignOut` falls back to `http://localhost:3000` if neither `BETTER_AUTH_URL` nor `NEXTAUTH_URL` is set (`src/components/user-menu/actions.ts:20`). Deploying without the env var will 302 to localhost. diff --git a/.claude/references/auth/route-protection.md b/.claude/references/auth/route-protection.md index ee7d94f..248e8ca 100644 --- a/.claude/references/auth/route-protection.md +++ b/.claude/references/auth/route-protection.md @@ -136,8 +136,8 @@ Mounted via: 1. User hits `/tools/addresslabels?s=123&pageID=456`. 2. Proxy: no cookie → 302 to `/signin?callbackUrl=/tools/addresslabels?s=123&pageID=456`. -3. `/signin` page reads `callbackUrl` from `searchParams`, calls `authClient.signIn.oauth2({ providerId: "ministry-platform", callbackURL })`. -4. After OIDC round-trip → `/api/auth/oauth2/callback/ministry-platform` → Better Auth redirects browser to `callbackURL`. +3. `/signin` page reads `callbackUrl` from `searchParams`, **sanitizes it** (`sanitizeCallbackUrl` — it must be a path on this origin, never `//host` or `/\host`), then calls `authClient.signIn.social({ provider: "ministryplatform", callbackURL })`. +4. After OIDC round-trip → `/api/auth/callback/ministryplatform` → Better Auth redirects browser to `callbackURL`. 5. Request for `/tools/addresslabels?s=123&pageID=456` now has a session cookie; proxy passes through with `x-pathname` set. 6. `AuthWrapper` confirms decoded session — if ever absent, falls back to `/signin?callbackUrl=<x-pathname value or "/">`. diff --git a/.claude/references/data-flow/call-graphs.md b/.claude/references/data-flow/call-graphs.md index 81ed6a0..a64c5de 100644 --- a/.claude/references/data-flow/call-graphs.md +++ b/.claude/references/data-flow/call-graphs.md @@ -23,7 +23,7 @@ last_verified: 2026-04-17 7. `src/components/layout/auth-wrapper.tsx:6-7` — `await headers()` then `auth.api.getSession({ headers })`. 8. `src/components/layout/auth-wrapper.tsx:9-17` — on no session: read `x-pathname`, build `/signin?callbackUrl=...`, `redirect(...)`. 9. Browser lands on `src/app/signin/page.tsx:7` (`SignInContent`). -10. `src/app/signin/page.tsx:25-28` — `authClient.signIn.oauth2({ providerId: "ministry-platform", callbackURL })` kicks off OAuth. See `../auth/oauth-flow.md`. +10. `src/app/signin/sign-in-content.tsx` — `authClient.signIn.social({ provider: "ministryplatform", callbackURL })` kicks off OAuth. See `../auth/oauth-flow.md`. **Side effects:** - Request header set (internal): `x-pathname = <pathname + search>` (`src/proxy.ts:13`). diff --git a/.claude/references/security/README.md b/.claude/references/security/README.md new file mode 100644 index 0000000..9dbea02 --- /dev/null +++ b/.claude/references/security/README.md @@ -0,0 +1,426 @@ +# Security Reference + +Entry point for the authorization model, the endpoint surface, and the header +policy. Read this before changing anything under `src/lib/auth.ts`, +`src/proxy.ts`, `src/app/api/auth/`, or `src/services/authorizationService.ts`. + +| Question | Read | +|---|---| +| Who is allowed to use a tool, and where is that enforced? | [Authorization policy](#authorization-policy) | +| Which Better Auth endpoints are reachable? | [Endpoint surface](#endpoint-surface) | +| Why is `style-src 'unsafe-inline'`? Can I tighten it? | [Headers and CSP](#headers-and-csp) | +| How is a user identified, and why is their email `@mp.invalid`? | [Identity](#identity) | +| What was fixed, and what is still open? | [Findings](#findings) | + +--- + +## Authorization policy + +> **Any MP user may sign in and use the app shell. The tools require an MP +> security role.** + +Sign-in is deliberately **not** role-gated — no check in `getUserInfo`, +`mapProfileToUser`, `customSession` or `AuthWrapper` — so a role-less user still +gets a session, the header, the user menu and a **working sign-out**. Refusing +at sign-in would strand them in the app with no way out. They are redirected +from `/tools/*` to `/no-access`, which renders inside the shell. + +### Why authentication is not enough + +MP's OIDC endpoint authenticates **any** `dp_Users` record, and this app fetches +all MP data with its own client-credentials service account +(`dataplatform/scopes/all`). **MP's per-user record security therefore never +applies to what this app returns.** A Better Auth session proves only that +*some* MP user signed in. + +The sharpest surface here is field management: `updatePageFieldOrder` rewrites +`dp_Page_Fields` for the **entire MP domain**, not just the caller. + +### Three enforcement layers + +A server action is a callable POST endpoint whether or not its page ever +rendered, so the gate runs at every layer that is independently reachable: + +| Layer | File | Call | +|---|---|---| +| Page | `src/app/(web)/tools/layout.tsx` | `hasSecurityRole()` then `redirect("/no-access")` | +| Action | `src/components/<feature>/actions.ts` | `requireAccess()` wrapping `requireSecurityRole()` | +| Service | `src/services/*.ts` | `requireSecurityRole()` on **every** method, reads included | + +A layout gate covers its child pages for free — React renders the layout first +and only renders `children` once it returns. + +### `AuthorizationService` design points + +- **Two entry points.** `requireSecurityRole()` throws `UnauthorizedError` and + logs a structured denial — it is the enforcement point, and it returns the + acting MP `User_ID`. `hasSecurityRole()` returns a decision without logging — + for UI affordances and the layout redirect, **never** as enforcement. +- **Per-request memoization via React `cache()`** — not module-level, not TTL. + The gate runs up to three times per request; this collapses that to one MP + read. Nothing crosses requests, which is what keeps a role revoked in MP + effective on the user's *very next* request. +- **Fails closed.** No session, no `userGuid`, no `dp_Users` row, or no role + means refused. +- **Infrastructure failures throw; they do not return `permitted: false`.** A + caller must never mistake "MP is down" for "this user is not allowed". +- **Config, not code:** `MP_SECURITY_ROLES` (comma-separated, case-insensitive). + Blank or unset means "any MP security role will do". Tighten without a deploy. + +### Write attribution + +`$userId` has exactly **one** source: the gate's return value, applied in the +**service**. Actions never assemble it, and no server action accepts a `userId` +parameter. `ToolService.getSelectionRecordIds` takes its `@UserID` from the gate +for the same reason — a selection belongs to a specific MP user, so accepting one +from the payload would let any caller read someone else's selection. + +### Carve-outs + +Four call sites use a plain session check because they touch no per-person MP +data. Each documents why **in-file**. Adding a fifth needs the same +justification, in the file, in writing. + +- `components/layout/auth-wrapper.tsx` — it *is* the session gate +- `components/shared-actions/user.ts` — the user's own profile +- `components/shared-actions/domain.ts` — the domain-wide time zone (one string) +- `components/dev-panel/panels/require-dev-session.ts` — dev-only + (`NODE_ENV !== "production"`), and the services it calls gate anyway + +--- + +## Endpoint surface + +`toNextJsHandler(auth)` mounts roughly thirty Better Auth endpoints. This app's +browser client calls **three**. `src/app/api/auth/[...all]/route.ts` is +therefore **deny-by-default**: + +``` +GET /get-session +GET /callback/ministryplatform +POST /sign-in/social +``` + +These were read off the installed version, not assumed — **and they changed in +Better Auth 1.7.** The genericOAuth plugin no longer mounts endpoints of its +own; it registers its providers as first-class **social** providers, so sign-in +goes through the **core** `/sign-in/social` and `/callback/:id` endpoints. + +On 1.6 the paths were `POST /sign-in/oauth2` and +`GET /oauth2/callback/:providerId`, and `/oauth2/link` existed as the plugin's +account-linking endpoint. None of those exist any more, and all three now 404. + +The `ministryplatform` segment is the `providerId` from +`ministryPlatformProviderConfig`. The provider id, this allowlist entry and the +sign-in page's `signIn.social({ provider })` must all agree — `src/auth.test.ts` +pins that. + +**Re-enumerate this list on every Better Auth upgrade.** A stale entry fails +closed — sign-in 404s loudly rather than silently opening something — which is +the behaviour to want, but it is still an outage. + +Everything else returns a plain 404 without reaching Better Auth — including any +endpoint a **future** Better Auth version adds. That is the point of +deny-by-default, and it is why this is the *primary* control, with +`disabledAuthPaths` in `src/lib/auth.ts` as defence in depth. + +- `/sign-out` is deliberately **absent**: sign-out runs server-side through + `auth.api.signOut`, which never crosses this HTTP boundary. Moving it to + `authClient.signOut()` would 404 — loudly, which is the point. +- `/oauth2/link` no longer exists in 1.7 (it was genericOAuth's account-linking + endpoint); core `/link-social` and `/unlink-account` are deliberately absent. +- **`onAPIError.errorURL` is set to `/auth-error`.** Better Auth's default is + `/api/auth/error`, which the allowlist now 404s — without this, a failed + sign-in dead-ends on a blank 404. +- **`/auth-error` is allowlisted as public in `src/proxy.ts`.** Without it an + unauthenticated visitor bounces to `/signin`, which auto-starts OAuth again, + looping forever on the very failure the page exists to explain. + +The error page maps the codes Better Auth **actually** emits (read off the +installed version — e.g. the literal is `oAuth_code_missing`, with that exact +capitalization) and **never renders `error_description`**, which is +attacker-influencable text arriving on a redirect. Its lookup is a `Map`, not an +object literal: the key is an arbitrary query value, and `?error=constructor` +against a plain object returns an inherited `Object.prototype` member. + +--- + +## Headers and CSP + +Split across two files, and the split is not arbitrary: + +| Where | Headers | Why there | +|---|---|---| +| `next.config.ts` on `/(.*)` | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS (prod only) | Request-independent, and reaches `/api` plus the static paths the proxy matcher skips | +| `src/proxy.ts` via `src/lib/security-headers.ts` | `Content-Security-Policy` | The nonce must be fresh per request; a build-time value is a constant an attacker reads off any page | + +Anti-framing is expressed **twice on purpose** — `X-Frame-Options` reaches the +routes the proxy skips, `frame-ancestors` covers the rest. They are not both CSP +headers: two `Content-Security-Policy` headers on one response are enforced as +an **intersection**, which is miserable to debug. + +HSTS is production-only, with **no `preload`** — that is a one-way submission to +a browser-vendor list and the deploying church's call, not a repo default. + +### Three loosenings — do NOT "tighten" these into an outage + +1. **`style-src 'unsafe-inline'`, with NO nonce.** Radix's dialog pulls in + react-remove-scroll, which locks body scroll by **injecting a `<style>` + element** at runtime. That is an element, not an attribute, so + `style-src-attr` never applies. A nonce cannot help (the element is created + by script long after the server chose the nonce) and a hash cannot either + (the content embeds the computed scrollbar width, so it varies by platform + and zoom). **The nonce must stay out of this directive** — CSP3 browsers + ignore `'unsafe-inline'` whenever a nonce sits beside it, which is exactly + the trap that produces a policy that looks correct and breaks every dialog. + `src/app/global-error.tsx` also depends on this; the two are coupled. +2. **`form-action` includes the MP origin.** Sign-out is a form-driven server + action ending in a redirect to MP's endsession endpoint, and browsers apply + `form-action` to the **whole redirect chain**, not just its first hop. +3. **`img-src` includes the MP origins**, for contact photos served from MP. + +### Nonces force dynamic rendering + +Next reads the nonce off the **incoming request headers** at render time, so the +proxy sets the CSP on the request *and* the response. A page prerendered at +build time has no request, therefore no nonce, therefore a blocked bootstrap +script and no hydration. + +`/signin` and `/session-error` are pinned with `export const dynamic = "force-dynamic"`. +**The trap: route segment config is silently IGNORED in a `"use client"` module** — +it sits inert, the build still reports the route as static, and the page still +fails to hydrate with nothing to explain why. That is why `/signin`'s body lives +in `sign-in-content.tsx` and its `page.tsx` is a server component. +`src/app/signin/page.test.tsx` pins **both** halves — the export, and the absence +of the directive. + +`/_not-found` remains static and nonce-less. Accepted: it is Next's built-in 404 +with no interactivity to lose. + +### `CSP_ENFORCE` + +**Enforces by default.** Only the exact string `"false"` drops to report-only, so +a typo fails **loud** (a too-strict header) rather than **silent** (no policy at +all). Report-only is the unusual state you switch on to diagnose a violation. + +Report-only is a necessary step before enforcing, not a sufficient one: dev's +`'unsafe-eval'` / `'unsafe-inline'` relaxations hide violations, and an enforced +policy can block things a clean report-only pass never flagged. Walk sign-in, +sign-out, images and **every Radix surface** (dropdown, dialog, select, tooltip) +against a production build before calling it done. + +--- + +## Identity + +**Ministry Platform enforces no uniqueness on email addresses** — households +routinely share one across contacts, each of whom may hold a `dp_Users` login. +Better Auth, however, keys identity on email: `handleOAuthUserInfo` looks a user +up by `userInfo.email` **before** it considers the provider account id. + +**Better Auth 1.7 narrowed this but did not close it.** `handleOAuthUserInfo` +now resolves the provider account key first (`findAccountOwnerByKey`), and only +falls back to `findUserByEmail` when no account matches — which is exactly what +happens on every **first** sign-in for a given `sub`. The email fallback, and +the implicit link it can perform, are still there. This fix remains +load-bearing, not redundant. + +So this app keys on the only identifier MP guarantees unique — the OIDC `sub` +(the MP `User_GUID`) — via a synthetic address: + +``` +<user_guid>@mp.invalid # mp.invalid is an RFC 2606 reserved TLD +``` + +- The **real** address is preserved separately as `mpEmail`, for display only. + Nothing in the UI reads `session.user.email`; the header uses + `MPUserProfile.Email_Address` straight from MP. +- `account.accountLinking.enabled = false` is the second lock. +- `emailVerified` reports the provider's actual claim instead of asserting + `true`. +- Side benefit: MP does not require a user to have an email at all, and Better + Auth hard-fails the callback with `email_is_missing` when the profile yields + none. Such users previously could not sign in. + +In genericOAuth this works because `mapProfileToUser`'s `email` overrides +`userInfo.email` **before** `handleOAuthUserInfo` runs — verified against the +installed version, not assumed. + +### `accountSubject` — the provider account key (new in 1.7) + +Better Auth 1.7 stopped deriving the provider account id from `profile.id` (the +user-info type now declares `id?: never`) and derives it from +`accountSubject(...)` instead. genericOAuth's default is +`isOidc ? profile.sub : profile.id`, where `isOidc` is inferred **at boot** from +whether the discovery fetch returned `id_token_signing_alg_values_supported`. + +Two consequences this app handles explicitly: + +1. **`getUserInfo` returns `sub`, not `id`.** Returning the 1.6 `id` shape + leaves `sub` undefined, and `resolveOAuthAccountKey` throws + `OAUTH_ACCOUNT_SUBJECT_INVALID` **after a successful token exchange** — + surfacing as `/auth-error?error=unable_to_get_user_info`, which reads like a + userinfo fetch failure rather than an identity-mapping one. +2. **`accountSubject` is declared explicitly**, so the account key does not + depend on a boot-time network fetch succeeding. A transient discovery failure + would otherwise silently switch which field identifies the user. + +`mapProfileToUser` receives the **raw** object `getUserInfo` returned, so it +reads `sub` for the same reason. `src/auth.test.ts` pins all three, and the +guard is verified to fail against the 1.6 shape. + +### `disableIdTokenNonceBinding` — required for Ministry Platform + +As of 1.7, any provider configured with a `discoveryUrl` that publishes a JWKS +binds the `id_token` to the authorization request **by default**: Better Auth +sends a server-generated `nonce` and rejects a callback whose `id_token` does +not echo it (OIDC Core 1.0 §3.1.3.7). **MP omits the claim**, so every sign-in +would fail with `unable_to_get_user_info`. + +The failure is inverted from the obvious reading, which is what makes it cost +hours: sign-in succeeds **only when the boot-time discovery fetch failed**, +because that leaves the id_token config undefined and skips verification +entirely. A *working* discovery means a *broken* sign-in. + +What this gives up is binding the id_token to that particular authorization +request. Signature, issuer and audience are still verified against MP's JWKS, +and residual replay risk is mitigated by the `state` cookie check, by this being +a confidential client exchanging the code with a client secret, and by PKCE. + +`src/auth.test.ts` pins this flag and `pkce`, because both fail as a broken +sign-in for every user rather than as a type error. + +### `userGuid` must stay `input: true` + +As of Better Auth 1.6, `parseAdditionalUserInputFromProviderProfile` strips any +additional field declared `input: false` **before** the user record is created — +so `input: false` silently drops `userGuid` and breaks every MP lookup (avatar, +user menu, `User_ID` resolution). But `input: true` also means the field is +writable through `/update-user`. + +**No value of `input` satisfies both.** The control is at the endpoint layer +(the allowlist, plus `disabledAuthPaths`), not the flag. A field-level +`validator.input` is not an alternative either: it runs on the provider-profile +path too, so it can constrain the GUID's *shape* but cannot tell +`mapProfileToUser` from an attacker sending a well-formed GUID. + +--- + +## Logging + +The rule: **identifiers and shape, never content.** `no-console` is enforced by +ESLint across `src/` (`warn` and `error` only; generator scripts exempt). The MP +provider's `logger` has **no `debug` channel** — it previously dumped `$filter` +params, stored-procedure parameters, PUT bodies and full result sets. Being +gated on `NODE_ENV !== "production"` was not enough: developer machines and any +non-production deployment still wrote member PII to a terminal or aggregator. + +Response bodies are stripped from **thrown error messages** as well as logs: a +thrown message reaches error reporters and client-visible action results, so +appending an MP response body leaked record content and `$filter` strings +everywhere at once. + +Structured events alerts can grep on: + +| Event | Emitted when | +|---|---| +| `mp.read.unauthorized` | role gate refuses a read | +| `mp.write.unauthorized` | role gate refuses a write | +| `mp.write.non_user` | a write ran with no resolved acting user | +| `mp.request.failed` | a non-2xx MP HTTP response | +| `auth.userinfo.invalid_sub` | MP userinfo returned no usable `sub` | +| `auth.userinfo.fetch_failed` | MP userinfo endpoint returned non-2xx | +| `ui.render.error` | a React error boundary caught a render error | + +--- + +## Error boundaries + +Placement is the whole design — `error.tsx` never wraps the layout of its **own** +segment, so one boundary cannot do every job: + +| File | Catches | Why separate | +|---|---|---| +| `src/app/(web)/error.tsx` | anything below the `(web)` layout | renders **inside** the shell, so the header and **sign-out survive** | +| `src/app/error.tsx` | `/signin`, `/session-error`, `/auth-error` | those routes have no shell | +| `src/app/global-error.tsx` | a throw in the root layout itself | replaces it; imports nothing from the app | + +Two details that bite: + +- **Next 16 renamed the prop to `retry`** (it was `reset`). `reset` still exists + but only clears error state without re-fetching, so a boundary wired to the + stale name renders fine and its button **silently does nothing**. + `src/app/(web)/error.test.tsx` pins that `retry` is what gets called. +- **Log and render identifiers only, never `error.message`.** These boundaries + sit above components rendering MP names and household data; unlike a + controlled catch around an HTTP call, a render error's message is not + guaranteed to be content-free. `digest` is the join key to the un-redacted + server log. + +`npm run build` is what proves Next accepts these file conventions. A unit test +of the component cannot. + +--- + +## Findings + +Closed in this repo, from the upstream MPNext hardening playbook +(commits `436466d..5bc505a`): + +| ID | Severity | What it was | +|---|---|---| +| F-UPDATE-USER | Critical | Any authenticated user could POST their own session a different MP `User_GUID` | +| F2 | High | Two MP users sharing an email merged onto one Better Auth user | +| F1 | High | Reads gated on "a session exists", which proves nothing | +| F5 | Medium | Member PII, `$filter` strings and response bodies in logs and thrown messages | +| F9 | Medium | No CSP, no HSTS, no anti-framing, no Referrer-Policy | +| F3 | Medium | Open redirect via `?callbackUrl=` on `/signin` | +| F7 | Low | ~30 Better Auth endpoints publicly mounted; OAuth errors on a third-party page | +| F8 | Low | **Resolved as WONTFIX.** PKCE stays `false`: MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`. See below. | + +**Not applicable to this repo:** F4 (no `Made_By` / contact-log feature, and +attribution was already server-authoritative — no server action ever accepted a +`userId` parameter), F10 and F11 (no `ContactService`; `getMpTimezone` is a +documented carve-out). + +### F8 (PKCE) — closed as not-possible, with evidence + +The upstream playbook lists PKCE as "likely can be flipped to `true`" because +MP's discovery document advertises +`code_challenge_methods_supported: ["plain", "S256"]`. **The discovery document +is wrong.** Enabling it produces: + +1. `POST /sign-in/social` → 200, authorize URL carries `code_challenge_method=S256` +2. MP authenticates the user and redirects back **with a valid code** +3. Token exchange → `{ error: 'invalid_grant', status: 400 }` +4. User lands on `/auth-error?error=invalid_code` + +Both of the signals you would naturally check — the advertised support, and MP +accepting the `code_challenge` on the authorize URL — look like confirmation. +The flow only breaks on the last hop. `src/auth.test.ts` pins `pkce: false` with +this reasoning so it is not re-opened from the discovery document alone. + +Without PKCE the code rests on the client secret and the `state` cookie check, +which is a confidential client's normal posture. + +### Still open — needs a human + +- **The MP OAuth client's registered redirect URI must be updated.** Better Auth + 1.7 changed the callback path, so the `redirect_uri` this app sends is now + `<BETTER_AUTH_URL>/api/auth/callback/ministryplatform` — previously + `/api/auth/oauth2/callback/ministry-platform`. Note BOTH halves changed: the + 1.7 upgrade moved `/oauth2/callback/` to `/callback/`, and the provider id was + renamed from `ministry-platform` to `ministryplatform`. Register the new value in the + Ministry Platform OAuth client **before** deploying, or MP will reject the + authorization request outright. +- **The enforced-CSP browser walk has not been done.** Headers and nonce + coverage were verified against `next start`; a human still needs to click + through every Radix surface under enforcement. +- **`BETTER_AUTH_SECRET` should be rotated** if this deployment was ever exposed + to F-UPDATE-USER. Patching does not revoke sessions already forged — they + survive in the JWT cookie cache for up to an hour, and with no database there + is no session table to clear. +- A transient **discovery failure at boot disables the OAuth provider for the + life of the process**, with no retry (inherited upstream issue). It also + inverts the sign-in failure mode: a *working* discovery is what turns on + id_token verification. diff --git a/.env.example b/.env.example index c1157ca..c8e14eb 100644 --- a/.env.example +++ b/.env.example @@ -67,3 +67,19 @@ NEXT_PUBLIC_APP_NAME=MPNextApp # construct the full production URL of the current tool (NEXT_PUBLIC_PROD_URL + pathname) # and check it against the authorized tool paths returned from Ministry Platform. NEXT_PUBLIC_PROD_URL= + +# --- Security --- + +# Comma-separated Ministry Platform security role names permitted to use the +# MP-data tools (READS as well as writes). Blank or unset means "any MP +# security role will do" — a signed-in user with NO role is still refused. +# +# Sign-in itself is deliberately NOT role-gated: a role-less user gets a +# session, the app shell and a working sign-out, and is redirected from the +# tools to /no-access. Refusing at sign-in would strand them with no way out. +MP_SECURITY_ROLES= + +# Content Security Policy. ENFORCES by default — only the exact string "false" +# drops to report-only, so a typo fails loud (too-strict header) rather than +# silent (no policy). Set to "false" only to diagnose a violation. +CSP_ENFORCE= diff --git a/CLAUDE.md b/CLAUDE.md index f256b8d..4f7488f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,7 +157,11 @@ export default MyComponent; // ❌ Avoid 9. **Use service classes in server actions** - call services from `src/services/`, not MPHelper directly from components or actions 10. **Disambiguate ambiguous columns** - when querying tables with FK joins, prefix columns that exist in multiple tables (e.g., `Contacts.Contact_ID` not just `Contact_ID`). Use `FKColumn_TABLE.Column` to traverse foreign keys (e.g., `Contact_ID_TABLE.First_Name`). For multi-level FK traversal, chain with `_TABLE_` underscores and use a dot only before the final field (e.g., `Building_ID_TABLE_Location_ID_TABLE.Congregation_ID`). See **[Services query-patterns](.claude/references/services/query-patterns.md)** for full rules and examples. 11. **Escape user input in filters** - always escape single quotes: `term.replace(/'/g, "''")` -12. **Convert all date/time values at the MP boundary** - use `DomainTimezoneService` (never raw `new Date(x).toISOString()`, `` `${date}T00:00:00Z` ``, or `getFullYear()`) when sending or receiving datetime fields, since MP stores wall-clock values in the domain's time zone, not UTC. See **[Date/Time Handling Reference](.claude/references/ministryplatform.datetimehandling.md)**. +12. **Authorize, don't just authenticate** — feature server actions **and** service methods that touch MP data call `AuthorizationService` (`requireSecurityRole`, for **reads** as well as writes), never a bare `auth.api.getSession()` check. MP's OIDC endpoint authenticates *any* `dp_Users` record, and this app reads MP with its own service account, so MP's per-user record security never applies to what it returns — "a session exists" proves nothing. Documented carve-outs, each justified in-file: `layout/auth-wrapper.tsx` (it *is* the session gate), `shared-actions/user.ts` (the user's own profile), `shared-actions/domain.ts` (one domain-wide config string), `dev-panel/panels/require-dev-session.ts` (dev-only, and the services it calls gate anyway). A fifth needs the same justification, in the file, in writing. +13. **Write attribution has exactly one source** — `$userId` comes from the gate's return value, assembled in the **service**, never passed in by an action and never read from a caller-supplied payload. Two layers stamping it can drift, and a caller value can slip past whichever was checked second. +14. **No debug logging in `src/`** — `no-console` is enforced by ESLint (`warn`/`error` only). Errors log **identifiers and shape** (table, IDs, HTTP status), never record content, `$filter` strings, request bodies, or response bodies — including inside thrown error messages, which travel further than logs do. +15. **`src/lib/tool-params.ts` must stay client-safe** — it is imported by client components. Importing a service from it drags `next/headers` into the client graph and fails the Turbopack build. Server-side parsing lives in `tool-params.server.ts`; a dynamic `import()` is *not* sufficient, it still creates a graph edge. +16. **Convert all date/time values at the MP boundary** - use `DomainTimezoneService` (never raw `new Date(x).toISOString()`, `` `${date}T00:00:00Z` ``, or `getFullYear()`) when sending or receiving datetime fields, since MP stores wall-clock values in the domain's time zone, not UTC. See **[Date/Time Handling Reference](.claude/references/ministryplatform.datetimehandling.md)**. ## Validation Best Practices @@ -230,6 +234,7 @@ Agent-facing reference docs are hierarchical under `.claude/references/`. Start | routing | [`.claude/references/routing/README.md`](.claude/references/routing/README.md) | | data-flow | [`.claude/references/data-flow/README.md`](.claude/references/data-flow/README.md) | | testing | [`.claude/references/testing/README.md`](.claude/references/testing/README.md) | +| security | [`.claude/references/security/README.md`](.claude/references/security/README.md) | | dto-constants | [`.claude/references/dto-constants/README.md`](.claude/references/dto-constants/README.md) | | utils | [`.claude/references/utils/README.md`](.claude/references/utils/README.md) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 220617c..a6b0628 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,31 @@ const eslintConfig = defineConfig([ ], }, }, + { + /** + * No debug logging in application code. + * + * `console.log`/`debug`/`info` were used to dump `$filter` query params, + * stored-procedure parameters, PUT request bodies and full MP result sets — + * names, email addresses, phone numbers — into hosting and log-aggregation + * platforms, which typically have broader access and longer retention than + * the Ministry Platform database itself. + * + * `warn` and `error` stay allowed, on the rule that they log IDENTIFIERS + * AND SHAPE only (table, IDs, HTTP status), never record content. + * + * Generator scripts are exempt: they are CLI tools whose entire output is + * console-based and which never touch member data. + */ + files: ["src/**/*.{ts,tsx}"], + ignores: [ + "src/lib/providers/ministry-platform/scripts/**", + "**/*.test.{ts,tsx}", + ], + rules: { + "no-console": ["error", { allow: ["warn", "error"] }], + }, + }, { files: ["**/*.test.ts", "**/*.test.tsx"], rules: { diff --git a/next.config.ts b/next.config.ts index 8adf543..d1152b8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,54 @@ import type { NextConfig } from "next"; +/** + * Request-independent security headers. + * + * These live here rather than in `src/proxy.ts` for one reason: the proxy's + * matcher deliberately skips `_next/static`, `_next/image`, `favicon.ico` and + * `assets/`, and these headers should still reach those responses (and `/api`). + * The Content-Security-Policy is the exception and lives in the proxy, because + * its nonce must be regenerated per request — see `src/lib/security-headers.ts`. + * + * Anti-framing is expressed TWICE on purpose: `X-Frame-Options` here covers the + * routes the proxy skips, and `frame-ancestors` in the CSP covers the rest. + * They are not both CSP headers — two `Content-Security-Policy` headers on one + * response are enforced as an INTERSECTION, which is miserable to debug. + */ +const securityHeaders = [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + }, +]; + +/** + * HSTS is production-only — sending it from a dev server pins localhost to + * HTTPS in the developer's browser, which is painful to undo. + * + * Deliberately NO `preload`: that is a one-way submission to a browser-vendor + * list, and it is the deploying church's decision, not a repo default. + */ +const hstsHeader = { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains", +}; + const nextConfig: NextConfig = { serverExternalPackages: ['mjml', 'mjml-core', 'mjml-validator', 'uglify-js'], + async headers() { + return [ + { + source: "/(.*)", + headers: + process.env.NODE_ENV === "production" + ? [...securityHeaders, hstsHeader] + : securityHeaders, + }, + ]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 809ffb0..57177a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-pdf/renderer": "^4.5.1", "@types/js-cookie": "^3.0.6", - "better-auth": "^1.6.11", + "better-auth": "^1.7.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -104,7 +104,7 @@ "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -121,7 +121,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -138,7 +138,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -148,7 +148,7 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@babel/code-frame": { @@ -181,7 +181,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -287,7 +286,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -330,7 +329,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -389,7 +388,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -403,29 +402,28 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@better-auth/core": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.23.tgz", - "integrity": "sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.4.tgz", + "integrity": "sha512-g66dKOMB5PLxoujkxkZeYb6dh5wLhfAx/D+w9XtcO669Dslnyen2naBYqnD1r5ZwnxFKcr6aSrUPaH952V89Lg==", "license": "MIT", - "peer": true, "dependencies": { - "@opentelemetry/semantic-conventions": "^1.39.0", + "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", - "zod": "^4.3.6" + "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", - "better-call": "1.3.7", + "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" @@ -440,14 +438,14 @@ } }, "node_modules/@better-auth/drizzle-adapter": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.23.tgz", - "integrity": "sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.4.tgz", + "integrity": "sha512-J9JMl0DGYNYwtDj5nq/fzycU9dzPUDEtTNn89KmToUrwPDGfByDgfmiQ9jGK+LN3EnfsG1Pt/dDmRocbnfihlA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", + "@better-auth/core": "^1.7.4", "@better-auth/utils": "0.4.2", - "drizzle-orm": "^0.45.2" + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "peerDependenciesMeta": { "drizzle-orm": { @@ -456,12 +454,12 @@ } }, "node_modules/@better-auth/kysely-adapter": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.23.tgz", - "integrity": "sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.4.tgz", + "integrity": "sha512-WHLsTs4ZwTKorifdK6Whol6Zy+0gIzOJEXzWsvzmLtlT1uIZFpd7IPBlDZTrXvIZnxPWJHPIv8/uHqS9QFPZOA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", + "@better-auth/core": "^1.7.4", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, @@ -472,22 +470,22 @@ } }, "node_modules/@better-auth/memory-adapter": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.23.tgz", - "integrity": "sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.4.tgz", + "integrity": "sha512-xAihOPh4mFjvKLzmu2XNzj7RchDmytA/d/xjrGyps1oHVGCTGDGghlSt/rIT5fagtTn2R9+TBLh5QrZnTvYu9g==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", + "@better-auth/core": "^1.7.4", "@better-auth/utils": "0.4.2" } }, "node_modules/@better-auth/mongo-adapter": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.23.tgz", - "integrity": "sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.4.tgz", + "integrity": "sha512-sIdIc8vONXgQxKPfzL4bbk6gO3gdFEDzC5VG8rD18xSFB6j7blBKcuzOofbswbwwbyuspnshoViGRqSoqp2PnA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", + "@better-auth/core": "^1.7.4", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, @@ -498,12 +496,12 @@ } }, "node_modules/@better-auth/prisma-adapter": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.23.tgz", - "integrity": "sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.4.tgz", + "integrity": "sha512-cvsMNaXLcwQiAIxQCx5Gq2VA1reW4RjkC5oLNXNJR4IhgeiqcvYF6qKF6UEC0vV2x3euKz5V/SDY/hblHWwx+w==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", + "@better-auth/core": "^1.7.4", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" @@ -518,12 +516,12 @@ } }, "node_modules/@better-auth/telemetry": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.23.tgz", - "integrity": "sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.4.tgz", + "integrity": "sha512-FycnvWXP1gZ8BlSN1lW9L/YYFVqxi7+n6A9i66gJKdoJqREDdmhxBKOaVCbNhNC2UNDAo9PbBm/6J3gv6WmAEA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", + "@better-auth/core": "^1.7.4", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } @@ -533,15 +531,14 @@ "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", "license": "MIT", - "peer": true, "dependencies": { "@noble/hashes": "^2.0.1" } }, "node_modules/@better-auth/utils/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -554,14 +551,13 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "css-tree": "^3.0.0" @@ -580,7 +576,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -600,7 +596,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -624,7 +620,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -652,7 +648,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -664,7 +660,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -676,7 +671,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -701,7 +696,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -713,7 +708,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -799,11 +793,41 @@ "tslib": "^2.6.2" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1374,7 +1398,7 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -2375,7 +2399,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2392,7 +2416,7 @@ "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2403,7 +2427,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2641,9 +2664,9 @@ "license": "MIT" }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.42.0.tgz", - "integrity": "sha512-icc5xCzndZfhuJMy5oqk5AvloWquR7jtae74qzpkKkhGp8BivK+oCcEXgGnjCdTfp8hA44l+w8gE8yYJbocJJw==", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -3751,7 +3774,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3768,7 +3790,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3785,7 +3806,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3802,7 +3822,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3819,7 +3838,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3836,7 +3854,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3853,7 +3870,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3870,7 +3886,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3887,7 +3902,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3904,7 +3918,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3921,7 +3934,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3938,7 +3950,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3955,7 +3966,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3971,7 +3981,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3983,7 +3992,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3997,7 +4005,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4014,7 +4021,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4423,7 +4429,6 @@ "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4435,7 +4440,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/backbone": { "version": "1.4.15", @@ -4518,9 +4524,8 @@ "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -4610,7 +4615,6 @@ "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", @@ -5238,9 +5242,8 @@ "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", @@ -5409,7 +5412,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5468,6 +5470,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -5684,7 +5687,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", @@ -5696,7 +5699,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/async-function": { @@ -5841,28 +5844,28 @@ } }, "node_modules/better-auth": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.23.tgz", - "integrity": "sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.6.23", - "@better-auth/drizzle-adapter": "1.6.23", - "@better-auth/kysely-adapter": "1.6.23", - "@better-auth/memory-adapter": "1.6.23", - "@better-auth/mongo-adapter": "1.6.23", - "@better-auth/prisma-adapter": "1.6.23", - "@better-auth/telemetry": "1.6.23", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.4.tgz", + "integrity": "sha512-rjL9g8b8UvdwK/ZSdawhqYzOm8YegurB6Fuzqtu/zFJamlFrPa+eEhP8Qj2vFWsMzHOk2mb/L4FRYh9pgaU7OA==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.7.4", + "@better-auth/drizzle-adapter": "1.7.4", + "@better-auth/kysely-adapter": "1.7.4", + "@better-auth/memory-adapter": "1.7.4", + "@better-auth/mongo-adapter": "1.7.4", + "@better-auth/prisma-adapter": "1.7.4", + "@better-auth/telemetry": "1.7.4", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", - "@noble/ciphers": "^2.1.1", - "@noble/hashes": "^2.0.1", - "better-call": "1.3.7", + "@noble/ciphers": "^2.2.0", + "@noble/hashes": "^2.2.0", + "better-call": "1.4.0", "defu": "^6.1.4", - "jose": "^6.1.3", + "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", - "nanostores": "^1.1.1", - "zod": "^4.3.6" + "nanostores": "^1.3.0", + "zod": "^4.5.4" }, "peerDependencies": { "@lynx-js/react": "*", @@ -5871,8 +5874,8 @@ "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", - "drizzle-kit": ">=0.31.4", - "drizzle-orm": "^0.45.2", + "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", @@ -5882,7 +5885,7 @@ "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", - "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "peerDependenciesMeta": { @@ -5970,16 +5973,15 @@ } }, "node_modules/better-call": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.7.tgz", - "integrity": "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", + "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", "license": "MIT", - "peer": true, "dependencies": { - "@better-auth/utils": "^0.4.0", - "@better-fetch/fetch": "^1.1.21", - "rou3": "^0.7.12", - "set-cookie-parser": "^3.0.1" + "@better-auth/utils": "^0.5.0", + "@better-fetch/fetch": "^1.3.1", + "rou3": "^0.9.1", + "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" @@ -5990,6 +5992,27 @@ } } }, + "node_modules/better-call/node_modules/@better-auth/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/better-call/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -6066,7 +6089,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", @@ -6584,7 +6606,6 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.9.tgz", "integrity": "sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==", "license": "MIT", - "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.17", "lilconfig": "^3.1.3" @@ -6724,7 +6745,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", @@ -6810,7 +6831,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/deep-is": { @@ -6972,7 +6993,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -7499,7 +7521,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7685,7 +7706,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8487,7 +8507,6 @@ "resolved": "https://registry.npmjs.org/grapesjs/-/grapesjs-0.22.16.tgz", "integrity": "sha512-kCfphgpC7pqJPuMYmIhMR6ueyB3+V67isdpMZOvmuGeWDMomkgzqRWOMH3matfdqIJW7LUivHZo9GeyVQAGmLw==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@types/backbone": "1.4.15", "backbone": "1.4.1", @@ -8535,7 +8554,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -8658,7 +8677,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" @@ -8677,7 +8696,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/htmlnano": { @@ -9163,7 +9182,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-regex": { @@ -9333,7 +9352,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=8" @@ -9343,7 +9362,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", @@ -9358,7 +9377,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", @@ -9414,18 +9433,17 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -9667,7 +9685,7 @@ "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^5.1.11", @@ -9708,7 +9726,7 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, + "devOptional": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -9847,11 +9865,10 @@ } }, "node_modules/kysely": { - "version": "0.29.3", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.3.tgz", - "integrity": "sha512-VHtBdW6XB/pgoTSqraM3UAa2rYoYdNXqnNPpX+8XXP+cwYbVEFuAp3HyPt1vpNfU9l7Y2kpUrA9QDPsy8uUqOQ==", + "version": "0.29.5", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.5.tgz", + "integrity": "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=22.0.0" } @@ -9936,7 +9953,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9957,7 +9973,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9978,7 +9993,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9999,7 +10013,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10020,7 +10033,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10041,7 +10053,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10062,7 +10073,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10083,7 +10093,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10104,7 +10113,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10125,7 +10133,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10146,7 +10153,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10275,6 +10281,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10293,7 +10300,7 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.3", @@ -10305,7 +10312,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -10321,7 +10328,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10908,9 +10915,9 @@ } }, "node_modules/nanostores": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.4.0.tgz", - "integrity": "sha512-i0tloweeudshAEuddpDxcg9Ik6pkPfVsHIgKyf143JrgG7/MOh0+q7BypdLXZPoOP7fOYt1eTcwGkyiVmhJFkA==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.3.tgz", + "integrity": "sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA==", "funding": [ { "type": "github", @@ -10918,7 +10925,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -10951,7 +10957,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz", "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.2.10", "@swc/helpers": "0.5.15", @@ -11405,7 +11410,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "entities": "^8.0.0" @@ -11491,7 +11496,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -11639,7 +11644,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -12301,6 +12305,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12349,7 +12354,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -12390,7 +12395,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12400,7 +12404,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12419,7 +12422,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz", "integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -12436,7 +12438,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-remove-scroll": { "version": "2.7.2", @@ -12706,9 +12709,9 @@ } }, "node_modules/rou3": { - "version": "0.7.12", - "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", - "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", + "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", "license": "MIT" }, "node_modules/run-parallel": { @@ -12829,7 +12832,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -12855,9 +12858,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", - "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", "license": "MIT" }, "node_modules/set-function-length": { @@ -13433,7 +13436,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -13499,7 +13502,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tailwind-merge": { @@ -13517,8 +13520,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.3", @@ -13598,7 +13600,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13620,7 +13621,7 @@ "version": "7.4.7", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz", "integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tldts-core": "^7.4.7" @@ -13633,7 +13634,7 @@ "version": "7.4.7", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz", "integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/to-regex-range": { @@ -13653,7 +13654,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -13666,7 +13667,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -13725,7 +13726,6 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -13846,7 +13846,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13908,7 +13907,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -13918,7 +13917,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicode-properties": { @@ -14102,7 +14101,6 @@ "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", @@ -14208,7 +14206,6 @@ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", @@ -14310,7 +14307,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -14339,7 +14336,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -14374,7 +14371,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20" @@ -14384,7 +14381,7 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -14620,7 +14617,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -14630,7 +14627,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/y18n": { @@ -14696,11 +14693,10 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.4.tgz", + "integrity": "sha512-AXSD6hvGdvRjajG/l1cC+d6IrhH+sjmPKtYeQdJIK8MFJl3LyClzS+o/YsVC+zQZPupAaeH5skwwm8YqYH7BqA==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 8c3d899..d494143 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-pdf/renderer": "^4.5.1", "@types/js-cookie": "^3.0.6", - "better-auth": "^1.6.11", + "better-auth": "^1.7.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/src/app/(web)/error.test.tsx b/src/app/(web)/error.test.tsx new file mode 100644 index 0000000..b784161 --- /dev/null +++ b/src/app/(web)/error.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import WebError from './error'; +import RootError from '../error'; + +/** + * Error-boundary tests. + * + * Two failure modes here are silent, which is why they get explicit tests: + * + * 1. Next 16 renamed the boundary prop to `retry` (it was `reset`). `reset` + * still exists but only clears error state without re-fetching, so a + * boundary wired to the old name renders perfectly and its button does + * nothing at all. + * 2. These boundaries sit above components that render MP names, addresses and + * household data. A render error's message is not guaranteed to be + * content-free, so neither the UI nor the log may include it. + */ +describe.each([ + ['(web) boundary', WebError, 'web'], + ['root boundary', RootError, 'root'], +])('%s', (_label, Boundary, expectedBoundaryName) => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const error = Object.assign(new Error('Contact Jane Doe, jane@example.org, failed to parse'), { + digest: 'abc123', + }); + + it('calls retry — not reset — when the button is clicked', () => { + const retry = vi.fn(); + render(<Boundary error={error} retry={retry} />); + + fireEvent.click(screen.getByRole('button', { name: /try again/i })); + + expect(retry).toHaveBeenCalledTimes(1); + }); + + it('does NOT render the error message to the user', () => { + render(<Boundary error={error} retry={vi.fn()} />); + + expect(screen.queryByText(/Jane Doe/)).not.toBeInTheDocument(); + expect(screen.queryByText(/jane@example.org/)).not.toBeInTheDocument(); + expect(document.body.textContent).not.toContain('jane@example.org'); + }); + + it('shows the digest so a user can quote it to support', () => { + render(<Boundary error={error} retry={vi.fn()} />); + + expect(screen.getByText('abc123')).toBeInTheDocument(); + }); + + it('renders without a digest', () => { + render(<Boundary error={new Error('boom')} retry={vi.fn()} />); + + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + it('logs identifiers only — never the message', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + render(<Boundary error={error} retry={vi.fn()} />); + + expect(spy).toHaveBeenCalledWith('ui.render.error', { + boundary: expectedBoundaryName, + name: 'Error', + digest: 'abc123', + }); + const payload = JSON.stringify(spy.mock.calls[0][1]); + expect(payload).not.toContain('Jane Doe'); + expect(payload).not.toContain('jane@example.org'); + }); +}); + +/** + * `global-error.tsx` replaces the root layout, so it must not depend on + * anything that might itself be what failed. It is checked at the source level + * rather than rendered, because it emits its own <html>/<body>. + */ +describe('global-error boundary', () => { + it('imports nothing from the app and styles itself inline', async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const source = await fs.readFile( + path.resolve(process.cwd(), 'src/app/global-error.tsx'), + 'utf-8', + ); + + // No app imports: whatever failed may be that very code, and it does not + // receive global styles either. + expect(source).not.toMatch(/from ["']@\//); + expect(source).toContain('<html'); + // Inline styles are safe ONLY because style-src is 'self' 'unsafe-inline' + // with no nonce — see src/lib/security-headers.ts. The two are coupled. + expect(source).toContain('style={{'); + }); + + it('uses the Next 16 retry prop', async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const source = await fs.readFile( + path.resolve(process.cwd(), 'src/app/global-error.tsx'), + 'utf-8', + ); + + expect(source).toContain('retry'); + expect(source).toContain('onClick={retry}'); + }); +}); diff --git a/src/app/(web)/error.tsx b/src/app/(web)/error.tsx index 9c515d7..353e583 100644 --- a/src/app/(web)/error.tsx +++ b/src/app/(web)/error.tsx @@ -2,32 +2,63 @@ import { useEffect } from "react"; +/** + * Error boundary for everything below the `(web)` layout. + * + * Placement is the whole design: because this sits INSIDE the `(web)` segment, + * the shell — header, user menu and critically the SIGN-OUT control — survives + * the error. A single boundary at `src/app/error.tsx` would replace the shell + * and strand the user with no way out. + * + * Two details that bite: + * + * 1. Next 16 renamed the prop to `retry` (it was `reset`). `reset` still + * exists but only clears error state without re-fetching, so a boundary + * wired to the old name renders perfectly and its button SILENTLY DOES + * NOTHING. `error.test.tsx` pins that `retry` is the one being called. + * + * 2. Log and render IDENTIFIERS ONLY, never `error.message`. This boundary sits + * above components rendering MP names, addresses and household data; unlike + * a controlled catch around an HTTP call, a render error's message is not + * guaranteed to be content-free. `digest` is the join key to the + * un-redacted server-side log. + */ export default function Error({ error, - reset, + retry, }: { error: Error & { digest?: string }; - reset: () => void; + retry: () => void; }) { useEffect(() => { - console.error("Unhandled error:", error); + console.error("ui.render.error", { + boundary: "web", + name: error.name, + digest: error.digest, + }); }, [error]); return ( - <div className="flex items-center justify-center min-h-screen"> + <div className="flex items-center justify-center min-h-[60vh]"> <div className="max-w-md w-full p-6 text-center space-y-4"> <h2 className="text-2xl font-semibold text-red-600"> Something went wrong </h2> <p className="text-sm text-gray-600"> - {error.message || "An unexpected error occurred."} + An unexpected error occurred while loading this page. You can try + again — if it keeps happening, contact your administrator. </p> <button - onClick={reset} + onClick={retry} className="inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors" > Try again </button> + {error.digest ? ( + <p className="text-xs text-gray-400"> + Reference: <span className="font-mono">{error.digest}</span> + </p> + ) : null} </div> </div> ); diff --git a/src/app/(web)/no-access/page.tsx b/src/app/(web)/no-access/page.tsx new file mode 100644 index 0000000..d694261 --- /dev/null +++ b/src/app/(web)/no-access/page.tsx @@ -0,0 +1,22 @@ +/** + * Explaining page for signed-in users who hold no MP security role. + * + * Deliberately inside the `(web)` route group so it renders WITH the app shell: + * the header, user menu and — critically — the sign-out control stay available. + * Refusing these users at sign-in instead would strand them in the app with no + * way out, which is why sign-in is not role-gated (see AuthorizationService). + */ +export default function NoAccessPage() { + return ( + <div className="flex items-center justify-center min-h-[60vh] px-4"> + <div className="max-w-md text-center"> + <h1 className="text-2xl font-semibold mb-3">You don't have access to this tool</h1> + <p className="text-gray-600"> + Your Ministry Platform account signed in successfully, but it + doesn't have a security role that grants access to these tools. + Ask your Ministry Platform administrator to assign one. + </p> + </div> + </div> + ); +} diff --git a/src/app/(web)/tools/addeditfamily/actions.ts b/src/app/(web)/tools/addeditfamily/actions.ts index 9fe97a5..af7abfa 100644 --- a/src/app/(web)/tools/addeditfamily/actions.ts +++ b/src/app/(web)/tools/addeditfamily/actions.ts @@ -1,10 +1,8 @@ "use server"; -import { auth } from "@/lib/auth"; -import { headers } from "next/headers"; import { FamilyService, PartialSaveError } from "@/services/familyService"; +import { AuthorizationService } from "@/services/authorizationService"; import { GooglePlacesService } from "@/services/googlePlacesService"; -import { getCurrentUserIdFromSession } from "@/components/shared-actions/user"; import type { ContactSearchResult, FamilyDefaults, @@ -16,26 +14,41 @@ import type { PlacePrediction, PlaceDetails } from "@/lib/providers/google-place export type ActionError = { success: false; error: string; progress?: SaveProgress }; -async function getSession() { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user?.id) throw new Error("Unauthorized"); - return session; +/** + * Authorization gate for this feature's server actions. + * + * A server action is a callable POST endpoint whether or not the page that + * renders it was ever fetched, so the page-level gate in the tools layout is + * not sufficient on its own. This replaces the previous bare session check: + * MP's OIDC endpoint authenticates ANY dp_Users record, and this app reads MP + * with its own service account, so "a session exists" proves nothing about + * whether the caller may see or change this data. + * + * The service layer gates again — that is deliberate defence in depth, and the + * per-request memoization in AuthorizationService keeps it to one MP read. + */ +async function requireAccess( + table: string, + operation: "read" | "create" | "update" | "delete", +): Promise<number> { + return AuthorizationService.getInstance().requireSecurityRole({ table, operation }); } + export async function searchContacts(term: string): Promise<ContactSearchResult[]> { - await getSession(); + await requireAccess("Contacts", "read"); const service = await FamilyService.getInstance(); return service.searchContacts(term); } export async function fetchFamilyLookups(): Promise<FamilyLookups> { - await getSession(); + await requireAccess("Contacts", "read"); const service = await FamilyService.getInstance(); return service.getLookups(); } export async function fetchFamilyDefaults(): Promise<FamilyDefaults> { - await getSession(); + await requireAccess("Contacts", "read"); const service = await FamilyService.getInstance(); return service.getDefaults(); } @@ -44,7 +57,7 @@ export async function fetchHousehold( contactId: number, ): Promise<{ success: true; household: Household } | ActionError> { try { - await getSession(); + await requireAccess("Households", "read"); const service = await FamilyService.getInstance(); const household = await service.getHousehold(contactId); if (!household) return { success: false, error: "Household not found" }; @@ -64,7 +77,7 @@ export async function resolveContactIdFromPage(args: { contactIdField: string; }): Promise<{ success: true; contactId: number | null } | ActionError> { try { - await getSession(); + await requireAccess(args.tableName, "read"); const service = await FamilyService.getInstance(); const contactId = await service.resolveContactIdFromPage( args.tableName, @@ -82,13 +95,13 @@ export async function resolveContactIdFromPage(args: { } export async function fetchNextEnvelopeNumber(): Promise<number> { - await getSession(); + await requireAccess("Contacts", "read"); const service = await FamilyService.getInstance(); return service.getNextEnvelopeNumber(); } export async function placesEnabled(): Promise<boolean> { - await getSession(); + await requireAccess("Addresses", "read"); const service = await GooglePlacesService.getInstance(); return service.isEnabled(); } @@ -97,7 +110,7 @@ export async function placeAutocomplete( input: string, sessionToken: string, ): Promise<PlacePrediction[]> { - await getSession(); + await requireAccess("Addresses", "read"); if (input.trim().length < 3) return []; const service = await GooglePlacesService.getInstance(); if (!(await service.isEnabled())) return []; @@ -109,7 +122,7 @@ export async function placeDetails( sessionToken: string, ): Promise<{ success: true; details: PlaceDetails } | ActionError> { try { - await getSession(); + await requireAccess("Addresses", "read"); const service = await GooglePlacesService.getInstance(); const details = await service.getPlaceDetails(placeId, sessionToken); return { success: true, details }; @@ -125,10 +138,9 @@ export async function saveFamily( household: Household, ): Promise<{ success: true; progress: SaveProgress } | ActionError> { try { - const session = await getSession(); - const userId = await getCurrentUserIdFromSession(session); + await requireAccess("Households", "update"); const service = await FamilyService.getInstance(); - const progress = await service.saveHousehold(household, userId); + const progress = await service.saveHousehold(household); return { success: true, progress }; } catch (error) { if (error instanceof PartialSaveError) { diff --git a/src/app/(web)/tools/addeditfamily/page.tsx b/src/app/(web)/tools/addeditfamily/page.tsx index 8e46393..065e0ed 100644 --- a/src/app/(web)/tools/addeditfamily/page.tsx +++ b/src/app/(web)/tools/addeditfamily/page.tsx @@ -1,5 +1,5 @@ import { AddEditFamily } from "./add-edit-family"; -import { parseToolParams } from "@/lib/tool-params"; +import { parseToolParams } from "@/lib/tool-params.server"; import { FamilyService } from "@/services/familyService"; interface AddEditFamilyPageProps { diff --git a/src/app/(web)/tools/addresslabels/page.tsx b/src/app/(web)/tools/addresslabels/page.tsx index b437404..83020b8 100644 --- a/src/app/(web)/tools/addresslabels/page.tsx +++ b/src/app/(web)/tools/addresslabels/page.tsx @@ -1,5 +1,5 @@ import { AddressLabels } from './address-labels'; -import { parseToolParams } from '@/lib/tool-params'; +import { parseToolParams } from '@/lib/tool-params.server'; interface AddressLabelsPageProps { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; diff --git a/src/app/(web)/tools/fieldmanagement/page.tsx b/src/app/(web)/tools/fieldmanagement/page.tsx index eb48c41..44565c3 100644 --- a/src/app/(web)/tools/fieldmanagement/page.tsx +++ b/src/app/(web)/tools/fieldmanagement/page.tsx @@ -1,5 +1,5 @@ import { FieldManagement } from "./field-management"; -import { parseToolParams } from "@/lib/tool-params"; +import { parseToolParams } from "@/lib/tool-params.server"; interface FieldManagementPageProps { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; diff --git a/src/app/(web)/tools/groupwizard/page.tsx b/src/app/(web)/tools/groupwizard/page.tsx index c0639dc..2762e26 100644 --- a/src/app/(web)/tools/groupwizard/page.tsx +++ b/src/app/(web)/tools/groupwizard/page.tsx @@ -1,5 +1,5 @@ import { GroupWizard } from "./group-wizard"; -import { parseToolParams } from "@/lib/tool-params"; +import { parseToolParams } from "@/lib/tool-params.server"; import { getMpTimezone } from "@/components/shared-actions/domain"; interface GroupWizardPageProps { diff --git a/src/app/(web)/tools/layout.tsx b/src/app/(web)/tools/layout.tsx index 42ea7f5..9595a20 100644 --- a/src/app/(web)/tools/layout.tsx +++ b/src/app/(web)/tools/layout.tsx @@ -1,8 +1,29 @@ -export default function ToolsLayout({ +import { redirect } from "next/navigation"; +import { AuthorizationService } from "@/services/authorizationService"; + +/** + * Page-level authorization gate for every tool. + * + * React renders a layout before its children and only renders `children` once + * the layout returns, so a `redirect()` here means the tool page component + * never runs — one gate covers every route under `/tools`. + * + * This is the UX layer, NOT the security control. It uses `hasSecurityRole()` + * (non-throwing, non-logging) purely to choose between rendering and + * redirecting. Enforcement lives in the server actions and in the service + * methods, both of which use `requireSecurityRole()` — a server action is a + * callable POST endpoint whether or not this layout ever rendered. + */ +export default async function ToolsLayout({ children, }: { children: React.ReactNode; }) { + const permitted = await AuthorizationService.getInstance().hasSecurityRole(); + if (!permitted) { + redirect("/no-access"); + } + return ( <div className="flex flex-col h-screen bg-gray-50"> {children} diff --git a/src/app/(web)/tools/template/page.tsx b/src/app/(web)/tools/template/page.tsx index 392284e..37c2150 100644 --- a/src/app/(web)/tools/template/page.tsx +++ b/src/app/(web)/tools/template/page.tsx @@ -1,5 +1,5 @@ import { TemplateTool } from "./template-tool"; -import { parseToolParams } from "@/lib/tool-params"; +import { parseToolParams } from "@/lib/tool-params.server"; interface TemplatePageProps { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; diff --git a/src/app/(web)/tools/template/template-tool.tsx b/src/app/(web)/tools/template/template-tool.tsx index 61f5baf..6ed3234 100644 --- a/src/app/(web)/tools/template/template-tool.tsx +++ b/src/app/(web)/tools/template/template-tool.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import { ToolContainer } from "@/components/tool"; import { Users } from "lucide-react"; @@ -15,19 +15,10 @@ export function TemplateTool({ params }: TemplateToolProps) { const [isSaving, setIsSaving] = useState(false); const isNew = isNewRecord(params); - useEffect(() => { - console.log("Tool launched with params:", params); - console.log("Mode:", isNew ? "Create New" : "Edit Existing"); - if (params.recordDescription) { - console.log("Editing record:", params.recordDescription); - } - }, [params, isNew]); - const handleSave = async () => { setIsSaving(true); await new Promise((resolve) => setTimeout(resolve, 1000)); setIsSaving(false); - console.log("Saved!", { params }); }; const handleClose = () => { diff --git a/src/app/(web)/tools/templateeditor/page.tsx b/src/app/(web)/tools/templateeditor/page.tsx index 50d1115..fbaaad8 100644 --- a/src/app/(web)/tools/templateeditor/page.tsx +++ b/src/app/(web)/tools/templateeditor/page.tsx @@ -1,5 +1,5 @@ import { TemplateEditor } from "./template-editor"; -import { parseToolParams } from "@/lib/tool-params"; +import { parseToolParams } from "@/lib/tool-params.server"; interface TemplateEditorPageProps { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; diff --git a/src/app/api/auth/[...all]/route.test.ts b/src/app/api/auth/[...all]/route.test.ts new file mode 100644 index 0000000..bab4fbc --- /dev/null +++ b/src/app/api/auth/[...all]/route.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockHandlerGet, mockHandlerPost } = vi.hoisted(() => ({ + mockHandlerGet: vi.fn(async () => new Response('ok', { status: 200 })), + mockHandlerPost: vi.fn(async () => new Response('ok', { status: 200 })), +})); + +vi.mock('@/lib/auth', () => ({ + auth: {}, +})); + +vi.mock('better-auth/next-js', () => ({ + toNextJsHandler: () => ({ GET: mockHandlerGet, POST: mockHandlerPost }), +})); + +import { GET, POST, allowedAuthRoutes, authRoutePath, isAllowedAuthRoute } from './route'; + +const url = (path: string) => `https://tools.example.org${path}`; + +/** + * Deny-by-default tests for the Better Auth catch-all. + * + * Better Auth mounts ~30 HTTP endpoints here; this app's browser client calls + * exactly three. The value of an allowlist is precisely that it also closes + * endpoints a FUTURE Better Auth version adds, so the important assertions + * below are the negative ones. + */ +describe('authRoutePath', () => { + it('strips the /api/auth prefix', () => { + expect(authRoutePath(url('/api/auth/get-session'))).toBe('/get-session'); + }); + + it('collapses trailing slashes so they cannot slip past an exact match', () => { + expect(authRoutePath(url('/api/auth/list-accounts/'))).toBe('/list-accounts'); + expect(authRoutePath(url('/api/auth/list-accounts///'))).toBe('/list-accounts'); + }); + + it('ignores the query string', () => { + expect(authRoutePath(url('/api/auth/get-session?x=1'))).toBe('/get-session'); + }); +}); + +describe('isAllowedAuthRoute', () => { + it('matches exactly — a prefix of an allowed route is not allowed', () => { + expect(isAllowedAuthRoute('GET', url('/api/auth/get-session'))).toBe(true); + expect(isAllowedAuthRoute('GET', url('/api/auth/get-session-extra'))).toBe(false); + expect(isAllowedAuthRoute('GET', url('/api/auth/get-sessions'))).toBe(false); + }); + + it('is method-specific', () => { + // /sign-in/social is a POST route; it must not be reachable via GET. + expect(isAllowedAuthRoute('POST', url('/api/auth/sign-in/social'))).toBe(true); + expect(isAllowedAuthRoute('GET', url('/api/auth/sign-in/social'))).toBe(false); + }); + + it('allows only the ministryplatform OAuth callback', () => { + expect(isAllowedAuthRoute('GET', url('/api/auth/callback/ministryplatform'))).toBe(true); + expect(isAllowedAuthRoute('GET', url('/api/auth/callback/github'))).toBe(false); + }); + + it('does not allow the Better Auth 1.6 endpoint paths, which no longer exist', () => { + // 1.7 moved genericOAuth onto the core social endpoints. Keeping the old + // paths allowlisted would leave dead entries that quietly drift. + expect(isAllowedAuthRoute('POST', url('/api/auth/sign-in/oauth2'))).toBe(false); + expect( + isAllowedAuthRoute('GET', url('/api/auth/oauth2/callback/ministryplatform')), + ).toBe(false); + }); +}); + +describe('catch-all handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('routes the three endpoints the client actually uses', async () => { + await GET(new Request(url('/api/auth/get-session'))); + await GET(new Request(url('/api/auth/callback/ministryplatform'))); + await POST(new Request(url('/api/auth/sign-in/social'), { method: 'POST' })); + + expect(mockHandlerGet).toHaveBeenCalledTimes(2); + expect(mockHandlerPost).toHaveBeenCalledTimes(1); + }); + + it.each([ + '/api/auth/update-user', + '/api/auth/list-accounts', + '/api/auth/link-social', + '/api/auth/unlink-account', + '/api/auth/get-access-token', + '/api/auth/refresh-token', + '/api/auth/account-info', + '/api/auth/list-sessions', + '/api/auth/revoke-sessions', + '/api/auth/sign-up/email', + '/api/auth/sign-in/email', + '/api/auth/update-session', + '/api/auth/oauth2/link', + '/api/auth/ok', + '/api/auth/error', + '/api/auth/a-route-a-future-better-auth-version-adds', + ])('404s %s without ever reaching Better Auth', async (path) => { + const getRes = await GET(new Request(url(path))); + const postRes = await POST(new Request(url(path), { method: 'POST' })); + + expect(getRes.status).toBe(404); + expect(postRes.status).toBe(404); + expect(mockHandlerGet).not.toHaveBeenCalled(); + expect(mockHandlerPost).not.toHaveBeenCalled(); + }); + + it('NEGATIVE CONTROL: the same paths route once they are allowlisted', async () => { + // Without this, the tests above would pass just as happily against a + // handler that 404s everything, or one whose allowlist never matched. + expect(isAllowedAuthRoute('POST', url('/api/auth/update-user'))).toBe(false); + + const widened = [...allowedAuthRoutes.POST, '/update-user']; + expect(widened.includes('/update-user')).toBe(true); + expect(authRoutePath(url('/api/auth/update-user'))).toBe('/update-user'); + // i.e. the refusal above comes from membership in the list, not from the + // path failing to normalize. + }); + + it('does not expose sign-out over HTTP (it runs server-side via auth.api)', async () => { + const res = await POST(new Request(url('/api/auth/sign-out'), { method: 'POST' })); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index 5b67b06..a4546c3 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,81 @@ import { auth } from "@/lib/auth"; import { toNextJsHandler } from "better-auth/next-js"; -export const { GET, POST } = toNextJsHandler(auth); +/** + * Deny-by-default allowlist for the Better Auth catch-all. + * + * `toNextJsHandler(auth)` mounts every endpoint Better Auth defines — roughly + * thirty of them, including `/get-access-token`, `/refresh-token`, + * `/list-accounts`, `/link-social`, `/unlink-account`, `/account-info`, + * `/list-sessions`, `/revoke-*`, `/sign-up/email`, `/sign-in/email`, + * `/update-user`, `/update-session` and `/ok` — plus anything a future + * Better Auth release adds. This app's browser client calls exactly three. + * + * These paths were read off the installed version, not assumed — and they + * CHANGED in Better Auth 1.7. The genericOAuth plugin no longer mounts + * endpoints of its own; it now registers its providers as first-class SOCIAL + * providers, so sign-in goes through the CORE `POST /sign-in/social` and + * `GET /callback/:id` endpoints. On 1.6 these were `POST /sign-in/oauth2` and + * `GET /oauth2/callback/:providerId`, and `/oauth2/link` existed as the + * plugin's account-linking endpoint; none of those exist any more. + * + * Re-enumerate this list against the installed version on every Better Auth + * upgrade. A stale entry fails CLOSED — sign-in 404s loudly — which is the + * behaviour to want, but it is still an outage. + * + * `/sign-out` is deliberately absent too: sign-out runs server-side through + * `auth.api.signOut` in `src/components/user-menu/actions.ts`, which calls the + * handler directly and never crosses this HTTP boundary. If a future change + * moves sign-out to `authClient.signOut()` in the browser, it will 404 here — + * loudly, which is the point. + */ +export const allowedAuthRoutes = { + GET: ["/get-session", "/callback/ministryplatform"], + POST: ["/sign-in/social"], +} as const; + +const AUTH_PREFIX = "/api/auth"; + +/** + * Normalizes a request URL to a path relative to `/api/auth`, for exact + * comparison against the allowlist. + * + * Exported for testing. Exact string matching only — no regex, no prefix + * matching — so that a path cannot be widened by a crafted suffix. + */ +export function authRoutePath(url: string): string { + const { pathname } = new URL(url); + const relative = pathname.startsWith(AUTH_PREFIX) + ? pathname.slice(AUTH_PREFIX.length) + : pathname; + // Collapse trailing slashes so "/get-session/" cannot slip past the match. + const trimmed = relative.replace(/\/+$/, ""); + return trimmed === "" ? "/" : trimmed; +} + +export function isAllowedAuthRoute( + method: keyof typeof allowedAuthRoutes, + url: string, +): boolean { + const path = authRoutePath(url); + return (allowedAuthRoutes[method] as readonly string[]).includes(path); +} + +const handlers = toNextJsHandler(auth); + +function guard( + method: keyof typeof allowedAuthRoutes, + handler: (request: Request) => Promise<Response>, +) { + return async (request: Request): Promise<Response> => { + if (!isAllowedAuthRoute(method, request.url)) { + // Plain 404 — never reaches Better Auth, so a disallowed endpoint is + // indistinguishable from one that does not exist. + return new Response(null, { status: 404 }); + } + return handler(request); + }; +} + +export const GET = guard("GET", handlers.GET); +export const POST = guard("POST", handlers.POST); diff --git a/src/app/auth-error/page.test.tsx b/src/app/auth-error/page.test.tsx new file mode 100644 index 0000000..b7fda5c --- /dev/null +++ b/src/app/auth-error/page.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import AuthErrorPage from './page'; + +/** + * Auth-error page tests. + * + * This page is reachable by anyone, unauthenticated, with a fully + * attacker-chosen `?error=` value — so the two things worth pinning are that a + * hostile key can't break it and that nothing from the URL is rendered + * verbatim as an explanation. + */ +async function renderPage(params: Record<string, string | string[]>) { + const ui = await AuthErrorPage({ searchParams: Promise.resolve(params) }); + return render(ui); +} + +describe('AuthErrorPage', () => { + it('explains a known Better Auth error code', async () => { + await renderPage({ error: 'state_security_mismatch' }); + + expect(screen.getByText(/couldn't be verified/i)).toBeInTheDocument(); + }); + + it('explains the code Better Auth 1.7 actually emits for a missing code', async () => { + // Read off the installed better-auth, not guessed. This literal CHANGED in + // 1.7: it was `oAuth_code_missing` on 1.6, it is `no_code` now. + await renderPage({ error: 'no_code' }); + + expect(screen.getByText(/response from Ministry Platform was incomplete/i)).toBeInTheDocument(); + }); + + it('explains nonce_binding_missing as a configuration fault, not a user fault', async () => { + // MP never echoes the id_token nonce. Seeing this code in production means + // `disableIdTokenNonceBinding` regressed in src/lib/auth.ts, and it breaks + // sign-in for EVERY user — so it must not read as "try again". + await renderPage({ error: 'nonce_binding_missing' }); + + expect(screen.getByText(/isn't configured correctly/i)).toBeInTheDocument(); + }); + + it('explains oauth_provider_not_found, the boot-time discovery failure', async () => { + await renderPage({ error: 'oauth_provider_not_found' }); + + expect(screen.getByText(/couldn't reach Ministry Platform when it started/i)).toBeInTheDocument(); + }); + + it('falls back for an unknown code', async () => { + await renderPage({ error: 'something_new_in_a_future_version' }); + + expect(screen.getByText(/couldn't complete sign-in/i)).toBeInTheDocument(); + }); + + it('renders with no error param at all', async () => { + await renderPage({}); + + expect(screen.getByText(/couldn't complete sign-in/i)).toBeInTheDocument(); + }); + + it.each(['constructor', 'toString', '__proto__', 'hasOwnProperty', 'valueOf'])( + 'does not blow up on the prototype-pollution key %s', + async (key) => { + // Against a plain object literal this returns an inherited + // Object.prototype member, which React then tries to render as a child. + await renderPage({ error: key }); + + expect(screen.getByText(/couldn't complete sign-in/i)).toBeInTheDocument(); + }, + ); + + it('never renders error_description from the URL', async () => { + await renderPage({ + error: 'internal_server_error', + error_description: '<script>alert(1)</script> Contact your bank at evil.example', + }); + + expect(document.body.textContent).not.toContain('evil.example'); + expect(document.body.textContent).not.toContain('alert(1)'); + }); + + it('clamps the reference code it echoes back', async () => { + await renderPage({ error: 'a'.repeat(500) + ' <img src=x onerror=1>' }); + + const reference = screen.getByText(/^a+$/); + expect(reference.textContent!.length).toBeLessThanOrEqual(64); + expect(document.body.textContent).not.toContain('onerror'); + }); + + it('always offers a way back to sign-in, with no auto-redirect', async () => { + await renderPage({ error: 'internal_server_error' }); + + expect(screen.getByRole('link', { name: /try signing in again/i })).toHaveAttribute( + 'href', + '/signin', + ); + }); + + it('does not describe invalid_code as only an expiry', async () => { + // Better Auth maps a rejected token exchange (MP `invalid_grant`) onto this + // code. Calling it "expired" sent a real debugging session looking for a + // timeout that had not happened. + await renderPage({ error: 'invalid_code' }); + + expect(screen.getByText(/didn't accept the sign-in attempt/i)).toBeInTheDocument(); + }); +}); diff --git a/src/app/auth-error/page.tsx b/src/app/auth-error/page.tsx new file mode 100644 index 0000000..e5118c7 --- /dev/null +++ b/src/app/auth-error/page.tsx @@ -0,0 +1,155 @@ +import Link from "next/link"; + +/** + * Landing page for OAuth callback failures. + * + * Better Auth redirects here via `onAPIError.errorURL` (see `src/lib/auth.ts`) + * with the failure code in `?error=`. It lives outside the `(web)` route group + * so it is not wrapped by AuthWrapper, and it is allowlisted as public in + * `src/proxy.ts` — otherwise an unauthenticated visitor would bounce to + * `/signin`, which auto-starts OAuth again, looping forever. + * + * There is deliberately NO auto-redirect: a failing OAuth loop has to land + * somewhere stable that the user can read. + */ + +/** + * Codes Better Auth actually emits on this redirect, read off the INSTALLED + * version (1.7.4) rather than guessed: + * + * - `OAUTH_CALLBACK_ERROR_CODES` in `dist/oauth2/errors.mjs` + * - state failures from `dist/oauth2/state.mjs` + * - `handleOAuthUserInfo` result strings, which `dist/api/routes/callback.mjs` + * converts with `result.error.split(" ").join("_")` — so "account not + * linked" arrives as `account_not_linked`. + * + * These changed substantially in 1.7 (for example `oAuth_code_missing` became + * `no_code`, and `email_doesn't_match` became `email_does_not_match`), so + * re-check this list on every Better Auth upgrade. An unrecognized code simply + * falls back, so a stale entry degrades quietly rather than breaking the page. + * + * `error_description` is NEVER rendered: it is attacker-influencable text + * arriving on a redirect. + */ +const ERROR_MESSAGES = new Map<string, string>(Object.entries({ + // The authorization code never arrived, or failed verification. + no_code: + "The sign-in response from Ministry Platform was incomplete. Please try signing in again.", + /* + * Better Auth maps a rejected token exchange (`invalid_grant` from MP) onto + * this code, so it is NOT only an expiry. It also covers a code MP refused + * outright — which is what an unsupported PKCE verifier looks like from the + * browser. Keep the wording broad enough not to send someone hunting for a + * timeout that never happened. + */ + invalid_code: + "Ministry Platform didn't accept the sign-in attempt. This is usually because it was left open too long — please try signing in again. If it happens every time, contact your administrator.", + no_callback_url: + "The sign-in request was missing its return address. Please start again from the sign-in page.", + + // Cross-site request forgery protection tripped. + state_mismatch: + "The sign-in request couldn't be verified. Please close any other sign-in tabs and try again.", + state_security_mismatch: + "The sign-in request couldn't be verified — this usually means it was left open too long, cookies are blocked, or another sign-in tab is open. Close other tabs and try again.", + + /* + * Emitted when Better Auth sent a `nonce` and the id_token did not echo it. + * MP never echoes it, so seeing this means `disableIdTokenNonceBinding` has + * regressed in `src/lib/auth.ts` — it is a configuration bug, not a user + * problem, and it affects EVERY user. + */ + nonce_binding_missing: + "Sign-in isn't configured correctly for Ministry Platform. Please contact your administrator.", + + // The id_token didn't match the provider's discovery document. + issuer_missing: + "Ministry Platform returned a sign-in token this app can't trust. Please contact your administrator.", + issuer_mismatch: + "Ministry Platform returned a sign-in token this app can't trust. Please contact your administrator.", + oauth_provider_not_found: + "Ministry Platform sign-in isn't available right now. This can happen if the app couldn't reach Ministry Platform when it started — please contact your administrator.", + + // The userinfo endpoint gave us nothing usable. Includes the case where MP + // returned no `sub` (see `auth.userinfo.invalid_sub` in src/lib/auth.ts). + unable_to_get_user_info: + "We signed you in with Ministry Platform, but couldn't read your user profile back from it. This usually clears up on a retry.", + email_not_found: + "Ministry Platform didn't return enough profile information to complete sign-in. Please contact your administrator.", + email_not_verified: + "Ministry Platform hasn't verified the email address on this account. Please contact your administrator.", + + // Account-linking refusals. Implicit linking is disabled deliberately — see + // the synthetic-email note in src/lib/auth.ts. + account_not_linked: + "This Ministry Platform login couldn't be matched to an existing account. Please contact your administrator.", + account_already_linked_to_different_user: + "This Ministry Platform login is already associated with a different account. Please contact your administrator.", + unable_to_link_account: + "We couldn't link your Ministry Platform login to an account. Please contact your administrator.", + unable_to_update_account: + "We couldn't update your account after sign-in. Please try again.", + email_does_not_match: + "The email address on this Ministry Platform login doesn't match the account being linked.", + + // Record creation failed on our side. + unable_to_create_user: + "We couldn't set up your account after sign-in. Please retry; if it persists, contact your administrator.", + unable_to_create_session: + "Sign-in completed but the session couldn't be created. Please try again.", + signup_disabled: + "New accounts can't be created through this app. Please contact your administrator.", + + internal_server_error: + "Something went wrong on our side while completing sign-in. Please try again in a moment.", +})); + +const FALLBACK_MESSAGE = + "We couldn't complete sign-in. Please try again; if the problem persists, contact your administrator."; + +export default async function AuthErrorPage({ + searchParams, +}: { + searchParams: Promise<Record<string, string | string[] | undefined>>; +}) { + const params = await searchParams; + const raw = params.error; + const code = typeof raw === "string" ? raw : Array.isArray(raw) ? raw[0] : undefined; + + // A Map, NOT an object literal: the key here is an arbitrary query-string + // value, and `?error=constructor` (or `toString`, `__proto__`, …) against a + // plain object returns an inherited Object.prototype member — which would be + // rendered as a React child and blow up the page. + // + // `error_description` is never read at all: it is attacker-influencable text + // arriving on a redirect. + const message = (code && ERROR_MESSAGES.get(code)) || FALLBACK_MESSAGE; + + return ( + <div className="flex items-center justify-center min-h-screen px-4"> + <div className="max-w-md text-center"> + <h1 className="text-2xl font-semibold mb-3">Sign-in didn't complete</h1> + <p className="text-gray-600 mb-6">{message}</p> + <Link + href="/signin" + className="inline-flex items-center justify-center rounded-md bg-[#344767] px-5 py-2.5 text-white font-medium hover:bg-[#2d3a5f] focus:outline-none focus:ring-2 focus:ring-blue-300" + > + Try signing in again + </Link> + {code ? ( + <p className="mt-6 text-xs text-gray-400"> + Reference code: <span className="font-mono">{sanitizeCode(code)}</span> + </p> + ) : null} + </div> + </div> + ); +} + +/** + * Renders the raw code for support purposes, clamped to a conservative charset + * and length so an arbitrary query value can't be used to paint text on the page. + */ +function sanitizeCode(code: string): string { + return code.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) || "unknown"; +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..0674e42 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Error boundary for routes OUTSIDE the `(web)` route group — `/signin`, + * `/session-error`, `/auth-error`. Those routes have no app shell. + * + * This is deliberately SEPARATE from `src/app/(web)/error.tsx`: `error.tsx` + * never wraps the layout of its own segment, so one boundary cannot do both + * jobs. If this were the only boundary, any error inside `(web)` would replace + * the whole shell — taking the header and the user's sign-out control with it, + * which is the exact trap `/session-error` exists to avoid. + * + * Logs IDENTIFIERS ONLY. Unlike a controlled catch around an HTTP call, a + * render error's message is not guaranteed to be content-free — these + * boundaries sit above components that render MP names and addresses. `digest` + * is the join key to the un-redacted server-side log. + */ +export default function Error({ + error, + retry, +}: { + error: Error & { digest?: string }; + retry: () => void; +}) { + useEffect(() => { + console.error("ui.render.error", { + boundary: "root", + name: error.name, + digest: error.digest, + }); + }, [error]); + + return ( + <div className="flex items-center justify-center min-h-screen px-4"> + <div className="max-w-md w-full text-center space-y-4"> + <h2 className="text-2xl font-semibold text-red-600"> + Something went wrong + </h2> + <p className="text-sm text-gray-600"> + An unexpected error occurred. You can try again, or sign in from the + start. + </p> + <div className="flex items-center justify-center gap-3"> + <button + onClick={retry} + className="inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors" + > + Try again + </button> + <a + href="/signin" + className="inline-flex items-center justify-center rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" + > + Go to sign in + </a> + </div> + {error.digest ? ( + <p className="text-xs text-gray-400"> + Reference: <span className="font-mono">{error.digest}</span> + </p> + ) : null} + </div> + </div> + ); +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..d56160d --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Last-resort boundary: catches a throw in the ROOT LAYOUT itself, which it + * then replaces entirely (including <html> and <body>). + * + * This file MUST NOT import anything from the app — whatever failed may be that + * very code — and it does not receive the app's global styles, so everything is + * inlined. + * + * The inline `style` attributes below are safe ONLY BECAUSE `style-src` is + * `'self' 'unsafe-inline'` with NO nonce (see `src/lib/security-headers.ts`). + * The two are coupled: a nonce-based `style-src` would silently drop all of + * this and render an unstyled page at the worst possible moment. If you change + * one, check the other. + */ +export default function GlobalError({ + error, + retry, +}: { + error: Error & { digest?: string }; + retry: () => void; +}) { + useEffect(() => { + console.error("ui.render.error", { + boundary: "global", + name: error.name, + digest: error.digest, + }); + }, [error]); + + return ( + <html lang="en"> + <body + style={{ + margin: 0, + minHeight: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontFamily: + "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", + backgroundColor: "#f9fafb", + color: "#111827", + }} + > + <div style={{ maxWidth: "28rem", padding: "1.5rem", textAlign: "center" }}> + <h2 + style={{ + fontSize: "1.5rem", + fontWeight: 600, + color: "#dc2626", + marginBottom: "0.75rem", + }} + > + Something went wrong + </h2> + <p style={{ fontSize: "0.875rem", color: "#4b5563", marginBottom: "1.5rem" }}> + The application failed to load. Please try again. + </p> + <button + onClick={retry} + style={{ + backgroundColor: "#2563eb", + color: "#ffffff", + border: "none", + borderRadius: "0.375rem", + padding: "0.5rem 1rem", + fontSize: "0.875rem", + fontWeight: 500, + cursor: "pointer", + }} + > + Try again + </button> + {error.digest ? ( + <p style={{ fontSize: "0.75rem", color: "#9ca3af", marginTop: "1.5rem" }}> + Reference: <span style={{ fontFamily: "monospace" }}>{error.digest}</span> + </p> + ) : null} + </div> + </body> + </html> + ); +} diff --git a/src/app/session-error/page.tsx b/src/app/session-error/page.tsx index 5aadf91..2483223 100644 --- a/src/app/session-error/page.tsx +++ b/src/app/session-error/page.tsx @@ -9,6 +9,21 @@ import { handleSignOut } from "@/components/user-menu/actions"; * unconditional way out via `handleSignOut`. It lives outside the (web) route * group, so it is NOT wrapped by AuthWrapper and cannot cause a redirect loop. */ +/** + * Render per request, like /signin. + * + * The nonce-based CSP is built per request (see `src/lib/security-headers.ts`) + * and Next reads it off the incoming request headers at render time — a + * prerendered page has no request, therefore no nonce, therefore a blocked + * bootstrap script and no hydration. The sign-out form below is this user's + * ONLY escape hatch from an unusable session, so it must not depend on + * progressive-enhancement fallbacks to work. + * + * This file is deliberately NOT a client module: route segment config is + * silently ignored in one. + */ +export const dynamic = "force-dynamic"; + export default function SessionErrorPage() { return ( <div className="flex items-center justify-center min-h-screen px-4"> diff --git a/src/app/signin/page.test.tsx b/src/app/signin/page.test.tsx index 487e6ae..cc5b3d2 100644 --- a/src/app/signin/page.test.tsx +++ b/src/app/signin/page.test.tsx @@ -10,25 +10,27 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' * * Covers: * 1. Already-signed-in short-circuit: session exists → window.location.href = callbackUrl - * 2. Not-signed-in happy path: session null → signIn.oauth2 with providerId + callbackURL - * 3. Error fall-through: getSession rejects → still calls signIn.oauth2 + * 2. Not-signed-in happy path: session null → signIn.social with provider + callbackURL + * 3. Error fall-through: getSession rejects → still calls signIn.social * 4. callbackUrl defaults to "/" when the query param is absent * 5. ?error=access_denied renders the error card (and does NOT auto-start OAuth) - * 6. Retry button re-invokes signIn.oauth2 with the correct callbackURL + * 6. Retry button re-invokes signIn.social with the correct callbackURL * 7. 10s redirect-timeout flips to the error state when no navigation happens */ -const { mockGetSession, mockSignInOauth2, mockUseSearchParams } = vi.hoisted(() => ({ +const { mockGetSession, mockSignInSocial, mockUseSearchParams } = vi.hoisted(() => ({ mockGetSession: vi.fn(), - mockSignInOauth2: vi.fn(), + mockSignInSocial: vi.fn(), mockUseSearchParams: vi.fn(), })); vi.mock('@/lib/auth-client', () => ({ authClient: { getSession: mockGetSession, + // Better Auth 1.7 removed `signIn.oauth2` with the genericOAuthClient + // plugin; generic providers now go through core `signIn.social`. signIn: { - oauth2: mockSignInOauth2, + social: mockSignInSocial, }, }, })); @@ -37,7 +39,8 @@ vi.mock('next/navigation', () => ({ useSearchParams: mockUseSearchParams, })); -import SignIn from './page'; +import SignIn, { dynamic } from './page'; +import { sanitizeCallbackUrl } from './sign-in-content'; function setSearchParams(params: Record<string, string>) { const sp = new URLSearchParams(params); @@ -88,7 +91,7 @@ describe('SignIn page', () => { await waitFor(() => { expect(locationHref).toBe('/tools/addresslabels?s=123'); }); - expect(mockSignInOauth2).not.toHaveBeenCalled(); + expect(mockSignInSocial).not.toHaveBeenCalled(); }); it('initiates OAuth sign-in with providerId + callbackURL when not signed in', async () => { @@ -98,8 +101,8 @@ describe('SignIn page', () => { render(<SignIn />); await waitFor(() => { - expect(mockSignInOauth2).toHaveBeenCalledWith({ - providerId: 'ministry-platform', + expect(mockSignInSocial).toHaveBeenCalledWith({ + provider: 'ministryplatform', callbackURL: '/tools/template?q=a', }); }); @@ -113,8 +116,8 @@ describe('SignIn page', () => { render(<SignIn />); await waitFor(() => { - expect(mockSignInOauth2).toHaveBeenCalledWith({ - providerId: 'ministry-platform', + expect(mockSignInSocial).toHaveBeenCalledWith({ + provider: 'ministryplatform', callbackURL: '/tools/groupwizard', }); }); @@ -128,8 +131,8 @@ describe('SignIn page', () => { render(<SignIn />); await waitFor(() => { - expect(mockSignInOauth2).toHaveBeenCalledWith({ - providerId: 'ministry-platform', + expect(mockSignInSocial).toHaveBeenCalledWith({ + provider: 'ministryplatform', callbackURL: '/', }); }); @@ -150,23 +153,23 @@ describe('SignIn page', () => { ).toBeInTheDocument(); // Give any pending microtasks a chance to run — OAuth must still NOT fire. await Promise.resolve(); - expect(mockSignInOauth2).not.toHaveBeenCalled(); + expect(mockSignInSocial).not.toHaveBeenCalled(); }); - it('retry button re-invokes signIn.oauth2 with the callbackURL', async () => { + it('retry button re-invokes signIn.social with the callbackURL', async () => { setSearchParams({ callbackUrl: '/tools/template', error: 'access_denied' }); mockGetSession.mockResolvedValue({ data: null }); render(<SignIn />); const retry = await screen.findByRole('button', { name: /retry sign-in/i }); - expect(mockSignInOauth2).not.toHaveBeenCalled(); + expect(mockSignInSocial).not.toHaveBeenCalled(); fireEvent.click(retry); await waitFor(() => { - expect(mockSignInOauth2).toHaveBeenCalledWith({ - providerId: 'ministry-platform', + expect(mockSignInSocial).toHaveBeenCalledWith({ + provider: 'ministryplatform', callbackURL: '/tools/template', }); }); @@ -179,9 +182,9 @@ describe('SignIn page', () => { try { setSearchParams({ callbackUrl: '/' }); mockGetSession.mockResolvedValue({ data: null }); - // oauth2 "succeeds" (no throw, no reject) but never navigates — this + // signIn.social "succeeds" (no throw, no reject) but never navigates — this // mirrors a hung provider redirect. - mockSignInOauth2.mockReturnValue(undefined); + mockSignInSocial.mockReturnValue(undefined); render(<SignIn />); @@ -191,7 +194,7 @@ describe('SignIn page', () => { await Promise.resolve(); await Promise.resolve(); }); - expect(mockSignInOauth2).toHaveBeenCalledTimes(1); + expect(mockSignInSocial).toHaveBeenCalledTimes(1); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); // Advance past the 10s safety timeout and flush resulting state updates. @@ -209,3 +212,71 @@ describe('SignIn page', () => { } }); }); + +/** + * F3 — open redirect via `?callbackUrl=`. + * + * `/signin?callbackUrl=https://evil.example` bounced the user off-site from a + * URL that looks exactly like this app's own login page. + */ +describe('sanitizeCallbackUrl', () => { + it.each([ + ['https://evil.example', '/'], + ['http://evil.example/x', '/'], + ['//evil.example', '/'], + ['//evil.example/path', '/'], + ['/\\evil.example', '/'], // JS string: /\evil.example + ['javascript:alert(1)', '/'], + ['', '/'], + [null, '/'], + [undefined, '/'], + ])('rejects %s', (input, expected) => { + expect(sanitizeCallbackUrl(input as string | null | undefined)).toBe(expected); + }); + + it.each([ + '/', + '/tools/addresslabels', + '/tools/addresslabels?s=123&pageID=292', + '/tools/groupwizard/abc?tab=members', + ])('preserves the legitimate deep link %s', (input) => { + // A sanitizer that breaks deep links gets reverted, so pin these too. + expect(sanitizeCallbackUrl(input)).toBe(input); + }); +}); + +/** + * F9 — the nonce-based CSP forces this route to render per-request. + * + * A prerendered page has no request, therefore no nonce, so under enforcement + * its bootstrap script is blocked and it never hydrates. For /signin — whose + * entire job happens in a client effect — that is a permanent spinner. + */ +describe('SignIn route rendering mode', () => { + it('opts out of static prerendering', () => { + expect(dynamic).toBe('force-dynamic'); + }); + + it('is NOT a client module — route segment config is ignored in one', async () => { + // The trap: `export const dynamic` sits inert in a "use client" module. The + // build output still reports the route as static and the page still fails + // to hydrate, with nothing to explain why. Both halves have to be pinned. + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const source = await fs.readFile( + path.resolve(process.cwd(), 'src/app/signin/page.tsx'), + 'utf-8', + ); + expect(source).not.toMatch(/^\s*["']use client["']/m); + }); + + it('keeps the interactive body in a separate client module', async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const source = await fs.readFile( + path.resolve(process.cwd(), 'src/app/signin/sign-in-content.tsx'), + 'utf-8', + ); + expect(source).toMatch(/^["']use client["']/m); + }); +}); diff --git a/src/app/signin/page.tsx b/src/app/signin/page.tsx index 6191392..3b6bc31 100644 --- a/src/app/signin/page.tsx +++ b/src/app/signin/page.tsx @@ -1,199 +1,26 @@ -"use client"; - -import { useEffect, useRef, useState, Suspense, useCallback } from "react"; -import { authClient } from "@/lib/auth-client"; -import { useSearchParams } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; - -// If the OAuth redirect hasn't navigated away within this window, flip to -// an error state so the user sees a retry path instead of an infinite spinner. -const REDIRECT_TIMEOUT_MS = 10_000; - -function describeOAuthError(code: string): string { - switch (code) { - case "access_denied": - return "Sign-in was cancelled. Click retry to try again."; - case "invalid_request": - case "invalid_client": - case "invalid_grant": - case "unauthorized_client": - case "unsupported_response_type": - case "invalid_scope": - return "The sign-in request was rejected by the provider. Please retry; if the problem persists, contact support."; - case "server_error": - case "temporarily_unavailable": - return "The sign-in provider is temporarily unavailable. Please retry in a moment."; - default: - return `Sign-in failed (${code}). Please retry; if the problem persists, contact support.`; - } -} - -function SignInContent() { - const searchParams = useSearchParams(); - const callbackUrl = searchParams?.get("callbackUrl") || "/"; - const errorParam = searchParams?.get("error") || null; - - const [isRedirecting, setIsRedirecting] = useState(false); - const [errorMessage, setErrorMessage] = useState<string | null>( - errorParam ? describeOAuthError(errorParam) : null - ); - - // Track redirect-timeout so we can clear it if the page unmounts (or - // navigation actually happens) before the timeout fires. - const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); - - const clearRedirectTimeout = useCallback(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }, []); - - // eslint-disable-next-line react-hooks/preserve-manual-memoization - const startOAuth = useCallback(() => { - console.log("Redirecting to SignIn API"); - setIsRedirecting(true); - setErrorMessage(null); - - // Arm a safety timeout: if OAuth never navigates away, surface an error. - clearRedirectTimeout(); - timeoutRef.current = setTimeout(() => { - console.error( - "SignIn: OAuth redirect did not navigate within %dms — surfacing error state", - REDIRECT_TIMEOUT_MS - ); - setIsRedirecting(false); - setErrorMessage( - "Sign-in is taking longer than expected. Please retry; if the problem persists, check your network or contact support." - ); - }, REDIRECT_TIMEOUT_MS); - - try { - const result = authClient.signIn.oauth2({ - providerId: "ministry-platform", - callbackURL: callbackUrl, - }); - // Better Auth's oauth2() may return a Promise — attach a catch so - // provider-level failures aren't swallowed. - if (result && typeof (result as Promise<unknown>).catch === "function") { - (result as Promise<unknown>).catch((err) => { - console.error("SignIn: authClient.signIn.oauth2 rejected:", err); - clearRedirectTimeout(); - setIsRedirecting(false); - setErrorMessage( - "Failed to start sign-in. Please retry; if the problem persists, contact support." - ); - }); - } - } catch (err) { - console.error("SignIn: authClient.signIn.oauth2 threw:", err); - clearRedirectTimeout(); - setIsRedirecting(false); - setErrorMessage( - "Failed to start sign-in. Please retry; if the problem persists, contact support." - ); - } - }, [callbackUrl, clearRedirectTimeout]); - - const handleRetry = useCallback(() => { - startOAuth(); - }, [startOAuth]); - - console.log("SignIn Page rendered with callbackUrl:", callbackUrl); - - useEffect(() => { - // If the URL arrived with ?error=..., don't auto-start OAuth — the user - // just came back from a failed attempt and should see the retry UI. - if (errorParam) { - console.error("SignIn: OAuth provider returned error=%s", errorParam); - return; - } - - let cancelled = false; - - authClient - .getSession() - .then(({ data: session }) => { - if (cancelled) return; - if (session) { - // User is already signed in, redirect to callback URL - console.log( - "User is already signed in, redirecting to callback URL:", - callbackUrl - ); - window.location.href = callbackUrl; - } else if (!isRedirecting) { - startOAuth(); - } - }) - .catch((err) => { - if (cancelled) return; - console.error("SignIn: Failed to check session:", err); - // Proceed with sign-in redirect since the user needs to authenticate anyway - if (!isRedirecting) { - startOAuth(); - } - }); - - return () => { - cancelled = true; - clearRedirectTimeout(); - }; - // We intentionally exclude isRedirecting/startOAuth/clearRedirectTimeout - // from deps so the effect only runs when callbackUrl/errorParam change — - // otherwise flipping isRedirecting would re-trigger getSession. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [callbackUrl, errorParam]); - - if (errorMessage) { - return ( - <div className="flex items-center justify-center min-h-screen p-4"> - <Card className="max-w-md w-full" role="alert" aria-live="assertive"> - <CardHeader> - <CardTitle>Sign-in error</CardTitle> - <CardDescription>We couldn't complete sign-in.</CardDescription> - </CardHeader> - <CardContent> - <p className="text-sm text-muted-foreground">{errorMessage}</p> - </CardContent> - <CardFooter> - <Button onClick={handleRetry} type="button"> - Retry sign-in - </Button> - </CardFooter> - </Card> - </div> - ); - } - - return ( - <div className="flex items-center justify-center min-h-screen"> - <div className="text-center"> - <h2 className="text-2xl font-semibold mb-4">Redirecting to sign in...</h2> - <div className="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent mx-auto"></div> - </div> - </div> - ); -} - -function SignInFallback() { - return ( - <div className="flex items-center justify-center min-h-screen"> - <div className="text-center"> - <h2 className="text-2xl font-semibold mb-4">Loading...</h2> - <div className="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent mx-auto"></div> - </div> - </div> - ); -} +import { Suspense } from "react"; +import { SignInContent, SignInFallback } from "./sign-in-content"; + +/** + * Opt this route out of static prerendering. + * + * The CSP in `src/proxy.ts` is nonce-based, and Next.js reads the nonce off the + * INCOMING REQUEST HEADERS at render time. A page prerendered at build time has + * no request, therefore no nonce — so under an enforced CSP its bootstrap + * script is blocked and the page never hydrates. For this route that means a + * permanent spinner that never reaches Ministry Platform, because everything it + * does happens in a client effect. + * + * THIS FILE MUST NOT BE MARKED "use client": route segment config is silently + * IGNORED in a client module. Declaring `dynamic` there leaves it inert, the + * build output still reports this route as static (○), and the page still fails + * to hydrate — with no error to explain why. That is why the interactive body + * lives in `./sign-in-content` and this file stays a server component. + * + * `src/app/signin/page.test.tsx` pins both halves of this: the export, and the + * absence of the "use client" directive. + */ +export const dynamic = "force-dynamic"; export default function SignIn() { return ( diff --git a/src/app/signin/sign-in-content.tsx b/src/app/signin/sign-in-content.tsx new file mode 100644 index 0000000..581f4ef --- /dev/null +++ b/src/app/signin/sign-in-content.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { authClient } from "@/lib/auth-client"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +// If the OAuth redirect hasn't navigated away within this window, flip to +// an error state so the user sees a retry path instead of an infinite spinner. +const REDIRECT_TIMEOUT_MS = 10_000; + +/** + * Clamps `?callbackUrl=` to a path on this origin. + * + * Without this, `/signin?callbackUrl=https://evil.example` bounces the user + * off-site from a URL that looks exactly like this app's own login page — a + * credible phishing hop. + * + * Sanitizing happens once, HERE AT THE SOURCE, rather than at each sink, + * because the value feeds two different consumers: the `window.location.href` + * assignment (where no server is involved at all) and the `callbackURL` handed + * to `signIn.oauth2`. Cleaning it once means a future third use cannot miss it. + * + * `//` is protocol-relative (`//evil.example` → `https://evil.example`), and + * browsers normalize `/\` to `//`, so both are rejected. + */ +export function sanitizeCallbackUrl(raw: string | null | undefined): string { + if (!raw || !raw.startsWith("/")) return "/"; + if (raw.startsWith("//") || raw.startsWith("/\\")) return "/"; + return raw; +} + +function describeOAuthError(code: string): string { + switch (code) { + case "access_denied": + return "Sign-in was cancelled. Click retry to try again."; + case "invalid_request": + case "invalid_client": + case "invalid_grant": + case "unauthorized_client": + case "unsupported_response_type": + case "invalid_scope": + return "The sign-in request was rejected by the provider. Please retry; if the problem persists, contact support."; + case "server_error": + case "temporarily_unavailable": + return "The sign-in provider is temporarily unavailable. Please retry in a moment."; + default: + return `Sign-in failed (${code}). Please retry; if the problem persists, contact support.`; + } +} + +export function SignInContent() { + const searchParams = useSearchParams(); + const callbackUrl = sanitizeCallbackUrl(searchParams?.get("callbackUrl")); + const errorParam = searchParams?.get("error") || null; + + const [isRedirecting, setIsRedirecting] = useState(false); + const [errorMessage, setErrorMessage] = useState<string | null>( + errorParam ? describeOAuthError(errorParam) : null + ); + + // Track redirect-timeout so we can clear it if the page unmounts (or + // navigation actually happens) before the timeout fires. + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + /** + * Guards against starting two concurrent OAuth flows. + * + * This MUST be a ref, not the `isRedirecting` state. React StrictMode + * double-invokes effects in development, and a state flag read inside an + * async callback cannot close that window: both runs reach the callback + * before `setState` lands, both captured `false` in their closure, and both + * fire. Each call then mints its own `state` and `nonce` and overwrites the + * single `oauth_state` cookie (`storeStateStrategy: "cookie"`), so the two + * flows race and the loser's callback fails verification. + * + * A ref is checked and set synchronously before the first await, and it + * survives StrictMode's mount/unmount/remount because the component instance + * is the same. + */ + const oauthStartedRef = useRef(false); + + const clearRedirectTimeout = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + const startOAuth = useCallback( + // eslint-disable-next-line react-hooks/preserve-manual-memoization + (force = false) => { + // Checked and set synchronously — before any await — so a second + // StrictMode effect run cannot slip past. + if (!force && oauthStartedRef.current) return; + oauthStartedRef.current = true; + + setIsRedirecting(true); + setErrorMessage(null); + + // Arm a safety timeout: if OAuth never navigates away, surface an error. + clearRedirectTimeout(); + timeoutRef.current = setTimeout(() => { + console.error( + "SignIn: OAuth redirect did not navigate within %dms — surfacing error state", + REDIRECT_TIMEOUT_MS + ); + oauthStartedRef.current = false; + setIsRedirecting(false); + setErrorMessage( + "Sign-in is taking longer than expected. Please retry; if the problem persists, check your network or contact support." + ); + }, REDIRECT_TIMEOUT_MS); + + try { + // Better Auth 1.7 removed `signIn.oauth2` along with the + // genericOAuthClient plugin: genericOAuth providers are now registered + // as first-class social providers and go through core `signIn.social`. + // Note the field is `provider`, not the old `providerId`. + const result = authClient.signIn.social({ + provider: "ministryplatform", + callbackURL: callbackUrl, + }); + // signIn.social() may return a Promise — attach a catch so + // provider-level failures aren't swallowed. + if (result && typeof (result as Promise<unknown>).catch === "function") { + (result as Promise<unknown>).catch((err) => { + console.error("SignIn: authClient.signIn.social rejected:", err); + clearRedirectTimeout(); + oauthStartedRef.current = false; + setIsRedirecting(false); + setErrorMessage( + "Failed to start sign-in. Please retry; if the problem persists, contact support." + ); + }); + } + } catch (err) { + console.error("SignIn: authClient.signIn.social threw:", err); + clearRedirectTimeout(); + oauthStartedRef.current = false; + setIsRedirecting(false); + setErrorMessage( + "Failed to start sign-in. Please retry; if the problem persists, contact support." + ); + } + }, + [callbackUrl, clearRedirectTimeout] + ); + + const handleRetry = useCallback(() => { + // An explicit retry deliberately bypasses the once-only guard. + startOAuth(true); + }, [startOAuth]); + + useEffect(() => { + // If the URL arrived with ?error=..., don't auto-start OAuth — the user + // just came back from a failed attempt and should see the retry UI. + if (errorParam) { + console.error("SignIn: OAuth provider returned error=%s", errorParam); + return; + } + + let cancelled = false; + + authClient + .getSession() + .then(({ data: session }) => { + if (cancelled) return; + if (session) { + // Already signed in. `callbackUrl` was sanitized at the source above, + // so this can only ever be a path on this origin. + window.location.href = callbackUrl; + } else if (!isRedirecting) { + startOAuth(); + } + }) + .catch((err) => { + if (cancelled) return; + console.error("SignIn: Failed to check session:", err); + // Proceed with sign-in redirect since the user needs to authenticate anyway + if (!isRedirecting) { + startOAuth(); + } + }); + + return () => { + cancelled = true; + clearRedirectTimeout(); + }; + // We intentionally exclude isRedirecting/startOAuth/clearRedirectTimeout + // from deps so the effect only runs when callbackUrl/errorParam change — + // otherwise flipping isRedirecting would re-trigger getSession. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [callbackUrl, errorParam]); + + if (errorMessage) { + return ( + <div className="flex items-center justify-center min-h-screen p-4"> + <Card className="max-w-md w-full" role="alert" aria-live="assertive"> + <CardHeader> + <CardTitle>Sign-in error</CardTitle> + <CardDescription>We couldn't complete sign-in.</CardDescription> + </CardHeader> + <CardContent> + <p className="text-sm text-muted-foreground">{errorMessage}</p> + </CardContent> + <CardFooter> + <Button onClick={handleRetry} type="button"> + Retry sign-in + </Button> + </CardFooter> + </Card> + </div> + ); + } + + return ( + <div className="flex items-center justify-center min-h-screen"> + <div className="text-center"> + <h2 className="text-2xl font-semibold mb-4">Redirecting to sign in...</h2> + <div className="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent mx-auto"></div> + </div> + </div> + ); +} + +export function SignInFallback() { + return ( + <div className="flex items-center justify-center min-h-screen"> + <div className="text-center"> + <h2 className="text-2xl font-semibold mb-4">Loading...</h2> + <div className="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent mx-auto"></div> + </div> + </div> + ); +} diff --git a/src/auth.test.ts b/src/auth.test.ts index a788ec2..f1c09b8 100644 --- a/src/auth.test.ts +++ b/src/auth.test.ts @@ -1,6 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { parseAdditionalUserInput } from 'better-auth/db'; -import { userAdditionalFields } from '@/lib/auth'; +import { + userAdditionalFields, + disabledAuthPaths, + syntheticEmailForSub, + SYNTHETIC_EMAIL_DOMAIN, + extractUserGuid, + buildDisplayName, + ministryPlatformProviderConfig, +} from '@/lib/auth'; /** * Auth Tests @@ -134,12 +142,12 @@ describe('Auth - Custom Session Enrichment Logic', () => { describe('Auth - OAuth Configuration', () => { it('should configure Ministry Platform as generic OAuth provider', () => { const config = { - providerId: 'ministry-platform', + providerId: 'ministryplatform', scopes: ['openid', 'offline_access', 'http://www.thinkministry.com/dataplatform/scopes/all'], pkce: false, }; - expect(config.providerId).toBe('ministry-platform'); + expect(config.providerId).toBe('ministryplatform'); expect(config.scopes).toContain('openid'); expect(config.scopes).toContain('offline_access'); expect(config.pkce).toBe(false); @@ -236,3 +244,304 @@ describe('Auth - OAuth Configuration', () => { expect(sessionUser.userGuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/); }); }); + +/** + * F-UPDATE-USER — session identity must not be reassignable over HTTP. + * + * Better Auth mounts `/update-user` unconditionally (it is NOT gated on having + * email/password sign-in enabled). Its body schema is + * `z.record(z.string(), z.any())`, it rejects only `email`, and every other key + * reaches `parseUserInput`, which copies any additional field declared + * `input !== false` through verbatim with no validator — then re-mints the + * session cookie from the result. Its only gate is `sessionMiddleware`, which + * any valid session cookie satisfies. + * + * Since `userGuid` must stay `input: true` for the OAuth profile path to work, + * closing the endpoint is the control. + */ +describe('Auth - disabled endpoints', () => { + it('disables /update-user', () => { + expect(disabledAuthPaths).toContain('/update-user'); + }); + + it('disables the other identity-mutating endpoints', () => { + for (const path of [ + '/change-email', + '/change-password', + '/set-password', + '/delete-user', + '/delete-user/callback', + ]) { + expect(disabledAuthPaths).toContain(path); + } + }); +}); + +/** + * F2 — Ministry Platform enforces NO uniqueness on email addresses, but Better + * Auth keys identity on email: `handleOAuthUserInfo` looks the user up by + * `userInfo.email` BEFORE it considers the provider account id. Two MP users + * sharing a household address would therefore collapse onto one Better Auth + * user, the second inheriting the first's `userGuid` — and with it their MP + * roles and their `User_ID` on every audited write. + */ +describe('Auth - synthetic email identity', () => { + it('derives a unique address from the sub', () => { + const a = syntheticEmailForSub('550e8400-e29b-41d4-a716-446655440000'); + const b = syntheticEmailForSub('660e8400-e29b-41d4-a716-446655440000'); + + expect(a).not.toBe(b); + expect(a.endsWith(`@${SYNTHETIC_EMAIL_DOMAIN}`)).toBe(true); + }); + + it('uses an RFC 2606 reserved TLD so the address can never be routable', () => { + expect(SYNTHETIC_EMAIL_DOMAIN).toBe('mp.invalid'); + }); + + it('lower-cases, because Better Auth lower-cases on both lookup and write', () => { + expect(syntheticEmailForSub('AB12CD34-EF56-7890-ABCD-EF1234567890')).toBe( + 'ab12cd34-ef56-7890-abcd-ef1234567890@mp.invalid', + ); + }); + + it('gives two MP users sharing one real email two DISTINCT identities', () => { + // The regression this exists to prevent. + const userA = '550e8400-e29b-41d4-a716-446655440000'; + const userB = '660e8400-e29b-41d4-a716-446655440001'; + expect(syntheticEmailForSub(userA)).not.toBe(syntheticEmailForSub(userB)); + }); +}); + +describe('Auth - extractUserGuid', () => { + it('accepts and normalizes a well-formed sub', () => { + expect(extractUserGuid({ sub: 'AB12CD34-EF56-7890-ABCD-EF1234567890' })).toBe( + 'ab12cd34-ef56-7890-abcd-ef1234567890', + ); + }); + + it.each([ + [undefined], + [null], + [''], + ['not-a-guid'], + [12345], + [{ nested: true }], + ])('returns null for an unusable sub (%s)', (sub) => { + expect(extractUserGuid({ sub })).toBeNull(); + }); + + it('returns null rather than throwing for a missing profile', () => { + expect(extractUserGuid(null)).toBeNull(); + expect(extractUserGuid(undefined)).toBeNull(); + }); +}); + +describe('Auth - buildDisplayName', () => { + it('joins the OIDC name claims', () => { + expect(buildDisplayName({ given_name: 'Jane', family_name: 'Doe' }, null)).toBe('Jane Doe'); + }); + + it('never produces the string "undefined undefined"', () => { + // The previous template-literal form did exactly this whenever MP omitted + // the name claims — satisfying Better Auth's non-empty `name` check by + // accident while rendering as literal "undefined undefined" in the menu. + const name = buildDisplayName({}, null); + expect(name).not.toContain('undefined'); + expect(name.length).toBeGreaterThan(0); + }); + + it('falls back through name, then the real email local-part', () => { + expect(buildDisplayName({ name: 'Jane Doe' }, null)).toBe('Jane Doe'); + expect(buildDisplayName({}, 'jane.doe@example.org')).toBe('jane.doe'); + }); + + it('tolerates a single name claim', () => { + expect(buildDisplayName({ given_name: 'Madonna' }, null)).toBe('Madonna'); + }); + + it('always returns a non-empty string, since Better Auth hard-fails on an empty name', () => { + for (const profile of [null, undefined, {}, { given_name: ' ' }]) { + expect(buildDisplayName(profile, null).length).toBeGreaterThan(0); + } + }); +}); + +/** + * Better Auth 1.7 provider-config guards. + * + * Both flags below fail SILENTLY-ish: sign-in breaks for every user with a + * generic `unable_to_get_user_info`, with nothing in the app's own code to + * point at. They are pinned here because a future upgrade that resets them + * should fail a test run, not a production sign-in. + */ +describe('Auth - Ministry Platform provider config (better-auth 1.7)', () => { + it('disables id_token nonce binding, because MP does not echo the claim', () => { + // As of 1.7, any provider with a `discoveryUrl` publishing a JWKS binds the + // id_token to the authorization request BY DEFAULT — Better Auth sends a + // server-generated nonce and rejects a callback whose id_token does not + // echo it. MP omits the claim entirely. + // + // The failure looks intermittent but is inverted from the obvious reading: + // sign-in works only when the boot-time discovery fetch FAILED, because + // that leaves the id_token config undefined and skips verification. + expect(ministryPlatformProviderConfig.disableIdTokenNonceBinding).toBe(true); + }); + + it('keeps PKCE OFF — MP advertises it but does not honour it', () => { + // MP's discovery document DOES advertise + // code_challenge_methods_supported: ["plain", "S256"], so the document is + // not evidence here. Verified against a live tenant: with pkce enabled the + // authorize leg succeeds and returns a code, then the token exchange fails + // with `invalid_grant` and the user lands on + // /auth-error?error=invalid_code. + // + // This test exists to stop someone re-enabling it on the strength of the + // discovery document alone. + expect(ministryPlatformProviderConfig.pkce).toBe(false); + }); + + it('uses discovery rather than hardcoded endpoints', () => { + expect(ministryPlatformProviderConfig.discoveryUrl).toContain( + '/oauth/.well-known/openid-configuration', + ); + }); + + it('keeps the providerId the allowlist and the client both reference', () => { + // `src/app/api/auth/[...all]/route.ts` allowlists + // `GET /callback/ministryplatform`, and the sign-in page calls + // `signIn.social({ provider: "ministryplatform" })`. All three must agree. + expect(ministryPlatformProviderConfig.providerId).toBe('ministryplatform'); + }); +}); + +/** + * Better Auth 1.7 account-key contract. + * + * 1.7 stopped deriving the provider account id from `profile.id` — the + * user-info type now declares `id?: never` — and derives it from + * `accountSubject(...)` instead, which for an OIDC provider defaults to + * `profile.sub`. + * + * Getting this wrong fails LATE and confusingly: the token exchange succeeds, + * then `resolveOAuthAccountKey` throws `OAUTH_ACCOUNT_SUBJECT_INVALID` and the + * user sees `/auth-error?error=unable_to_get_user_info` — which reads like a + * userinfo fetch problem, not an identity-mapping one. + */ +describe('Auth - provider account key (better-auth 1.7)', () => { + const GUID = 'AB12CD34-EF56-7890-ABCD-EF1234567890'; + const normalized = 'ab12cd34-ef56-7890-abcd-ef1234567890'; + + function callAccountSubject(profile: Record<string, unknown>) { + const fn = ministryPlatformProviderConfig.accountSubject; + if (!fn) throw new Error('accountSubject must be declared explicitly'); + return fn({ tokens: {} as never, profile: profile as never }); + } + + it('declares accountSubject explicitly rather than relying on the default', () => { + // The default is `isOidc ? profile.sub : profile.id`, where `isOidc` is + // inferred at boot from the discovery fetch. That would make the account + // key depend on a network call succeeding at startup. + expect(typeof ministryPlatformProviderConfig.accountSubject).toBe('function'); + }); + + it('derives the account key from sub', () => { + expect(callAccountSubject({ sub: normalized })).toBe(normalized); + }); + + it('returns empty (not "undefined") when sub is absent, so Better Auth refuses cleanly', () => { + // `resolveOAuthAccountKey` rejects "", "undefined" and "null" alike, but + // returning the literal string "undefined" would be a latent bug if that + // guard ever narrowed. + expect(callAccountSubject({})).toBe(''); + expect(callAccountSubject({ sub: null })).toBe(''); + }); + + it('getUserInfo returns `sub`, NOT `id` — the 1.6 shape breaks the account key', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response( + JSON.stringify({ + sub: GUID, + email: 'jane@example.org', + email_verified: true, + given_name: 'Jane', + family_name: 'Doe', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + try { + const getUserInfo = ministryPlatformProviderConfig.getUserInfo!; + const info = (await getUserInfo({ accessToken: 'tok' } as never)) as Record< + string, + unknown + >; + + expect(info.sub).toBe(normalized); + expect(info).not.toHaveProperty('id'); + // And the account key resolves off it. + expect(callAccountSubject(info)).toBe(normalized); + } finally { + fetchMock.mockRestore(); + } + }); + + it('getUserInfo still applies the synthetic email and keeps the real one as mpEmail', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response( + JSON.stringify({ sub: GUID, email: 'shared@household.org', email_verified: false }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + try { + const info = (await ministryPlatformProviderConfig.getUserInfo!({ + accessToken: 'tok', + } as never)) as Record<string, unknown>; + + expect(info.email).toBe(`${normalized}@mp.invalid`); + expect(info.mpEmail).toBe('shared@household.org'); + expect(info.emailVerified).toBe(false); + } finally { + fetchMock.mockRestore(); + } + }); + + it('getUserInfo returns null (never throws) when MP sends no usable sub', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify({ email: 'x@y.org' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await expect( + ministryPlatformProviderConfig.getUserInfo!({ accessToken: 'tok' } as never), + ).resolves.toBeNull(); + } finally { + fetchMock.mockRestore(); + errSpy.mockRestore(); + } + }); + + it('mapProfileToUser reads sub from the raw profile', () => { + // Better Auth passes `mapProfileToUser` the RAW object getUserInfo + // returned, so it must read the same field `accountSubject` does. + const mapped = ministryPlatformProviderConfig.mapProfileToUser!({ + sub: normalized, + mpEmail: 'jane@example.org', + } as never) as Record<string, unknown>; + + expect(mapped.userGuid).toBe(normalized); + expect(mapped.email).toBe(`${normalized}@mp.invalid`); + expect(mapped.mpEmail).toBe('jane@example.org'); + }); +}); diff --git a/src/components/address-labels/actions.test.ts b/src/components/address-labels/actions.test.ts index 550386c..f29276d 100644 --- a/src/components/address-labels/actions.test.ts +++ b/src/components/address-labels/actions.test.ts @@ -21,6 +21,33 @@ vi.mock('next/headers', () => ({ headers: vi.fn().mockResolvedValue(new Headers()), })); +/** + * These actions now authorize through AuthorizationService instead of a bare + * session check. The default implementation delegates to the same + * `mockGetSession` these tests already drive, so existing session-shape tests + * keep their meaning; tests that need the gate itself to refuse override it + * with `mockRequireSecurityRole.mockRejectedValueOnce(...)`. + */ +const mockRequireSecurityRole = vi.hoisted(() => vi.fn()); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: async () => { + try { + await mockRequireSecurityRole(); + return true; + } catch { + return false; + } + }, + }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + + vi.mock('@/services/toolService', () => ({ ToolService: { getInstance: vi.fn().mockResolvedValue({ @@ -104,8 +131,12 @@ describe('fetchAddressLabels', () => { beforeEach(() => { mockGetSession.mockResolvedValue({ user: { id: 'user-1', userGuid: '550e8400-e29b-41d4-a716-446655440000' } }); - // getMPUserId lookup — return a User_ID for the test guid - mockGetUserIdByGuid.mockResolvedValue(42); + mockRequireSecurityRole.mockReset(); + mockRequireSecurityRole.mockImplementation(async () => { + const session = await mockGetSession(); + if (!session?.user?.id) throw new Error('Unauthorized'); + return 42; + }); mockGetSelectionRecordIds.mockReset(); mockGetAddressesForContacts.mockReset(); mockGetAddressForContact.mockReset(); diff --git a/src/components/address-labels/actions.ts b/src/components/address-labels/actions.ts index 363a0c8..24f699c 100644 --- a/src/components/address-labels/actions.ts +++ b/src/components/address-labels/actions.ts @@ -1,13 +1,11 @@ 'use server'; import React from 'react'; -import { auth } from '@/lib/auth'; -import { headers } from 'next/headers'; import { pdf } from '@react-pdf/renderer'; import { Packer } from 'docx'; import { ToolService } from '@/services/toolService'; import { AddressLabelService } from '@/services/addressLabelService'; -import { getCurrentUserIdFromSession } from '@/components/shared-actions/user'; +import { AuthorizationService } from '@/services/authorizationService'; import type { ContactAddressRow } from '@/services/addressLabelService'; import type { ToolParams } from '@/lib/tool-params'; import type { @@ -26,12 +24,27 @@ import PizZip from 'pizzip'; import ImageModule from 'docxtemplater-image'; import { imbBarcodeToBmp, postnetBarcodeToBmp } from '@/lib/barcode-image'; -async function getSession() { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user?.id) throw new Error('Unauthorized'); - return session; +/** + * Authorization gate for this feature's server actions. + * + * A server action is a callable POST endpoint whether or not the page that + * renders it was ever fetched, so the page-level gate in the tools layout is + * not sufficient on its own. This replaces the previous bare session check: + * MP's OIDC endpoint authenticates ANY dp_Users record, and this app reads MP + * with its own service account, so "a session exists" proves nothing about + * whether the caller may see or change this data. + * + * The service layer gates again — that is deliberate defence in depth, and the + * per-request memoization in AuthorizationService keeps it to one MP read. + */ +async function requireAccess( + table: string, + operation: 'read' | 'create' | 'update' | 'delete', +): Promise<number> { + return AuthorizationService.getInstance().requireSecurityRole({ table, operation }); } + function filterAndTransform( rows: ContactAddressRow[], config: LabelConfig @@ -100,15 +113,17 @@ export async function fetchAddressLabels( params: ToolParams, config: LabelConfig ): Promise<FetchAddressLabelsResult> { - const session = await getSession(); + await requireAccess('Contacts', 'read'); const addressService = await AddressLabelService.getInstance(); if (params.s && params.pageID) { - // Selection mode — need MP User_ID for the selection stored proc - const userId = await getCurrentUserIdFromSession(session); + // Selection mode. The acting MP User_ID for the selection stored proc comes + // from the gate inside getSelectionRecordIds, not from here — a selection + // belongs to a specific user and must not be readable on someone else's + // behalf. const toolService = await ToolService.getInstance(); - const contactIds = await toolService.getSelectionRecordIds(params.s, userId, params.pageID); + const contactIds = await toolService.getSelectionRecordIds(params.s, params.pageID); if (contactIds.length === 0) { return { printable: [], skipped: [] }; @@ -130,7 +145,7 @@ export async function generateLabelPdf( labels: LabelData[], config: LabelConfig ): Promise<{ success: true; data: string } | { success: false; error: string }> { - await getSession(); + await requireAccess('Contacts', 'read'); const stock = getLabelStock(config.stockId); if (!stock) { @@ -183,7 +198,7 @@ export async function generateLabelDocx( labels: LabelData[], config: LabelConfig ): Promise<{ success: true; data: string } | { success: false; error: string }> { - await getSession(); + await requireAccess('Contacts', 'read'); const stock = getLabelStock(config.stockId); if (!stock) { @@ -229,7 +244,7 @@ export async function mergeTemplate( labels: LabelData[], config: LabelConfig ): Promise<{ success: true; data: string } | { success: false; error: string }> { - await getSession(); + await requireAccess('Contacts', 'read'); if (labels.length === 0) { return { success: false, error: 'No addresses to merge' }; diff --git a/src/components/dev-panel/panels/deploy-tool-actions.test.ts b/src/components/dev-panel/panels/deploy-tool-actions.test.ts index 19676b2..9511d60 100644 --- a/src/components/dev-panel/panels/deploy-tool-actions.test.ts +++ b/src/components/dev-panel/panels/deploy-tool-actions.test.ts @@ -167,24 +167,25 @@ describe('deploy-tool-actions', () => { expect(result).toEqual([{ Role_ID: 1, Role_Name: 'Administrators' }]); }); - it('deployToolAction forwards input + resolved userId to service and returns result', async () => { + it('deployToolAction forwards only the input — never a caller-supplied userId', async () => { mockGetSession.mockResolvedValueOnce(validSession); mockDeployTool.mockResolvedValueOnce(sampleResult); const result = await deployToolAction(sampleInput); - expect(mockGetUserIdByGuid).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000'); - expect(mockDeployTool).toHaveBeenCalledWith(sampleInput, 42); + // Write attribution is stamped by the authorization gate inside + // ToolService.deployTool, so there is exactly one source for it and this + // action must not assemble one of its own. + expect(mockDeployTool).toHaveBeenCalledWith(sampleInput); expect(result).toEqual(sampleResult); }); - it('deployToolAction rejects when userGuid missing from session', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + it('deployToolAction propagates a refusal from the service-layer gate', async () => { + mockGetSession.mockResolvedValueOnce(validSession); + mockDeployTool.mockRejectedValueOnce(new Error('Not authorized')); - await expect(deployToolAction(sampleInput)).rejects.toThrow( - 'User GUID not found in session' - ); - expect(mockDeployTool).not.toHaveBeenCalled(); + await expect(deployToolAction(sampleInput)).rejects.toThrow('Not authorized'); + expect(mockDeployTool).toHaveBeenCalledWith(sampleInput); }); it('deployToolAction propagates service errors', async () => { diff --git a/src/components/dev-panel/panels/deploy-tool-actions.ts b/src/components/dev-panel/panels/deploy-tool-actions.ts index 3c5a887..e3cff1b 100644 --- a/src/components/dev-panel/panels/deploy-tool-actions.ts +++ b/src/components/dev-panel/panels/deploy-tool-actions.ts @@ -8,7 +8,6 @@ import { type PageLookup, type RoleLookup, } from "@/services/toolService"; -import { getCurrentUserIdFromSession } from "@/components/shared-actions/user"; export async function listPagesAction(search?: string): Promise<PageLookup[]> { await requireDevSession("Deploy Tool"); @@ -23,10 +22,11 @@ export async function listRolesAction(search?: string): Promise<RoleLookup[]> { } export async function deployToolAction(input: DeployToolInput): Promise<DeployToolResult> { - const session = await requireDevSession("Deploy Tool"); - const userId = await getCurrentUserIdFromSession(session); + await requireDevSession("Deploy Tool"); const toolService = await ToolService.getInstance(); - return toolService.deployTool(input, userId); + // Write attribution comes from the authorization gate inside deployTool, so + // there is exactly one source for it. + return toolService.deployTool(input); } export interface DeployToolEnvStatus { diff --git a/src/components/dev-panel/panels/selection-actions.test.ts b/src/components/dev-panel/panels/selection-actions.test.ts index b9d3e74..e3dbcc4 100644 --- a/src/components/dev-panel/panels/selection-actions.test.ts +++ b/src/components/dev-panel/panels/selection-actions.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockGetSession = vi.hoisted(() => vi.fn()); const mockGetSelectionRecordIds = vi.hoisted(() => vi.fn()); -const mockGetUserIdByGuid = vi.hoisted(() => vi.fn()); vi.mock('@/lib/auth', () => ({ auth: { @@ -24,14 +23,6 @@ vi.mock('@/services/toolService', () => ({ }, })); -vi.mock('@/services/userService', () => ({ - UserService: { - getInstance: vi.fn().mockResolvedValue({ - getUserIdByGuid: mockGetUserIdByGuid, - }), - }, -})); - import { resolveSelection } from './selection-actions'; const validSession = { @@ -43,7 +34,6 @@ describe('resolveSelection', () => { beforeEach(() => { vi.clearAllMocks(); mockGetSession.mockResolvedValue(validSession); - mockGetUserIdByGuid.mockResolvedValue(42); }); it('should resolve selection record IDs', async () => { @@ -52,7 +42,11 @@ describe('resolveSelection', () => { const result = await resolveSelection(5, 292); expect(result).toEqual({ recordIds: [100, 200, 300], count: 3 }); - expect(mockGetSelectionRecordIds).toHaveBeenCalledWith(5, 42, 292); + // No acting user id is passed from here: a selection belongs to a + // specific MP user, so the acting User_ID is resolved by the + // authorization gate inside the service instead of being supplied by + // the caller. + expect(mockGetSelectionRecordIds).toHaveBeenCalledWith(5, 292); }); it('should return empty array when selection has no records', async () => { @@ -69,15 +63,11 @@ describe('resolveSelection', () => { await expect(resolveSelection(5, 292)).rejects.toThrow('Unauthorized'); }); - it('should throw when userGuid is missing from session', async () => { - mockGetSession.mockResolvedValue({ user: { id: 'ba-1' } }); - - await expect(resolveSelection(5, 292)).rejects.toThrow('User GUID not found in session'); - }); - - it('should throw when MP user not found', async () => { - mockGetUserIdByGuid.mockRejectedValue(new Error('User not found')); + it('propagates a refusal from the service-layer authorization gate', async () => { + // Resolving the acting user and refusing an unauthorized one are now the + // service's job, so this action simply must not swallow the refusal. + mockGetSelectionRecordIds.mockRejectedValue(new Error('Not authorized')); - await expect(resolveSelection(5, 292)).rejects.toThrow('User not found'); + await expect(resolveSelection(5, 292)).rejects.toThrow('Not authorized'); }); }); diff --git a/src/components/dev-panel/panels/selection-actions.ts b/src/components/dev-panel/panels/selection-actions.ts index 4c4a33d..3804ab5 100644 --- a/src/components/dev-panel/panels/selection-actions.ts +++ b/src/components/dev-panel/panels/selection-actions.ts @@ -2,7 +2,6 @@ import { requireDevSession } from './require-dev-session'; import { ToolService } from '@/services/toolService'; -import { getCurrentUserIdFromSession } from '@/components/shared-actions/user'; export interface SelectionResult { recordIds: number[]; @@ -13,12 +12,12 @@ export async function resolveSelection( selectionId: number, pageId: number ): Promise<SelectionResult> { - const session = await requireDevSession('Dev panel'); - - const userId = await getCurrentUserIdFromSession(session); + await requireDevSession('Dev panel'); const toolService = await ToolService.getInstance(); - const recordIds = await toolService.getSelectionRecordIds(selectionId, userId, pageId); + // The acting MP User_ID comes from the authorization gate inside + // getSelectionRecordIds — a selection belongs to a specific user. + const recordIds = await toolService.getSelectionRecordIds(selectionId, pageId); return { recordIds, count: recordIds.length }; } diff --git a/src/components/dev-panel/panels/user-tools-actions.test.ts b/src/components/dev-panel/panels/user-tools-actions.test.ts index a6224c4..9cc7b4d 100644 --- a/src/components/dev-panel/panels/user-tools-actions.test.ts +++ b/src/components/dev-panel/panels/user-tools-actions.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockGetSession, mockGetUserIdByGuid, mockGetUserTools } = vi.hoisted(() => ({ +const { mockGetSession, mockGetUserTools } = vi.hoisted(() => ({ mockGetSession: vi.fn(), - mockGetUserIdByGuid: vi.fn(), mockGetUserTools: vi.fn(), })); @@ -18,14 +17,6 @@ vi.mock('next/headers', () => ({ headers: vi.fn().mockResolvedValue(new Headers()), })); -vi.mock('@/services/userService', () => ({ - UserService: { - getInstance: vi.fn().mockResolvedValue({ - getUserIdByGuid: mockGetUserIdByGuid, - }), - }, -})); - vi.mock('@/services/toolService', () => ({ ToolService: { getInstance: vi.fn().mockResolvedValue({ @@ -36,6 +27,12 @@ vi.mock('@/services/toolService', () => ({ import { getUserTools } from './user-tools-actions'; +/** + * Resolving the acting MP User_ID — and refusing a caller who holds no MP + * security role — moved into `ToolService.getUserTools`, where the + * authorization gate is the single source of both. What remains this action's + * responsibility is the dev-session guard, and not swallowing a refusal. + */ describe('getUserTools', () => { beforeEach(() => { vi.clearAllMocks(); @@ -47,34 +44,24 @@ describe('getUserTools', () => { await expect(getUserTools()).rejects.toThrow('Unauthorized'); }); - it('should throw when userGuid is missing from session', async () => { - mockGetSession.mockResolvedValueOnce({ - user: { id: 'internal-id' }, - }); - - await expect(getUserTools()).rejects.toThrow('User GUID not found'); - }); - - it('should throw when user not found in MP', async () => { + it('passes no caller-supplied user id to the service', async () => { mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id', userGuid: '550e8400-e29b-41d4-a716-446655440000' }, }); - mockGetUserIdByGuid.mockRejectedValueOnce(new Error('User not found')); + mockGetUserTools.mockResolvedValueOnce(['/contacts', '/events']); - await expect(getUserTools()).rejects.toThrow('User not found'); + const result = await getUserTools(); + + expect(mockGetUserTools).toHaveBeenCalledWith(); + expect(result).toEqual(['/contacts', '/events']); }); - it('should return tool paths when authenticated', async () => { + it('propagates a refusal from the service-layer authorization gate', async () => { mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id', userGuid: '550e8400-e29b-41d4-a716-446655440000' }, }); - mockGetUserIdByGuid.mockResolvedValueOnce(42); - mockGetUserTools.mockResolvedValueOnce(['/contacts', '/events']); - - const result = await getUserTools(); + mockGetUserTools.mockRejectedValueOnce(new Error('Not authorized')); - expect(mockGetUserIdByGuid).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000'); - expect(mockGetUserTools).toHaveBeenCalledWith(42); - expect(result).toEqual(['/contacts', '/events']); + await expect(getUserTools()).rejects.toThrow('Not authorized'); }); }); diff --git a/src/components/dev-panel/panels/user-tools-actions.ts b/src/components/dev-panel/panels/user-tools-actions.ts index 58f6224..967584c 100644 --- a/src/components/dev-panel/panels/user-tools-actions.ts +++ b/src/components/dev-panel/panels/user-tools-actions.ts @@ -2,21 +2,12 @@ import { requireDevSession } from "./require-dev-session"; import { ToolService } from "@/services/toolService"; -import { UserService } from "@/services/userService"; export async function getUserTools(): Promise<string[]> { - const session = await requireDevSession("Dev panel"); - - const userGuid = (session.user as Record<string, unknown>).userGuid as string | undefined; - if (!userGuid) { - throw new Error("User GUID not found in session"); - } - - const userService = await UserService.getInstance(); - const userId = await userService.getUserIdByGuid(userGuid); + await requireDevSession("Dev panel"); + // The acting MP User_ID is resolved by the authorization gate inside + // getUserTools, which is also what refuses a caller with no MP security role. const toolService = await ToolService.getInstance(); - const toolPaths = await toolService.getUserTools(userId); - - return toolPaths; + return toolService.getUserTools(); } diff --git a/src/components/field-management/actions.test.ts b/src/components/field-management/actions.test.ts index 65dbfb2..30dbf7b 100644 --- a/src/components/field-management/actions.test.ts +++ b/src/components/field-management/actions.test.ts @@ -28,6 +28,42 @@ vi.mock('next/headers', () => ({ headers: vi.fn().mockResolvedValue(new Headers()), })); +/** + * These actions now authorize through AuthorizationService instead of a bare + * session check. + * + * The default implementation (set in beforeEach) delegates to the same + * `mockGetSession` these tests already drive, so every existing session-shape + * test keeps its original meaning. Tests that need the gate itself to refuse — + * as opposed to the session being absent — override it with + * `mockRequireSecurityRole.mockRejectedValueOnce(...)`. + * + * 42 is the acting MP User_ID and the ONLY source of write attribution, so + * assertions that a service was called WITHOUT a userId argument are asserting + * exactly that. + */ +const { mockRequireSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(), +})); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: async () => { + try { + await mockRequireSecurityRole(); + return true; + } catch { + return false; + } + }, + }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + + vi.mock('@/services/fieldManagementService', () => ({ FieldManagementService: { getInstance: mockGetInstance, @@ -47,6 +83,11 @@ const authedSession = { describe('field-management actions', () => { beforeEach(() => { vi.clearAllMocks(); + mockRequireSecurityRole.mockImplementation(async () => { + const session = await mockGetSession(); + if (!session?.user?.id) throw new Error('Unauthorized'); + return 42; + }); mockGetInstance.mockResolvedValue({ getPages: mockGetPages, getPageFields: mockGetPageFields, @@ -73,7 +114,7 @@ describe('field-management actions', () => { }); it('should return pages when authorized', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + mockGetSession.mockResolvedValueOnce(authedSession); const pages = [ { Page_ID: 292, Display_Name: 'Contacts', Table_Name: 'Contacts' }, ]; @@ -94,7 +135,7 @@ describe('field-management actions', () => { }); it('should tag fields with isSeparator:false when tableMetadata is null', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + mockGetSession.mockResolvedValueOnce(authedSession); const fields = [ { Page_Field_ID: 1, @@ -121,7 +162,7 @@ describe('field-management actions', () => { }); it('should merge unmapped columns from tableMetadata.Columns (skipping IsPrimaryKey)', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([ { Page_Field_ID: 1, @@ -156,7 +197,7 @@ describe('field-management actions', () => { }); it('should assign negative IDs starting at -1 and decrementing for new fields', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([]); mockGetTableMetadata.mockResolvedValueOnce({ Table_Name: 'Contacts', @@ -173,7 +214,7 @@ describe('field-management actions', () => { }); it('should assign sequential View_Order starting at maxViewOrder + 1', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([ { Page_Field_ID: 1, @@ -205,7 +246,7 @@ describe('field-management actions', () => { }); it('should skip primary key columns', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([]); mockGetTableMetadata.mockResolvedValueOnce({ Table_Name: 'Contacts', @@ -222,7 +263,7 @@ describe('field-management actions', () => { }); it('should tag existing page field as isSeparator when matching column DataType is Separator', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([ { Page_Field_ID: 1, @@ -271,7 +312,7 @@ describe('field-management actions', () => { }); it('should auto-add Separator columns missing from dp_Page_Fields with isSeparator:true', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([ { Page_Field_ID: 1, @@ -312,7 +353,7 @@ describe('field-management actions', () => { }); it('should force Separator auto-add to Required:false even when IsRequired is true in metadata', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([]); mockGetTableMetadata.mockResolvedValueOnce({ Table_Name: 'Contacts', @@ -329,7 +370,7 @@ describe('field-management actions', () => { }); it('should leave page fields not in metadata tagged isSeparator:false', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([ { Page_Field_ID: 1, @@ -358,7 +399,7 @@ describe('field-management actions', () => { }); it('should skip columns with names already in fields', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'internal-id' } }); + mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([ { Page_Field_ID: 1, @@ -407,14 +448,14 @@ describe('field-management actions', () => { }, ]; - it('should return success:true when service succeeds and forward userId', async () => { + it('should return success:true and pass no caller-supplied userId to the service', async () => { mockGetSession.mockResolvedValueOnce(authedSession); mockUpdatePageFieldOrder.mockResolvedValueOnce(undefined); const result = await savePageFieldOrder(samplePayload); expect(result).toEqual({ success: true }); - expect(mockUpdatePageFieldOrder).toHaveBeenCalledWith(samplePayload, 42); + expect(mockUpdatePageFieldOrder).toHaveBeenCalledWith(samplePayload); }); it('should return success:false with error message when service throws an Error', async () => { diff --git a/src/components/field-management/actions.ts b/src/components/field-management/actions.ts index a7e785c..f77ecce 100644 --- a/src/components/field-management/actions.ts +++ b/src/components/field-management/actions.ts @@ -1,26 +1,39 @@ 'use server'; -import { auth } from '@/lib/auth'; -import { headers } from 'next/headers'; import { FieldManagementService } from '@/services/fieldManagementService'; -import { getCurrentUserIdFromSession } from '@/components/shared-actions/user'; +import { AuthorizationService } from '@/services/authorizationService'; import type { ColumnMetadata } from '@/lib/providers/ministry-platform/types/provider.types'; import type { PageListItem, PageFieldData, FieldOrderPayload } from './types'; -async function getSession() { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user?.id) throw new Error('Unauthorized'); - return session; +/** + * Authorization gate for this feature's server actions. + * + * A server action is a callable POST endpoint whether or not the page that + * renders it was ever fetched, so the page-level gate in the tools layout is + * not sufficient on its own. This replaces the previous bare session check: + * MP's OIDC endpoint authenticates ANY dp_Users record, and this app reads MP + * with its own service account, so "a session exists" proves nothing about + * whether the caller may see or change this data. + * + * The service layer gates again — that is deliberate defence in depth, and the + * per-request memoization in AuthorizationService keeps it to one MP read. + */ +async function requireAccess( + table: string, + operation: 'read' | 'create' | 'update' | 'delete', +): Promise<number> { + return AuthorizationService.getInstance().requireSecurityRole({ table, operation }); } + export async function fetchPages(): Promise<PageListItem[]> { - await getSession(); + await requireAccess('dp_Pages', 'read'); const service = await FieldManagementService.getInstance(); return service.getPages(); } export async function fetchPageFieldData(pageId: number, tableName: string): Promise<PageFieldData> { - await getSession(); + await requireAccess('dp_Page_Fields', 'read'); const service = await FieldManagementService.getInstance(); const [rawFields, tableMetadata] = await Promise.all([ @@ -79,10 +92,9 @@ export async function savePageFieldOrder( fields: FieldOrderPayload[] ): Promise<{ success: boolean; error?: string }> { try { - const session = await getSession(); - const userId = await getCurrentUserIdFromSession(session); + await requireAccess('dp_Page_Fields', 'update'); const service = await FieldManagementService.getInstance(); - await service.updatePageFieldOrder(fields, userId); + await service.updatePageFieldOrder(fields); return { success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : 'Failed to save field order' }; diff --git a/src/components/group-wizard/actions.test.ts b/src/components/group-wizard/actions.test.ts index e8db057..2efcc9a 100644 --- a/src/components/group-wizard/actions.test.ts +++ b/src/components/group-wizard/actions.test.ts @@ -32,6 +32,42 @@ vi.mock('next/headers', () => ({ headers: vi.fn().mockResolvedValue(new Headers()), })); +/** + * These actions now authorize through AuthorizationService instead of a bare + * session check. + * + * The default implementation (set in beforeEach) delegates to the same + * `mockGetSession` these tests already drive, so every existing session-shape + * test keeps its original meaning. Tests that need the gate itself to refuse — + * as opposed to the session being absent — override it with + * `mockRequireSecurityRole.mockRejectedValueOnce(...)`. + * + * 42 is the acting MP User_ID and the ONLY source of write attribution, so + * assertions that a service was called WITHOUT a userId argument are asserting + * exactly that. + */ +const { mockRequireSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(), +})); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: async () => { + try { + await mockRequireSecurityRole(); + return true; + } catch { + return false; + } + }, + }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + + vi.mock('@/services/groupService', () => ({ GroupService: { getInstance: mockGroupGetInstance }, })); @@ -95,6 +131,11 @@ const BASE_FORM: GroupWizardFormData = { beforeEach(() => { vi.clearAllMocks(); +mockRequireSecurityRole.mockImplementation(async () => { + const session = await mockGetSession(); + if (!session?.user?.id) throw new Error('Unauthorized'); + return 42; +}); mockGroupGetInstance.mockResolvedValue({ fetchAllLookups: mockFetchAllLookups, searchContacts: mockSearchContacts, @@ -257,16 +298,23 @@ describe('createGroup', () => { expect(result).toEqual({ success: false, error: 'Unauthorized' }); }); - it('returns User GUID not found in session error when userGuid is absent', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'user-1' } }); + it('is refused when the authorization gate denies the caller', async () => { + // A signed-in MP user with no qualifying security role: the session is + // valid, the gate refuses anyway. This is the case a bare session check + // used to let through. + // + // No `mockGetSession` value is queued here on purpose: the rejection + // short-circuits the gate's default implementation, so a queued + // `mockResolvedValueOnce` would go unconsumed and leak into the next test. + mockRequireSecurityRole.mockRejectedValueOnce(new Error('Not authorized')); const result = await createGroup(BASE_FORM); - expect(result).toEqual({ success: false, error: 'User GUID not found in session' }); + expect(result).toEqual({ success: false, error: 'Not authorized' }); expect(mockCreateGroup).not.toHaveBeenCalled(); }); - it('resolves MP user id and creates group on happy path', async () => { + it('creates the group on the happy path, passing no caller-supplied userId', async () => { mockGetSession.mockResolvedValueOnce({ user: { id: 'user-1', userGuid: '550e8400-e29b-41d4-a716-446655440000' }, }); @@ -275,8 +323,7 @@ describe('createGroup', () => { const result = await createGroup(BASE_FORM); - expect(mockGetUserIdByGuid).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000'); - expect(mockCreateGroup).toHaveBeenCalledWith(BASE_FORM, 42); + expect(mockCreateGroup).toHaveBeenCalledWith(BASE_FORM); expect(result).toEqual({ success: true, groupId: 200, groupName: 'Test Group' }); }); @@ -314,16 +361,23 @@ describe('updateGroup', () => { expect(result).toEqual({ success: false, error: 'Unauthorized' }); }); - it('returns User GUID not found in session error when userGuid is absent', async () => { - mockGetSession.mockResolvedValueOnce({ user: { id: 'user-1' } }); + it('is refused when the authorization gate denies the caller', async () => { + // A signed-in MP user with no qualifying security role: the session is + // valid, the gate refuses anyway. This is the case a bare session check + // used to let through. + // + // No `mockGetSession` value is queued here on purpose: the rejection + // short-circuits the gate's default implementation, so a queued + // `mockResolvedValueOnce` would go unconsumed and leak into the next test. + mockRequireSecurityRole.mockRejectedValueOnce(new Error('Not authorized')); const result = await updateGroup(100, BASE_FORM); - expect(result).toEqual({ success: false, error: 'User GUID not found in session' }); + expect(result).toEqual({ success: false, error: 'Not authorized' }); expect(mockUpdateGroup).not.toHaveBeenCalled(); }); - it('resolves MP user id and updates group on happy path', async () => { + it('updates the group on the happy path, passing no caller-supplied userId', async () => { mockGetSession.mockResolvedValueOnce({ user: { id: 'user-1', userGuid: '550e8400-e29b-41d4-a716-446655440000' }, }); @@ -332,7 +386,7 @@ describe('updateGroup', () => { const result = await updateGroup(100, BASE_FORM); - expect(mockUpdateGroup).toHaveBeenCalledWith(100, BASE_FORM, 42); + expect(mockUpdateGroup).toHaveBeenCalledWith(100, BASE_FORM); expect(result).toEqual({ success: true, groupId: 100, groupName: 'Updated' }); }); diff --git a/src/components/group-wizard/actions.ts b/src/components/group-wizard/actions.ts index 27797fd..59328a8 100644 --- a/src/components/group-wizard/actions.ts +++ b/src/components/group-wizard/actions.ts @@ -1,9 +1,7 @@ 'use server'; -import { auth } from '@/lib/auth'; -import { headers } from 'next/headers'; import { GroupService } from '@/services/groupService'; -import { getCurrentUserIdFromSession } from '@/components/shared-actions/user'; +import { AuthorizationService } from '@/services/authorizationService'; import type { GroupWizardLookups, ContactSearchResult, @@ -14,27 +12,42 @@ import type { } from './types'; import type { GroupWizardFormData } from './schema'; -async function getSession() { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user?.id) throw new Error('Unauthorized'); - return session; +/** + * Authorization gate for this feature's server actions. + * + * A server action is a callable POST endpoint whether or not the page that + * renders it was ever fetched, so the page-level gate in the tools layout is + * not sufficient on its own. This replaces the previous bare session check: + * MP's OIDC endpoint authenticates ANY dp_Users record, and this app reads MP + * with its own service account, so "a session exists" proves nothing about + * whether the caller may see or change this data. + * + * The service layer gates again — that is deliberate defence in depth, and the + * per-request memoization in AuthorizationService keeps it to one MP read. + */ +async function requireAccess( + table: string, + operation: 'read' | 'create' | 'update' | 'delete', +): Promise<number> { + return AuthorizationService.getInstance().requireSecurityRole({ table, operation }); } + export async function fetchGroupWizardLookups(): Promise<GroupWizardLookups> { - await getSession(); + await requireAccess('Groups', 'read'); const service = await GroupService.getInstance(); return service.fetchAllLookups(); } export async function searchContacts(term: string): Promise<ContactSearchResult[]> { - await getSession(); + await requireAccess('Contacts', 'read'); if (!term || term.length < 2) return []; const service = await GroupService.getInstance(); return service.searchContacts(term); } export async function searchGroups(term: string): Promise<GroupSearchResult[]> { - await getSession(); + await requireAccess('Groups', 'read'); if (!term || term.length < 2) return []; const service = await GroupService.getInstance(); return service.searchGroups(term); @@ -51,7 +64,7 @@ export async function fetchGroupRecord( | ActionError > { try { - await getSession(); + await requireAccess('Groups', 'read'); const service = await GroupService.getInstance(); const group = await service.getGroup(groupId); if (!group) return { success: false, error: 'Group not found' }; @@ -65,10 +78,9 @@ export async function createGroup( data: GroupWizardFormData, ): Promise<CreateGroupResult | ActionError> { try { - const session = await getSession(); - const userId = await getCurrentUserIdFromSession(session); + await requireAccess('Groups', 'create'); const service = await GroupService.getInstance(); - const result = await service.createGroup(data, userId); + const result = await service.createGroup(data); return { success: true, groupId: result.Group_ID, groupName: result.Group_Name }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to create group' }; @@ -80,10 +92,9 @@ export async function updateGroup( data: GroupWizardFormData, ): Promise<UpdateGroupResult | ActionError> { try { - const session = await getSession(); - const userId = await getCurrentUserIdFromSession(session); + await requireAccess('Groups', 'update'); const service = await GroupService.getInstance(); - const result = await service.updateGroup(groupId, data, userId); + const result = await service.updateGroup(groupId, data); return { success: true, groupId: result.Group_ID, groupName: result.Group_Name }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to update group' }; diff --git a/src/components/template-editor/actions.ts b/src/components/template-editor/actions.ts index 5c15e12..f8d9d2c 100644 --- a/src/components/template-editor/actions.ts +++ b/src/components/template-editor/actions.ts @@ -1,19 +1,36 @@ 'use server'; -import { auth } from '@/lib/auth'; -import { headers } from 'next/headers'; +import { AuthorizationService } from '@/services/authorizationService'; import type { MjmlCompileResult } from '@/components/template-editor/types'; -async function getSession() { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user?.id) throw new Error('Unauthorized'); - return session; +/** + * Authorization gate for this feature's server actions. + * + * A server action is a callable POST endpoint whether or not the page that + * renders it was ever fetched, so the page-level gate in the tools layout is + * not sufficient on its own. This replaces the previous bare session check: + * MP's OIDC endpoint authenticates ANY dp_Users record, and this app reads MP + * with its own service account, so "a session exists" proves nothing about + * whether the caller may see or change this data. + * + * The service layer gates again — that is deliberate defence in depth, and the + * per-request memoization in AuthorizationService keeps it to one MP read. + */ +async function requireAccess( + table: string, + operation: 'read' | 'create' | 'update' | 'delete', +): Promise<number> { + return AuthorizationService.getInstance().requireSecurityRole({ table, operation }); } + const MAX_MJML_SIZE = 512_000; // 500KB export async function compileMjml(mjmlSource: string): Promise<MjmlCompileResult> { - await getSession(); + // Touches no MP data, but it is a tool feature and the policy is that tools + // require an MP security role — gating here keeps that rule uniform rather + // than creating a carve-out that has to be reasoned about later. + await requireAccess('dp_Tools', 'read'); if (!mjmlSource || mjmlSource.length > MAX_MJML_SIZE) { throw new Error(`MJML source must be between 1 and ${MAX_MJML_SIZE} characters`); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 6e8cb7c..be7b20a 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,10 +1,21 @@ import { createAuthClient } from "better-auth/react"; -import { genericOAuthClient, customSessionClient } from "better-auth/client/plugins"; +import { customSessionClient } from "better-auth/client/plugins"; import type { auth } from "./auth"; +/** + * NOTE: there is deliberately no `genericOAuthClient()` here. + * + * Better Auth 1.7 removed it. As of 1.7, the genericOAuth plugin registers its + * providers as first-class SOCIAL providers rather than mounting endpoints of + * its own, so sign-in goes through the core `signIn.social` / `callback/:id` + * endpoints. The client plugin — and the `signIn.oauth2` method it added — no + * longer exist. + * + * Call `authClient.signIn.social({ provider: "ministryplatform" })`. Note the + * field is `provider`, not the `providerId` the old `signIn.oauth2` took. + */ export const authClient = createAuthClient({ plugins: [ - genericOAuthClient(), customSessionClient<typeof auth>(), ], }); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ba11c69..58f9483 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,35 +1,364 @@ import { betterAuth, BetterAuthOptions } from "better-auth"; -import { genericOAuth } from "better-auth/plugins"; +import { + genericOAuth, + type GenericOAuthConfig, + type GenericOAuthUserInfo, +} from "better-auth/plugins"; import { customSession } from "better-auth/plugins"; import { nextCookies } from "better-auth/next-js"; +import { validateGuid } from "@/lib/validation"; const mpBaseUrl = process.env.MINISTRY_PLATFORM_BASE_URL!; +/** + * Reserved TLD (RFC 2606) used to build a synthetic, guaranteed-unique email + * address for every Better Auth user record. + * + * Ministry Platform enforces NO uniqueness on email addresses — households + * routinely share one address across several contacts, each of whom may hold a + * `dp_Users` login. Better Auth, however, still falls back to email as an + * identity key: `handleOAuthUserInfo` first looks up the provider account key + * (`findAccountOwnerByKey`), and when no account matches — which is every FIRST + * sign-in for a given `sub` — it falls back to + * `findUserByEmail(userInfo.email.toLowerCase())` and may link the new provider + * account onto that existing user. See + * `node_modules/better-auth/dist/oauth2/link-account.mjs`. + * + * Keying on a shared address therefore lets the second person to sign in land + * on the FIRST person's user record — inheriting their `userGuid`, and with it + * their MP roles and their `User_ID` on every audited write. + * + * (Better Auth 1.7 moved the account-key lookup ahead of the email lookup, + * which narrows the window, but the email fallback is still there and still + * reachable on a first sign-in. This fix is load-bearing, not redundant.) + * + * The only identifier MP guarantees to be unique is the OIDC `sub` (the MP + * `User_GUID`), so that is what we key on. The real address is preserved + * separately as `mpEmail` for display. + * + * Side benefit: MP does not require a user to have an email address at all. + * Because Better Auth hard-fails the callback with `email_is_missing` when the + * profile yields no address, such users previously could not sign in. + */ +export const SYNTHETIC_EMAIL_DOMAIN = "mp.invalid"; + +/** + * Builds the synthetic address Better Auth stores in its `email` column. + * Lower-cased because Better Auth lower-cases the email on both the lookup and + * the write, and we want our value to round-trip unchanged. + */ +export function syntheticEmailForSub(sub: string): string { + return `${sub.toLowerCase()}@${SYNTHETIC_EMAIL_DOMAIN}`; +} + +/** + * Better Auth endpoints that must never be reachable over HTTP. + * + * `/update-user` is the important one. Better Auth mounts it unconditionally — + * it is NOT gated on having email/password sign-in enabled — and its body + * schema is `z.record(z.string(), z.any())`. It rejects only `email`; every + * other key is handed to `parseUserInput`, which copies any additional field + * declared `input !== false` through VERBATIM AND WITH NO VALIDATOR, then + * re-mints the session cookie from the result. Its only gate is + * `sessionMiddleware`, which any valid session cookie satisfies. + * + * Because `userGuid` must stay `input: true` (see `userAdditionalFields`), the + * two facts compose into a full identity takeover: any authenticated user could + * POST themselves a different MP `User_GUID` and inherit that user's MP roles + * on every authorization check and their `User_ID` on every write. + * + * Being stateless is not a mitigation — the handler falls back to + * `{ ...session.user, ...additionalFields }` when the adapter returns nothing, + * so the forged value still reaches the cookie. + * + * `disabledPaths` is matched in the router's `onRequest`, BEFORE rate limiting, + * plugins and `sessionMiddleware`, so these 404 for authenticated and anonymous + * callers alike. The deny-by-default allowlist in + * `src/app/api/auth/[...all]/route.ts` is the primary control; this is defence + * in depth for anything that reaches the handler by another route. + */ +export const disabledAuthPaths = [ + "/update-user", + "/change-email", + "/change-password", + "/set-password", + "/delete-user", + "/delete-user/callback", +]; + /** * Custom fields added to the Better Auth `user` record. * - * `userGuid` (the MP User_GUID / OAuth `sub`) MUST keep `input: true`. It is - * populated server-side from the OAuth profile via `mapProfileToUser`. As of - * better-auth 1.6, `parseAdditionalUserInputFromProviderProfile` strips any - * additional field declared with `input: false` BEFORE the user record is - * created — so `input: false` silently drops `userGuid`, which breaks every MP - * profile lookup (avatar, user menu, User_ID resolution). There is no - * user-facing form that sets this field (the app uses genericOAuth only, with - * no email/password signup or update-user endpoint), so allowing input carries - * no practical risk here. `src/auth.test.ts` guards this against future - * regressions. + * BOTH fields MUST keep `input: true`. They are populated server-side from the + * OAuth profile via `mapProfileToUser`, and as of better-auth 1.6 + * `parseAdditionalUserInputFromProviderProfile` strips any additional field + * declared `input: false` BEFORE the user record is created — so `input: false` + * silently drops the value, which breaks every MP profile lookup (avatar, user + * menu, User_ID resolution). `src/auth.test.ts` guards this. + * + * `input: true` also means these fields are writable through Better Auth's + * `/update-user` endpoint. That endpoint is closed at the route layer (see + * `disabledAuthPaths` above and the allowlist in the catch-all route) — that + * is the control, not this flag. Do not "fix" this by flipping the flag; no + * value of `input` satisfies both requirements, and a field-level + * `validator.input` runs on the provider-profile path too, so it can constrain + * the GUID's shape but cannot tell `mapProfileToUser` from an attacker sending + * a well-formed GUID. + * + * `userGuid` is `required: true` so `parseInputData` refuses to create a user + * record with no MP identity — a session that cannot be tied back to a + * `dp_Users` row is useless and must fail closed rather than exist. */ export const userAdditionalFields = { userGuid: { + type: "string" as const, + required: true, + input: true, + }, + mpEmail: { type: "string" as const, required: false, input: true, }, }; +/** + * Extracts a usable MP `User_GUID` from an OIDC userinfo payload, or `null`. + * + * Exported for testing. Validates the shape because `sub` is the value every + * downstream MP lookup keys on — `UserService.getUserIdByGuid` already runs it + * through `validateGuid`, so an unparseable `sub` would otherwise surface as a + * mystery failure on the first MP call instead of a clean refusal at sign-in. + */ +export function extractUserGuid(profile: unknown): string | null { + const sub = (profile as { sub?: unknown } | null | undefined)?.sub; + if (typeof sub !== "string" || sub.length === 0) return null; + try { + return validateGuid(sub).toLowerCase(); + } catch { + return null; + } +} + +/** + * Builds the display name from the OIDC profile. + * + * Better Auth hard-fails the OAuth callback with `name_is_missing` when the + * resolved name is empty, so this must always return a non-empty string. The + * previous template-literal form produced the string "undefined undefined" + * whenever MP omitted the name claims, which satisfied that check by accident + * while rendering as literal "undefined undefined" in the user menu. + */ +export function buildDisplayName(profile: unknown, fallbackEmail: string | null): string { + const p = (profile ?? {}) as Record<string, unknown>; + const part = (v: unknown) => + typeof v === "string" && v.trim().length > 0 ? v.trim() : null; + + const fromClaims = [part(p.given_name), part(p.family_name)] + .filter((v): v is string => v !== null) + .join(" "); + if (fromClaims) return fromClaims; + + const fullName = part(p.name); + if (fullName) return fullName; + + const localPart = fallbackEmail?.split("@")[0]; + if (localPart) return localPart; + + return "Ministry Platform User"; +} + +/** + * Ministry Platform OAuth provider configuration. + * + * Exported so `src/auth.test.ts` can pin the flags whose failure mode is a + * SILENT broken sign-in rather than a type error — `pkce` and + * `disableIdTokenNonceBinding` in particular. + */ +export const ministryPlatformProviderConfig: GenericOAuthConfig = { + providerId: "ministryplatform", + discoveryUrl: `${mpBaseUrl}/oauth/.well-known/openid-configuration`, + clientId: process.env.MINISTRY_PLATFORM_CLIENT_ID!, + clientSecret: process.env.MINISTRY_PLATFORM_CLIENT_SECRET!, + scopes: [ + "openid", + "offline_access", + "http://www.thinkministry.com/dataplatform/scopes/all", + ], + /** + * PKCE is OFF, deliberately and permanently. + * + * DO NOT "fix" this by reading the discovery document. MP advertises + * `code_challenge_methods_supported: ["plain", "S256"]` at + * `/oauth/.well-known/openid-configuration`, but it does not honour the + * verifier at the token endpoint: with `pkce: true` the authorize leg + * succeeds and returns a code, then the exchange fails with `invalid_grant` + * (HTTP 400) and the user lands on `/auth-error?error=invalid_code`. + * + * That failure shape is what makes this trap expensive — the advertised + * support and the accepted `code_challenge` on the authorize URL both look + * like confirmation, and the flow only breaks on the last hop. Verified + * against a live MP tenant on 2026-09-13. + * + * Without PKCE the authorization code rests on the client secret and the + * OAuth `state` cookie check. This is a confidential client, so that is the + * posture this app has always had. + */ + pkce: false, + /** + * Ministry Platform does not echo the `nonce` claim in the + * authorization-code flow. + * + * As of Better Auth 1.7, any provider configured with `discoveryUrl` + * that publishes a JWKS binds the id_token to the authorization + * request BY DEFAULT: it sends a server-generated `nonce` and rejects + * a callback whose id_token does not echo it (OIDC Core 1.0 + * §3.1.3.7). MP omits the claim, so every sign-in would fail with + * `unable_to_get_user_info`. + * + * What this gives up is binding the id_token to this particular + * authorization request. Signature, issuer and audience are still + * verified against MP's JWKS, and the residual replay risk is + * mitigated by the OAuth `state` cookie check, by this being a + * confidential client exchanging the code with a client secret, and + * by PKCE above. + * + * Watch out when debugging: this failure looks intermittent but is + * inverted from the obvious reading — sign-in succeeds only when the + * boot-time discovery fetch FAILED, because that leaves the id_token + * config undefined and skips verification entirely. A working + * discovery means a broken sign-in. + */ + disableIdTokenNonceBinding: true, + authorizationUrlParams: { + realm: "realm", + }, + /** + * The stable provider account key, new in Better Auth 1.7. + * + * Declared EXPLICITLY rather than relying on genericOAuth's default. The + * default is `isOidc ? profile.sub : profile.id`, where `isOidc` is inferred + * at boot from whether MP's discovery document returned + * `id_token_signing_alg_values_supported`. That makes the account key depend + * on a network fetch succeeding at startup — a transient discovery failure + * would silently switch which field identifies the user. + * + * `getUserInfo` has already run `extractUserGuid`, so `sub` is a validated, + * lower-cased MP `User_GUID` by the time this is called. + */ + accountSubject: ({ profile }) => (profile.sub == null ? "" : String(profile.sub)), + getUserInfo: async (tokens) => { + // Fetch the OIDC profile to get the sub (User_GUID) + const response = await fetch( + `${mpBaseUrl}/oauth/connect/userinfo`, + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }, + ); + + if (!response.ok) { + // Status only — the body can echo profile content. + console.error("auth.userinfo.fetch_failed", { + status: response.status, + }); + return null; + } + + const profile = await response.json(); + + const sub = extractUserGuid(profile); + if (!sub) { + // Return null rather than throwing: `provider.getUserInfo` is NOT + // wrapped in a try/catch in Better Auth's callback route, so a + // throw surfaces as an unhandled error instead of a clean + // `unable_to_get_user_info` redirect with no session. + console.error("auth.userinfo.invalid_sub", { + hasSub: typeof (profile as { sub?: unknown })?.sub === "string", + }); + return null; + } + + const mpEmail = + typeof profile.email === "string" && profile.email.trim() + ? profile.email.trim() + : null; + + return { + /** + * The account subject. MUST be `sub`, not `id`. + * + * Better Auth 1.7 derives the stable provider account key from + * `accountSubject(...)` rather than from `profile.id` — the user-info + * type now literally declares `id?: never`. genericOAuth's default + * resolver reads `profile.sub` for an OIDC provider, and `accountSubject` + * below reads it explicitly. + * + * Returning `id` here (the 1.6 shape) leaves `sub` undefined, which + * `resolveOAuthAccountKey` rejects with `OAUTH_ACCOUNT_SUBJECT_INVALID` + * AFTER a successful token exchange — surfacing to the user as + * `/auth-error?error=unable_to_get_user_info`. + */ + sub, + // What Better Auth stores and keys identity on. NOT the real + // address — see SYNTHETIC_EMAIL_DOMAIN. + email: syntheticEmailForSub(sub), + // The real MP address, carried through for display only. + mpEmail, + name: buildDisplayName(profile, mpEmail), + image: undefined, + // Report what the provider actually claimed instead of asserting + // it. Hardcoding `true` here satisfied both halves of Better + // Auth's implicit-linking condition unconditionally. + emailVerified: profile.email_verified === true, + } satisfies GenericOAuthUserInfo; + }, + // Map the OAuth sub claim (User_GUID) to our custom userGuid field. + // Better Auth generates its own internal user.id, so we need a + // separate field to store the MP User_GUID for API lookups. + // The cast is needed because genericOAuth's type doesn't include + // additionalFields, but the runtime code does pass extra fields + // through to createOAuthUser. + mapProfileToUser: (profile) => { + // `profile` here is the RAW object `getUserInfo` returned (Better Auth + // passes it through verbatim), so this reads `sub` for the same reason + // `accountSubject` does. + const sub = extractUserGuid(profile); + if (!sub) { + // getUserInfo already validated this; if it is gone by now the + // provider contract has changed and we must not create a user + // record that cannot be tied back to dp_Users. + throw new Error("mapProfileToUser: profile has no usable sub"); + } + return { + userGuid: sub, + email: syntheticEmailForSub(sub), + mpEmail: + (profile as unknown as { mpEmail?: string | null }).mpEmail ?? + null, + } as Record<string, unknown>; + }, +}; + const options = { baseURL: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL, secret: process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET, + disabledPaths: disabledAuthPaths, + /** + * Send OAuth callback failures to a page this app owns. + * + * Better Auth's default is `/api/auth/error`, which the deny-by-default + * allowlist in the catch-all route now 404s — so without this, a failed + * sign-in would dead-end on a blank 404 instead of an explanation. + * + * `/auth-error` must also be allowlisted as public in `src/proxy.ts`: it sits + * outside the session gate, and bouncing an unauthenticated visitor to + * `/signin` — which immediately auto-starts OAuth again — would loop forever. + */ + onAPIError: { + errorURL: "/auth-error", + }, session: { cookieCache: { enabled: true, @@ -40,68 +369,24 @@ const options = { account: { storeStateStrategy: "cookie" as const, storeAccountCookie: true, + /** + * Never merge a new provider account onto an existing user record. + * + * Keying users on the synthetic `sub`-derived email (above) already makes a + * collision impossible, so in practice this never fires. It stays as the + * second lock: if anything ever reintroduces a real address as the stored + * email, this turns a silent identity merge into a clean refusal. + */ + accountLinking: { + enabled: false, + }, }, user: { additionalFields: userAdditionalFields, }, plugins: [ genericOAuth({ - config: [ - { - providerId: "ministry-platform", - discoveryUrl: `${mpBaseUrl}/oauth/.well-known/openid-configuration`, - clientId: process.env.MINISTRY_PLATFORM_CLIENT_ID!, - clientSecret: process.env.MINISTRY_PLATFORM_CLIENT_SECRET!, - scopes: [ - "openid", - "offline_access", - "http://www.thinkministry.com/dataplatform/scopes/all", - ], - pkce: false, - authorizationUrlParams: { - realm: "realm", - }, - getUserInfo: async (tokens) => { - // Fetch the OIDC profile to get the sub (User_GUID) - const response = await fetch( - `${mpBaseUrl}/oauth/connect/userinfo`, - { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }, - ); - - if (!response.ok) { - console.error( - "getUserInfo - Failed to fetch user info:", - response.status, - ); - return null; - } - - const profile = await response.json(); - - return { - id: profile.sub, - email: profile.email, - name: `${profile.given_name} ${profile.family_name}`, - image: undefined, - emailVerified: true, - }; - }, - // Map the OAuth sub claim (User_GUID) to our custom userGuid field. - // Better Auth generates its own internal user.id, so we need a - // separate field to store the MP User_GUID for API lookups. - // The cast is needed because genericOAuth's type doesn't include additionalFields, - // but the runtime code does pass extra fields through to createOAuthUser. - mapProfileToUser: (profile) => { - return { - userGuid: profile.id, - } as Record<string, unknown>; - }, - }, - ], + config: [ministryPlatformProviderConfig], }), ], } satisfies BetterAuthOptions; diff --git a/src/lib/providers/ministry-platform/client.ts b/src/lib/providers/ministry-platform/client.ts index a86df61..72adfbf 100644 --- a/src/lib/providers/ministry-platform/client.ts +++ b/src/lib/providers/ministry-platform/client.ts @@ -49,9 +49,6 @@ export class MinistryPlatformClient { * @throws Error if token refresh fails */ public async ensureValidToken(): Promise<void> { - logger.debug("Checking token validity..."); - logger.debug("Expires at:", this.expiresAt); - logger.debug("Current time:", new Date()); if (this.expiresAt >= new Date()) return; @@ -59,7 +56,6 @@ export class MinistryPlatformClient { // starts the refresh; subsequent callers await the same in-flight promise. if (this.refreshPromise) return this.refreshPromise; - logger.debug("Token expired, refreshing..."); this.refreshPromise = (async () => { try { @@ -67,7 +63,6 @@ export class MinistryPlatformClient { this.token = creds.access_token; this.expiresAt = new Date(Date.now() + TOKEN_LIFE); - logger.debug("Token refreshed. Expires at:", this.expiresAt); } catch (error) { logger.error("Failed to refresh token:", error); throw error; @@ -85,16 +80,12 @@ export class MinistryPlatformClient { * @throws Error if token refresh fails or dev credentials are not configured */ public async ensureValidDevToken(): Promise<void> { - logger.debug("Checking dev token validity..."); - logger.debug("Dev expires at:", this.devExpiresAt); - logger.debug("Current time:", new Date()); if (this.devExpiresAt >= new Date()) return; // Dedup concurrent callers on the dev pipeline (symmetric with default pipeline). if (this.devRefreshPromise) return this.devRefreshPromise; - logger.debug("Dev token expired, refreshing..."); this.devRefreshPromise = (async () => { try { @@ -102,7 +93,6 @@ export class MinistryPlatformClient { this.devToken = creds.access_token; this.devExpiresAt = new Date(Date.now() + TOKEN_LIFE); - logger.debug("Dev token refreshed. Expires at:", this.devExpiresAt); } catch (error) { logger.error("Failed to refresh dev token:", error); throw error; diff --git a/src/lib/providers/ministry-platform/services/procedure.service.ts b/src/lib/providers/ministry-platform/services/procedure.service.ts index 5e8b5be..873e10f 100644 --- a/src/lib/providers/ministry-platform/services/procedure.service.ts +++ b/src/lib/providers/ministry-platform/services/procedure.service.ts @@ -39,13 +39,10 @@ export class ProcedureService { try { const http = await this.resolveHttpClient(procedure); - logger.debug('Executing procedure:', procedure); - logger.debug('Query Params:', params); const endpoint = `/procs/${encodeURIComponent(procedure)}`; const data = await http.get<unknown[][]>(endpoint, params); - logger.debug('Procedure results:', data); return data; } catch (error) { logger.error(`Error executing procedure ${procedure}:`, error); @@ -70,14 +67,10 @@ export class ProcedureService { try { const http = await this.resolveHttpClient(procedure); - logger.debug('Executing procedure with body:', procedure); - logger.debug('Parameters:', parameters); - logger.debug('Query Params:', queryParams); const endpoint = `/procs/${encodeURIComponent(procedure)}`; const data = await http.post<unknown[][]>(endpoint, parameters, queryParams); - logger.debug('Procedure results:', data); return data; } catch (error) { logger.error(`Error executing procedure ${procedure}:`, error); diff --git a/src/lib/providers/ministry-platform/services/table.service.ts b/src/lib/providers/ministry-platform/services/table.service.ts index d7a8ef6..1bf251b 100644 --- a/src/lib/providers/ministry-platform/services/table.service.ts +++ b/src/lib/providers/ministry-platform/services/table.service.ts @@ -16,13 +16,10 @@ export class TableService { try { await this.client.ensureValidToken(); - logger.debug('Fetching records from table:', table); - logger.debug('Query Params:', params); const endpoint = `/tables/${encodeURIComponent(table)}`; const data = await this.client.getHttpClient().get<T[]>(endpoint, params as QueryParams); - logger.debug('Fetched records:', data); return data; } catch (error) { logger.error(`Error fetching records from table ${table}:`, error); diff --git a/src/lib/providers/ministry-platform/utils/http-client.test.ts b/src/lib/providers/ministry-platform/utils/http-client.test.ts index 1fe08e8..b044d39 100644 --- a/src/lib/providers/ministry-platform/utils/http-client.test.ts +++ b/src/lib/providers/ministry-platform/utils/http-client.test.ts @@ -292,7 +292,7 @@ describe('HttpClient', () => { ); }); - it('should include response body in POST FormData error message', async () => { + it('does NOT leak the response body into the POST FormData error message', async () => { const formData = new FormData(); mockFetch.mockResolvedValueOnce({ ok: false, @@ -302,7 +302,7 @@ describe('HttpClient', () => { }); await expect(httpClient.postFormData('/files', formData)).rejects.toThrow( - 'POST /files failed: 400 Bad Request - File type not allowed' + 'POST /files failed: 400 Bad Request' ); }); }); @@ -332,7 +332,7 @@ describe('HttpClient', () => { expect(result).toEqual([updatedRecord]); }); - it('should include response body in thrown error message on failed PUT', async () => { + it('does NOT leak the response body into the thrown error message on failed PUT', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 400, @@ -342,9 +342,7 @@ describe('HttpClient', () => { await expect( httpClient.put('/tables/Contacts', { Invalid: 'data' }) - ).rejects.toThrow( - 'PUT /tables/Contacts failed: 400 Bad Request - Validation error: Field X is required' - ); + ).rejects.toThrow('PUT /tables/Contacts failed: 400 Bad Request'); }); it('should omit body segment from PUT error message when body is empty', async () => { @@ -403,7 +401,7 @@ describe('HttpClient', () => { ); }); - it('should include response body in PUT FormData error message', async () => { + it('does NOT leak the response body into the PUT FormData error message', async () => { const formData = new FormData(); formData.append('file', new Blob(['bad']), 'fail.txt'); @@ -415,7 +413,7 @@ describe('HttpClient', () => { }); await expect(httpClient.putFormData('/files/1', formData)).rejects.toThrow( - 'PUT /files/1 failed: 422 Unprocessable Entity - Unsupported MIME type' + 'PUT /files/1 failed: 422 Unprocessable Entity' ); }); }); @@ -455,7 +453,7 @@ describe('HttpClient', () => { ); }); - it('should include response body in DELETE error message', async () => { + it('does NOT leak the response body into the DELETE error message', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 409, @@ -464,7 +462,7 @@ describe('HttpClient', () => { }); await expect(httpClient.delete('/tables/Contacts', { id: [1] })).rejects.toThrow( - 'DELETE /tables/Contacts failed: 409 Conflict - Record has dependent rows' + 'DELETE /tables/Contacts failed: 409 Conflict' ); }); }); @@ -523,3 +521,90 @@ describe('HttpClient', () => { }); }); }); + +/** + * F5 — negative tests for PII leakage through the error path. + * + * A failed MP request's response body routinely echoes back record content + * (names, emails, notes) and the `$filter` string that produced it. A thrown + * message travels much further than a log line — into error reporters, + * client-visible action results, and every downstream log that stringifies the + * error — so the body must appear in NEITHER the log NOR the message. + * + * These assertions are the ones that survive the next refactor; the shape of + * the message is incidental, the absence of the body is the point. + */ +describe('HttpClient - error paths leak no record content', () => { + const SENSITIVE = "Jane Doe <jane.doe@example.org> — Notes: pastoral visit"; + let httpClient: HttpClient; + + function failWith(body: string, status = 400) { + mockFetch.mockResolvedValue({ + ok: false, + status, + statusText: 'Bad Request', + text: () => Promise.resolve(body), + }); + } + + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockReset(); + httpClient = new HttpClient('https://api.ministryplatform.com', () => 'token'); + }); + + it('keeps the response body out of the thrown message', async () => { + failWith(SENSITIVE); + + const err = await httpClient + .get('/tables/Contacts', { $filter: "Last_Name = 'Doe'" }) + .catch((e: Error) => e); + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).not.toContain('Jane Doe'); + expect((err as Error).message).not.toContain('jane.doe@example.org'); + expect((err as Error).message).not.toContain('Notes'); + }); + + it('keeps the query string — and therefore the $filter — out of the thrown message', async () => { + failWith('boom'); + + const err = await httpClient + .get('/tables/Contacts', { $filter: "Last_Name = 'Doe'" }) + .catch((e: Error) => e); + + expect((err as Error).message).not.toContain('$filter'); + expect((err as Error).message).not.toContain('Last_Name'); + }); + + it('keeps the response body out of the log', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + failWith(SENSITIVE); + + await httpClient.get('/tables/Contacts', { $filter: "Last_Name = 'Doe'" }).catch(() => {}); + + const logged = JSON.stringify(errorSpy.mock.calls); + expect(logged).not.toContain('Jane Doe'); + expect(logged).not.toContain('jane.doe@example.org'); + expect(logged).not.toContain('$filter'); + errorSpy.mockRestore(); + }); + + it('still logs the identifiers an operator needs', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + failWith(SENSITIVE, 503); + + await httpClient.get('/tables/Contacts').catch(() => {}); + + expect(errorSpy).toHaveBeenCalledWith( + '[MP]', + 'mp.request.failed', + expect.objectContaining({ + method: 'GET', + endpoint: '/tables/Contacts', + status: 503, + }), + ); + errorSpy.mockRestore(); + }); +}); diff --git a/src/lib/providers/ministry-platform/utils/http-client.ts b/src/lib/providers/ministry-platform/utils/http-client.ts index c23c611..de81032 100644 --- a/src/lib/providers/ministry-platform/utils/http-client.ts +++ b/src/lib/providers/ministry-platform/utils/http-client.ts @@ -11,27 +11,36 @@ export class HttpClient { } /** - * Shared error-handling for non-2xx responses. Always attempts to read the - * response body (tolerating failure), logs with a consistent key, and throws - * an Error whose message includes status/statusText and the body when present. + * Shared error-handling for non-2xx responses. * - * All HTTP methods (GET/POST/POST FormData/PUT/PUT FormData/DELETE) route through - * this helper so callers get the same shape of error regardless of method. + * Logs and throws IDENTIFIERS AND SHAPE ONLY — method, endpoint, status, + * statusText. Deliberately NOT the response body and NOT the full URL: + * + * - The body of a failed MP request routinely echoes back record content + * (names, emails, notes) and the `$filter` string that produced it. + * - `endpoint` is the path only; the query string carries the `$filter`. + * - A thrown message propagates much further than a log line — into error + * reporters, client-visible action results and, previously, into every + * downstream log that stringified the error. Appending the body there + * leaked it everywhere at once. + * + * All HTTP methods (GET/POST/POST FormData/PUT/PUT FormData/DELETE) route + * through this helper so callers get the same shape of error regardless of + * method. */ private async handleFailedResponse( method: string, endpoint: string, response: Response ): Promise<never> { - const responseText = await response.text().catch(() => ''); - logger.error(`${method} Request failed:`, { + logger.error('mp.request.failed', { + method, + endpoint, status: response.status, statusText: response.statusText, - endpoint, - responseBody: responseText, }); throw new Error( - `${method} ${endpoint} failed: ${response.status} ${response.statusText}${responseText ? ` - ${responseText}` : ''}` + `${method} ${endpoint} failed: ${response.status} ${response.statusText}` ); } @@ -96,12 +105,6 @@ export class HttpClient { async put<T = unknown>(endpoint: string, body: RequestBody, queryParams?: QueryParams): Promise<T> { const url = this.buildUrl(endpoint, queryParams); - logger.debug("HTTP PUT Request:", { - url, - endpoint, - body: JSON.stringify(body, null, 2), - queryParams - }); const response = await fetch(url, { method: 'PUT', diff --git a/src/lib/providers/ministry-platform/utils/logger.test.ts b/src/lib/providers/ministry-platform/utils/logger.test.ts index 1b9bf25..f58bec6 100644 --- a/src/lib/providers/ministry-platform/utils/logger.test.ts +++ b/src/lib/providers/ministry-platform/utils/logger.test.ts @@ -1,40 +1,49 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { logger } from './logger'; +/** + * Logger tests. + * + * The `debug` channel was REMOVED, not merely quieted. It wrapped + * `console.log` and was used to dump `$filter` query params, stored-procedure + * parameters, PUT request bodies and full MP result sets — names, email + * addresses, phone numbers. Gating it on `NODE_ENV !== 'production'` was not + * enough: developer machines and any non-production deployment still wrote + * member PII to a terminal or log aggregator, which typically has broader + * access and longer retention than the Ministry Platform database itself. + */ describe('logger', () => { - const originalEnv = process.env.NODE_ENV; let logSpy: ReturnType<typeof vi.spyOn>; let errorSpy: ReturnType<typeof vi.spyOn>; beforeEach(() => { - vi.resetModules(); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { - process.env.NODE_ENV = originalEnv; logSpy.mockRestore(); errorSpy.mockRestore(); }); - it('debug logs to console when NODE_ENV is not production', async () => { - process.env.NODE_ENV = 'development'; - const { logger } = await import('./logger'); - logger.debug('message', { foo: 'bar' }); - expect(logSpy).toHaveBeenCalledWith('[MP]', 'message', { foo: 'bar' }); + it('exposes NO debug channel', () => { + expect('debug' in logger).toBe(false); + expect((logger as Record<string, unknown>).debug).toBeUndefined(); }); - it('debug is a no-op when NODE_ENV=production', async () => { - process.env.NODE_ENV = 'production'; - const { logger } = await import('./logger'); - logger.debug('should-be-silenced'); - expect(logSpy).not.toHaveBeenCalled(); + it('exposes only the error channel', () => { + expect(Object.keys(logger)).toEqual(['error']); }); - it('error always logs regardless of environment', async () => { - process.env.NODE_ENV = 'production'; - const { logger } = await import('./logger'); + it('error always logs regardless of environment', () => { logger.error('boom', new Error('oops')); + expect(errorSpy).toHaveBeenCalledWith('[MP]', 'boom', expect.any(Error)); }); + + it('never routes anything through console.log', () => { + logger.error('some event', { table: 'Contacts', status: 500 }); + + expect(logSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/providers/ministry-platform/utils/logger.ts b/src/lib/providers/ministry-platform/utils/logger.ts index 9baba8d..04fa39a 100644 --- a/src/lib/providers/ministry-platform/utils/logger.ts +++ b/src/lib/providers/ministry-platform/utils/logger.ts @@ -1,8 +1,21 @@ -const isDev = process.env.NODE_ENV !== 'production'; - +/** + * MP provider logging. + * + * There is deliberately NO `debug` channel. It previously wrapped + * `console.log` and was used to dump `$filter` query params, stored-procedure + * parameters, PUT request bodies and full result sets — names, email + * addresses, phone numbers. Being gated on `NODE_ENV !== 'production'` was not + * enough: developer machines and any non-production deployment still wrote + * member PII to a terminal or a log aggregator, which typically has broader + * access and longer retention than the Ministry Platform database itself. + * + * The rule for what remains: log IDENTIFIERS AND SHAPE, never content — table + * names, record IDs, HTTP status. Never record fields, `$filter` strings, + * request bodies or response bodies. + * + * `no-console` in `eslint.config.mjs` enforces this across `src/`, allowing + * only `warn` and `error`. + */ export const logger = { - debug: isDev - ? (...args: unknown[]) => console.log('[MP]', ...args) - : () => {}, error: (...args: unknown[]) => console.error('[MP]', ...args), }; diff --git a/src/lib/security-headers.test.ts b/src/lib/security-headers.test.ts new file mode 100644 index 0000000..fa74d37 --- /dev/null +++ b/src/lib/security-headers.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { buildCsp, cspHeaderName, generateNonce } from './security-headers'; + +/** + * Security-header tests. + * + * These pin the three deliberate loosenings in the policy and the two defaults + * that are easy to invert by accident. Each assertion below corresponds to a + * failure mode that produces either a silent security hole or a silent outage. + */ +describe('cspHeaderName', () => { + it('enforces by default', () => { + expect(cspHeaderName(true)).toBe('Content-Security-Policy'); + }); + + it('drops to report-only only when explicitly disabled', () => { + expect(cspHeaderName(false)).toBe('Content-Security-Policy-Report-Only'); + }); + + it('treats any value other than the exact string "false" as enforcing', () => { + // The point of the default: a typo ("FALSE", "0", "no") must fail LOUD with + // a too-strict header, never SILENT with no policy at all. + for (const value of ['FALSE', '0', 'no', 'true', '', undefined]) { + expect(cspHeaderName(value !== 'false')).toBe('Content-Security-Policy'); + } + }); +}); + +describe('buildCsp', () => { + const base = { + nonce: 'abc123', + mpBaseUrl: 'https://mp.example.org/ministryplatformapi', + mpFileUrl: 'https://mp.example.org/ministryplatform/files', + }; + + it('nonces script-src and enables strict-dynamic', () => { + const csp = buildCsp(base); + expect(csp).toContain("script-src 'self' 'nonce-abc123' 'strict-dynamic'"); + }); + + it('does NOT put a nonce on style-src', () => { + // CSP3 browsers IGNORE 'unsafe-inline' whenever a nonce sits beside it in + // the same directive. Radix's dialog (via react-remove-scroll) injects a + // <style> ELEMENT at runtime whose content embeds the computed scrollbar + // width — it can be covered by neither a nonce nor a stable hash. A nonce + // here therefore breaks every dialog in the app with React error #441. + const csp = buildCsp(base); + const styleSrc = csp.split('; ').find((d) => d.startsWith('style-src')); + expect(styleSrc).toBe("style-src 'self' 'unsafe-inline'"); + expect(styleSrc).not.toContain('nonce'); + }); + + it('allows form submissions to the MP origin', () => { + // Sign-out is a form-driven server action ending in a redirect to MP's + // endsession endpoint, and browsers apply form-action to the WHOLE redirect + // chain, not just the first hop. + const csp = buildCsp(base); + expect(csp).toContain("form-action 'self' https://mp.example.org"); + }); + + it('allows images from the MP origins', () => { + const csp = buildCsp(base); + const imgSrc = csp.split('; ').find((d) => d.startsWith('img-src')); + expect(imgSrc).toContain("'self'"); + expect(imgSrc).toContain('data:'); + expect(imgSrc).toContain('https://mp.example.org'); + }); + + it('blocks framing, objects and frames outright', () => { + const csp = buildCsp(base); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).toContain("object-src 'none'"); + expect(csp).toContain("frame-src 'none'"); + expect(csp).toContain("base-uri 'self'"); + }); + + it('omits upgrade-insecure-requests when report-only', () => { + // Browsers refuse to honour it in a report-only policy and log an error + // saying so on EVERY page — burying the reports report-only exists to + // surface. + expect(buildCsp({ ...base, reportOnly: true })).not.toContain( + 'upgrade-insecure-requests', + ); + }); + + it('omits upgrade-insecure-requests in development', () => { + expect(buildCsp({ ...base, isDev: true })).not.toContain( + 'upgrade-insecure-requests', + ); + }); + + it('includes upgrade-insecure-requests when enforcing in production', () => { + expect(buildCsp(base)).toContain('upgrade-insecure-requests'); + }); + + it("only relaxes script-src with 'unsafe-eval' in development", () => { + expect(buildCsp({ ...base, isDev: true })).toContain("'unsafe-eval'"); + expect(buildCsp(base)).not.toContain("'unsafe-eval'"); + }); + + it('tolerates unset or malformed MP URLs without emitting a broken directive', () => { + const csp = buildCsp({ nonce: 'n', mpBaseUrl: undefined, mpFileUrl: 'not a url' }); + expect(csp).toContain("form-action 'self'"); + expect(csp).not.toContain('undefined'); + expect(csp).not.toContain('null'); + }); +}); + +describe('generateNonce', () => { + it('produces a fresh value per call', () => { + expect(generateNonce()).not.toBe(generateNonce()); + }); + + it('matches the charset Next accepts when extracting the nonce', () => { + // Next's CSP_NONCE_SOURCE_REGEX is /^'nonce-([A-Za-z0-9+/_-]+={0,2})'$/. + // A nonce outside that charset is silently ignored and every script tag + // renders unnonced. + for (let i = 0; i < 25; i++) { + expect(generateNonce()).toMatch(/^[A-Za-z0-9+/_-]+={0,2}$/); + } + }); +}); diff --git a/src/lib/security-headers.ts b/src/lib/security-headers.ts new file mode 100644 index 0000000..bb738a3 --- /dev/null +++ b/src/lib/security-headers.ts @@ -0,0 +1,136 @@ +/** + * Content Security Policy construction. + * + * WHY: the Better Auth session cookie is the only credential this app holds, + * and every page renders strings that came out of Ministry Platform. A script + * injection anywhere is therefore an immediate session-theft path. This is the + * defence-in-depth layer underneath the authorization and endpoint work. + * + * WHY HERE AND NOT `next.config.ts`: the nonce must be fresh per request. A + * build-time value is a constant an attacker can read off any page, which + * defeats the point. The request-independent headers (X-Frame-Options, + * X-Content-Type-Options, Referrer-Policy, Permissions-Policy, HSTS) DO live in + * `next.config.ts`, because that also covers `/api` and the static paths the + * proxy matcher skips. + */ + +/** + * Returns the response header name to use. + * + * ENFORCES BY DEFAULT. Only the exact string "false" drops to report-only, so a + * typo or a missing variable fails LOUD (a too-strict header) rather than + * SILENT (no policy at all). Report-only is the unusual state you switch on to + * diagnose a violation, not a state a deploy can drift into by forgetting to + * set something. + */ +export function cspHeaderName( + enforce: boolean = process.env.CSP_ENFORCE !== "false", +): "Content-Security-Policy" | "Content-Security-Policy-Report-Only" { + return enforce + ? "Content-Security-Policy" + : "Content-Security-Policy-Report-Only"; +} + +/** Reduces a configured URL to a bare scheme+host origin, or null. */ +function originOf(value: string | undefined): string | null { + if (!value) return null; + try { + return new URL(value).origin; + } catch { + return null; + } +} + +export interface CspOptions { + nonce: string; + isDev?: boolean; + /** When true, omit `upgrade-insecure-requests` (see below). */ + reportOnly?: boolean; + mpBaseUrl?: string; + mpFileUrl?: string; +} + +/** + * Builds the CSP header value. + * + * Three directives are deliberately looser than they look like they should be. + * Do NOT "tighten" them back into an outage: + * + * 1. `style-src 'unsafe-inline'`, WITH NO NONCE. Radix's dialog pulls in + * react-remove-scroll, which locks body scroll by INJECTING A `<style>` + * ELEMENT at runtime. That is an element, not an attribute, so + * `style-src-attr` never applies and it falls through to `style-src` — where + * a nonce cannot help, because the element is created by script long after + * the server chose the nonce. A hash does not work either: the content + * embeds the computed scrollbar width, so it varies by platform and zoom. + * Critically, the nonce MUST stay out of this directive — CSP3 browsers + * ignore `'unsafe-inline'` whenever a nonce sits beside it, which is exactly + * the trap that produces a policy that looks correct and breaks every + * dialog. The cost is bounded: inline STYLE injection permits limited + * selector-based exfiltration, not script execution. `script-src` keeps its + * nonce and `strict-dynamic`, which is the control that actually matters. + * + * 2. `form-action` includes the MP origin. Sign-out is a form-driven server + * action that ends in a redirect to MP's endsession endpoint, and browsers + * apply `form-action` to the WHOLE REDIRECT CHAIN, not just its first hop. + * + * 3. `img-src` includes the MP file origin, for contact photos served straight + * from Ministry Platform. + * + * `upgrade-insecure-requests` is omitted in dev AND whenever the policy is + * report-only: browsers refuse to honour it in a report-only policy and log an + * error saying so on every page, which buries the reports that report-only + * exists to surface. + */ +export function buildCsp({ + nonce, + isDev = false, + reportOnly = false, + mpBaseUrl = process.env.MINISTRY_PLATFORM_BASE_URL, + mpFileUrl = process.env.NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL, +}: CspOptions): string { + const mpOrigin = originOf(mpBaseUrl); + const fileOrigin = originOf(mpFileUrl); + + const imgSources = ["'self'", "data:", "blob:", mpOrigin, fileOrigin].filter( + (v, i, a): v is string => Boolean(v) && a.indexOf(v) === i, + ); + + const formActions = ["'self'", mpOrigin].filter( + (v, i, a): v is string => Boolean(v) && a.indexOf(v) === i, + ); + + const directives: string[] = [ + `default-src 'self'`, + // 'strict-dynamic' lets a nonced script load its own chunks (Next does this + // constantly) without whitelisting origins. + `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`, + `style-src 'self' 'unsafe-inline'`, + `img-src ${imgSources.join(" ")}`, + `font-src 'self'`, + `connect-src 'self'${isDev ? " ws: wss:" : ""}`, + `object-src 'none'`, + `frame-src 'none'`, + `base-uri 'self'`, + `form-action ${formActions.join(" ")}`, + `frame-ancestors 'none'`, + ]; + + if (!isDev && !reportOnly) { + directives.push("upgrade-insecure-requests"); + } + + return directives.join("; "); +} + +/** + * Generates a fresh per-request nonce. + * + * Base64 of 16 random bytes — matches the charset Next's + * `getScriptNonceFromHeader` accepts (`[A-Za-z0-9+/_-]+={0,2}`). + */ +export function generateNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return btoa(String.fromCharCode(...bytes)); +} diff --git a/src/lib/tool-params.server.ts b/src/lib/tool-params.server.ts new file mode 100644 index 0000000..eade546 --- /dev/null +++ b/src/lib/tool-params.server.ts @@ -0,0 +1,78 @@ +import type { PageData, ToolParams } from "./tool-params"; + +/** + * Server-only parser for tool query-string params. + * + * Split out of `./tool-params` because it needs `ToolService` (and therefore + * `next/headers` and `MPHelper`), while the types and pure helpers next door + * are imported by client components. Keeping them in one module traced the + * whole server-only chain into the client bundle and failed the Turbopack + * build. + * + * Only ever called from server components (the `page.tsx` of each tool). + */ + +/** + * Parse a query-string value to a finite integer, or return `undefined`. + * + * Guards against `parseInt('abc', 10)` returning `NaN` — which would otherwise + * leak as `typeof === 'number'` and silently corrupt downstream state (e.g. + * `ToolService.getPageData(NaN)`, `===` checks, display as `"NaN"`). + */ +function parseIntOrUndefined(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export async function parseToolParams(searchParams: URLSearchParams | { [key: string]: string | string[] | undefined }): Promise<ToolParams> { + const getValue = (key: string): string | undefined => { + if (searchParams instanceof URLSearchParams) { + return searchParams.get(key) || undefined; + } + const value = searchParams[key]; + return Array.isArray(value) ? value[0] : value; + }; + + const pageID = getValue('pageID'); + const s = getValue('s'); + const sc = getValue('sc'); + const p = getValue('p'); + const q = getValue('q'); + const v = getValue('v'); + const recordID = getValue('recordID'); + const recordDescription = getValue('recordDescription'); + const addl = getValue('addl'); + + const parsedPageID = parseIntOrUndefined(pageID); + + // Fetch page data if pageID is provided + let pageData: PageData | undefined; + if (parsedPageID) { + try { + const { ToolService } = await import("@/services/toolService"); + const toolService = await ToolService.getInstance(); + pageData = await toolService.getPageData(parsedPageID) || undefined; + } catch { + // Identifier only. A caller without an MP security role also lands + // here — the tools layout redirects them to /no-access, and the page + // simply renders without page metadata in the meantime. + console.warn('tool_params.page_data_unavailable', { pageID: parsedPageID }); + pageData = undefined; + } + } + + return { + pageID: parsedPageID, + s: parseIntOrUndefined(s), + sc: parseIntOrUndefined(sc), + p: parseIntOrUndefined(p), + q: q || undefined, + v: parseIntOrUndefined(v), + recordID: parseIntOrUndefined(recordID), + recordDescription: recordDescription ? decodeURIComponent(recordDescription) : undefined, + addl: addl || undefined, + pageData: pageData, + }; +} + diff --git a/src/lib/tool-params.test.ts b/src/lib/tool-params.test.ts index cde5fd3..d5b080c 100644 --- a/src/lib/tool-params.test.ts +++ b/src/lib/tool-params.test.ts @@ -18,7 +18,11 @@ vi.mock('@/services/toolService', () => ({ }, })); -import { parseToolParams, isNewRecord, isEditMode, type PageData } from './tool-params'; +// `parseToolParams` moved to the server-only module: `./tool-params` must stay +// client-safe, so it can no longer import ToolService (which drags in +// `next/headers`). The types and pure helpers still live in `./tool-params`. +import { isNewRecord, isEditMode, type PageData } from './tool-params'; +import { parseToolParams } from './tool-params.server'; import { ToolService } from '@/services/toolService'; describe('tool-params', () => { diff --git a/src/lib/tool-params.ts b/src/lib/tool-params.ts index a52bfd6..14286b6 100644 --- a/src/lib/tool-params.ts +++ b/src/lib/tool-params.ts @@ -1,5 +1,16 @@ -import { ToolService } from "@/services/toolService"; - +/** + * Tool query-string types and pure helpers. + * + * This module is CLIENT-SAFE and must stay that way: client components import + * `ToolParams` and the `isNewRecord`/`isEditMode` helpers from here. It must + * therefore never import a service — `ToolService` pulls in + * `AuthorizationService`, `next/headers` and `MPHelper`, and Turbopack fails + * the build with "You're importing a module that depends on next/headers". + * + * A dynamic `import()` is NOT sufficient: it still creates a graph edge, so the + * server-only chain is still traced into the client bundle. The parser lives in + * `./tool-params.server` instead. + */ export interface PageData { Page_ID: number; Display_Name: string; @@ -27,66 +38,6 @@ export interface ToolParams { pageData?: PageData; } -/** - * Parse a query-string value to a finite integer, or return `undefined`. - * - * Guards against `parseInt('abc', 10)` returning `NaN` — which would otherwise - * leak as `typeof === 'number'` and silently corrupt downstream state (e.g. - * `ToolService.getPageData(NaN)`, `===` checks, display as `"NaN"`). - */ -function parseIntOrUndefined(value: string | undefined): number | undefined { - if (!value) return undefined; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : undefined; -} - -export async function parseToolParams(searchParams: URLSearchParams | { [key: string]: string | string[] | undefined }): Promise<ToolParams> { - const getValue = (key: string): string | undefined => { - if (searchParams instanceof URLSearchParams) { - return searchParams.get(key) || undefined; - } - const value = searchParams[key]; - return Array.isArray(value) ? value[0] : value; - }; - - const pageID = getValue('pageID'); - const s = getValue('s'); - const sc = getValue('sc'); - const p = getValue('p'); - const q = getValue('q'); - const v = getValue('v'); - const recordID = getValue('recordID'); - const recordDescription = getValue('recordDescription'); - const addl = getValue('addl'); - - const parsedPageID = parseIntOrUndefined(pageID); - - // Fetch page data if pageID is provided - let pageData: PageData | undefined; - if (parsedPageID) { - try { - const toolService = await ToolService.getInstance(); - pageData = await toolService.getPageData(parsedPageID) || undefined; - } catch { - console.warn('Could not fetch page data for pageID:', parsedPageID, '- Stored procedure may not exist yet'); - pageData = undefined; - } - } - - return { - pageID: parsedPageID, - s: parseIntOrUndefined(s), - sc: parseIntOrUndefined(sc), - p: parseIntOrUndefined(p), - q: q || undefined, - v: parseIntOrUndefined(v), - recordID: parseIntOrUndefined(recordID), - recordDescription: recordDescription ? decodeURIComponent(recordDescription) : undefined, - addl: addl || undefined, - pageData: pageData, - }; -} - export function isNewRecord(params: ToolParams): boolean { return params.recordID === -1 || params.recordID === undefined; } diff --git a/src/proxy.test.ts b/src/proxy.test.ts index e2c7fc6..d1e8074 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -21,13 +21,22 @@ vi.mock('better-auth/cookies', () => ({ getSessionCookie: mockGetSessionCookie, })); -// Mock NextResponse since next/server may not work in test env +// Mock NextResponse since next/server may not work in test env. +// Each mock returns its own RESPONSE `headers` bag, distinct from the forwarded +// REQUEST headers — the proxy writes the CSP onto the response and the nonce +// plus x-pathname onto the request, and conflating the two would hide a bug +// where the policy never reaches the browser. const { mockNext, mockRedirect } = vi.hoisted(() => ({ mockNext: vi.fn((init?: { request?: { headers?: Headers } }) => ({ type: 'next', - headers: init?.request?.headers, + headers: new Headers(), + requestHeaders: init?.request?.headers, + })), + mockRedirect: vi.fn((url: URL) => ({ + type: 'redirect', + url, + headers: new Headers(), })), - mockRedirect: vi.fn((url: URL) => ({ type: 'redirect', url })), })); vi.mock('next/server', () => ({ @@ -65,7 +74,7 @@ describe('proxy', () => { }); it('should allow nested /api/auth paths without session check', async () => { - const request = createMockRequest('/api/auth/callback/ministry-platform'); + const request = createMockRequest('/api/auth/callback/ministryplatform'); await proxy(request); @@ -214,3 +223,88 @@ describe('proxy', () => { }); }); }); + +/** + * F9 — the CSP is emitted here, not in `next.config.ts`, because the nonce must + * be fresh per request. A build-time value is a constant an attacker can read + * off any page. + */ +describe('proxy - Content Security Policy', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionCookie.mockReturnValue('session-cookie'); + }); + + it('sets the CSP on the RESPONSE so the browser enforces it', async () => { + const result = await proxy(createMockRequest('/tools/addresslabels')); + + const csp = (result as unknown as { headers: Headers }).headers.get( + 'Content-Security-Policy', + ); + expect(csp).toContain("default-src 'self'"); + expect(csp).toMatch(/script-src [^;]*'nonce-[^']+'/); + }); + + it('sets the CSP on the REQUEST too, because Next reads the nonce from there', async () => { + // Next.js does not accept the nonce as an argument; it re-reads it off the + // incoming request headers during render. Setting only the response leaves + // every script tag unnonced and the policy matching nothing on the page. + await proxy(createMockRequest('/tools/addresslabels')); + + const init = mockNext.mock.calls[0]?.[0]; + const requestCsp = init?.request?.headers?.get('Content-Security-Policy'); + expect(requestCsp).toMatch(/'nonce-[^']+'/); + expect(init?.request?.headers?.get('x-nonce')).toBeTruthy(); + }); + + it('uses the SAME nonce on the request and the response', async () => { + const result = await proxy(createMockRequest('/tools/addresslabels')); + + const responseCsp = (result as unknown as { headers: Headers }).headers.get( + 'Content-Security-Policy', + )!; + const requestNonce = mockNext.mock.calls[0]?.[0]?.request?.headers?.get('x-nonce'); + + expect(responseCsp).toContain(`'nonce-${requestNonce}'`); + }); + + it('issues a different nonce on every request', async () => { + const a = await proxy(createMockRequest('/a')); + const b = await proxy(createMockRequest('/b')); + + const nonceOf = (r: unknown) => + (r as { headers: Headers }).headers + .get('Content-Security-Policy')! + .match(/'nonce-([^']+)'/)![1]; + + expect(nonceOf(a)).not.toBe(nonceOf(b)); + }); + + it('sets the CSP on redirects as well', async () => { + mockGetSessionCookie.mockReturnValue(null); + + const result = await proxy(createMockRequest('/tools/addresslabels')); + + expect( + (result as unknown as { headers: Headers }).headers.get('Content-Security-Policy'), + ).toBeTruthy(); + }); +}); + +describe('proxy - /auth-error is public', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('lets an unauthenticated visitor reach /auth-error', async () => { + // Better Auth redirects OAuth failures here and the visitor has no session + // by definition. Bouncing them to /signin would auto-start OAuth again and + // loop forever on the very failure this page exists to explain. + mockGetSessionCookie.mockReturnValue(null); + + const result = await proxy(createMockRequest('/auth-error?error=invalid_code')); + + expect((result as unknown as { type: string }).type).toBe('next'); + expect(mockRedirect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index 264cc2d..cd16a5d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,25 +1,74 @@ import { NextResponse, NextRequest } from 'next/server'; import { getSessionCookie } from 'better-auth/cookies'; +import { buildCsp, cspHeaderName, generateNonce } from '@/lib/security-headers'; + +/** + * Paths reachable without a session. + * + * `/auth-error` MUST be here. Better Auth redirects OAuth callback failures to + * it (see `onAPIError` in `src/lib/auth.ts`), and the visitor has no session at + * that point by definition. Without this entry they would bounce to `/signin`, + * which immediately auto-starts OAuth again — looping forever on the exact + * failure the page exists to explain. + * + * Only the Better Auth catch-all (`/api/auth/*`) is intentionally + * unauthenticated among API routes — and it is itself deny-by-default; see the + * allowlist in `src/app/api/auth/[...all]/route.ts`. Any other `/api/*` route + * goes through the session-cookie check below so handlers don't have to + * re-implement auth on their own. + */ +function isPublicPath(pathname: string): boolean { + return ( + pathname.startsWith('/api/auth') || + pathname === '/signin' || + pathname === '/auth-error' + ); +} export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; const fullPath = pathname + request.nextUrl.search; + const isDev = process.env.NODE_ENV !== 'production'; + const headerName = cspHeaderName(); + const nonce = generateNonce(); + const csp = buildCsp({ + nonce, + isDev, + reportOnly: headerName === 'Content-Security-Policy-Report-Only', + }); + // Forward the original URL (pathname + search) to downstream server // components via a request header, so AuthWrapper (and anything else // that needs to redirect to /signin) can build a correct callbackUrl // even when the proxy itself lets the request through. const forwardedHeaders = new Headers(request.headers); forwardedHeaders.set('x-pathname', fullPath); + + // Next.js does NOT accept the nonce as an argument — it re-reads it off the + // INCOMING REQUEST HEADERS during render (see `app-render.js`, which reads + // `content-security-policy` or `content-security-policy-report-only`). So the + // policy has to be set on the request as well as the response; setting only + // the response leaves every script tag unnonced and the policy matching + // nothing on the page. + forwardedHeaders.set('x-nonce', nonce); + forwardedHeaders.set(headerName, csp); + + const withCsp = (response: NextResponse) => { + response.headers.set(headerName, csp); + return response; + }; + const passThrough = () => - NextResponse.next({ request: { headers: forwardedHeaders } }); - - // Early returns for public paths. Only the Better Auth catch-all - // (`/api/auth/*`) is intentionally unauthenticated — any other `/api/*` - // route must go through the session-cookie check below so handlers - // don't have to re-implement auth on their own. - if (pathname.startsWith('/api/auth') || pathname === '/signin') { - console.log(`Proxy: Allowing public path ${pathname}`); + withCsp(NextResponse.next({ request: { headers: forwardedHeaders } })); + + const redirectToSignin = () => { + const signinUrl = new URL('/signin', request.url); + signinUrl.searchParams.set('callbackUrl', fullPath); + return withCsp(NextResponse.redirect(signinUrl)); + }; + + if (isPublicPath(pathname)) { return passThrough(); } @@ -27,20 +76,17 @@ export async function proxy(request: NextRequest) { const sessionCookie = getSessionCookie(request); if (!sessionCookie) { - console.log("Proxy: Redirecting to signin - no session cookie"); - const signinUrl = new URL('/signin', request.url); - signinUrl.searchParams.set('callbackUrl', fullPath); - return NextResponse.redirect(signinUrl); + return redirectToSignin(); } - console.log(`Proxy: Allowing request to ${pathname}`); return passThrough(); } catch (error) { - console.error('Proxy: Error checking session:', error); - const signinUrl = new URL('/signin', request.url); - signinUrl.searchParams.set('callbackUrl', fullPath); - return NextResponse.redirect(signinUrl); + // Identifier/shape only — never the request path or query string. + console.error('proxy.session_check_failed', { + message: error instanceof Error ? error.message : String(error), + }); + return redirectToSignin(); } } diff --git a/src/services/addressLabelService.test.ts b/src/services/addressLabelService.test.ts index fba4567..1960fff 100644 --- a/src/services/addressLabelService.test.ts +++ b/src/services/addressLabelService.test.ts @@ -11,6 +11,26 @@ vi.mock('@/lib/providers/ministry-platform', () => ({ }, })); +/** + * The service gates every MP-touching method through AuthorizationService. + * Mock it so these tests exercise the service logic; the gate has its own + * tests in `authorizationService.test.ts`. + */ +const { mockRequireSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(async () => 42), +})); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: vi.fn(async () => true), + }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + + import { AddressLabelService } from './addressLabelService'; describe('AddressLabelService', () => { diff --git a/src/services/addressLabelService.ts b/src/services/addressLabelService.ts index d741818..d4d5031 100644 --- a/src/services/addressLabelService.ts +++ b/src/services/addressLabelService.ts @@ -1,4 +1,5 @@ import { MPHelper } from '@/lib/providers/ministry-platform'; +import { AuthorizationService } from '@/services/authorizationService'; import { validatePositiveInt } from '@/lib/validation'; import { MP_FETCH_BATCH_SIZE } from '@/lib/constants'; @@ -66,6 +67,12 @@ export class AddressLabelService { * oversized filter clauses. */ async getAddressesForContacts(contactIds: number[]): Promise<ContactAddressRow[]> { + // Reads names and mailing addresses in bulk — the largest PII surface in + // this app. Authentication alone is not sufficient here. + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contacts', + operation: 'read', + }); if (contactIds.length === 0) return []; const results: ContactAddressRow[] = []; @@ -92,6 +99,10 @@ export class AddressLabelService { * Fetch the address for a single contact. Returns null if not found. */ async getAddressForContact(contactId: number): Promise<ContactAddressRow | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contacts', + operation: 'read', + }); validatePositiveInt(contactId); const rows = await this.mp!.getTableRecords<ContactAddressRow>({ diff --git a/src/services/authorizationService.test.ts b/src/services/authorizationService.test.ts new file mode 100644 index 0000000..ba466fa --- /dev/null +++ b/src/services/authorizationService.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { mockGetSession, mockGetTableRecords } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetTableRecords: vi.fn(), +})); + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: mockGetSession } }, +})); + +vi.mock('next/headers', () => ({ + headers: vi.fn().mockResolvedValue(new Headers()), +})); + +vi.mock('@/lib/providers/ministry-platform', () => ({ + MPHelper: class { + getTableRecords = mockGetTableRecords; + }, +})); + +// React's `cache()` is a no-op outside a request scope; identity-map it so the +// service's per-request memoization doesn't mask repeated MP reads in tests. +vi.mock('react', async () => { + const actual = await vi.importActual<typeof import('react')>('react'); + return { ...actual, cache: <T,>(fn: T) => fn }; +}); + +import { AuthorizationService, UnauthorizedError } from './authorizationService'; + +const GUID = '550e8400-e29b-41d4-a716-446655440000'; +const signedIn = { user: { id: 'ba-1', userGuid: GUID } }; + +/** dp_Users lookup, then dp_User_Roles lookup. */ +function mpReturns(userId: number | null, roles: string[]) { + mockGetTableRecords.mockImplementation(async ({ table }: { table: string }) => { + if (table === 'dp_Users') return userId === null ? [] : [{ User_ID: userId }]; + if (table === 'dp_User_Roles') return roles.map((Role_Name) => ({ Role_Name })); + return []; + }); +} + +describe('AuthorizationService', () => { + beforeEach(() => { + vi.clearAllMocks(); + (AuthorizationService as unknown as { instance?: unknown }).instance = undefined; + delete process.env.MP_SECURITY_ROLES; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const gate = () => AuthorizationService.getInstance(); + const readCtx = { table: 'Contacts', operation: 'read' as const }; + const writeCtx = { table: 'Groups', operation: 'update' as const }; + + describe('requireSecurityRole', () => { + it('returns the acting MP User_ID when the user holds any role and none are configured', async () => { + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, ['Administrators']); + + await expect(gate().requireSecurityRole(readCtx)).resolves.toBe(42); + }); + + it('permits when the user holds one of the configured roles', async () => { + process.env.MP_SECURITY_ROLES = 'Tools Users, Administrators'; + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, ['Administrators']); + + await expect(gate().requireSecurityRole(readCtx)).resolves.toBe(42); + }); + + it('matches configured roles case-insensitively', async () => { + process.env.MP_SECURITY_ROLES = 'tools users'; + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, ['Tools Users']); + + await expect(gate().requireSecurityRole(readCtx)).resolves.toBe(42); + }); + + it('REFUSES a signed-in MP user holding none of the configured roles', async () => { + // The core of the finding: MP's OIDC endpoint authenticates ANY dp_Users + // record, so a valid session proves nothing about authorization. + process.env.MP_SECURITY_ROLES = 'Tools Users'; + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, ['Some Unrelated Role']); + + await expect(gate().requireSecurityRole(readCtx)).rejects.toThrow(UnauthorizedError); + }); + + it('REFUSES a signed-in MP user with no security roles at all', async () => { + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, []); + + await expect(gate().requireSecurityRole(readCtx)).rejects.toThrow(UnauthorizedError); + }); + + it('fails closed when there is no session', async () => { + mockGetSession.mockResolvedValue(null); + + await expect(gate().requireSecurityRole(readCtx)).rejects.toThrow(UnauthorizedError); + expect(mockGetTableRecords).not.toHaveBeenCalled(); + }); + + it('fails closed when the session carries no userGuid', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'ba-1' } }); + + await expect(gate().requireSecurityRole(readCtx)).rejects.toThrow(UnauthorizedError); + }); + + it('fails closed when the userGuid resolves to no dp_Users row', async () => { + mockGetSession.mockResolvedValue(signedIn); + mpReturns(null, []); + + await expect(gate().requireSecurityRole(readCtx)).rejects.toThrow(UnauthorizedError); + }); + + it('RETHROWS infrastructure failures instead of reporting them as a refusal', async () => { + // A caller must never mistake "MP is unreachable" for "this user is not + // allowed" — the two demand very different responses. + mockGetSession.mockResolvedValue(signedIn); + mockGetTableRecords.mockRejectedValue(new Error('ConnectTimeoutError')); + + const err = await gate() + .requireSecurityRole(readCtx) + .catch((e) => e); + + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(UnauthorizedError); + expect(err.message).toBe('ConnectTimeoutError'); + }); + + it('logs a structured read denial carrying identifiers only', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, []); + + await gate().requireSecurityRole(readCtx).catch(() => {}); + + expect(warn).toHaveBeenCalledWith( + 'mp.read.unauthorized', + expect.objectContaining({ table: 'Contacts', operation: 'read' }), + ); + // No record content, no filter strings. + const payload = JSON.stringify(warn.mock.calls[0][1]); + expect(payload).not.toContain(GUID); + }); + + it('logs a structured write denial under the write event name', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, []); + + await gate().requireSecurityRole(writeCtx).catch(() => {}); + + expect(warn).toHaveBeenCalledWith( + 'mp.write.unauthorized', + expect.objectContaining({ table: 'Groups', operation: 'update' }), + ); + }); + + it('emits mp.write.non_user when no acting MP user could be resolved', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockGetSession.mockResolvedValue(signedIn); + mpReturns(null, []); + + await gate().requireSecurityRole(writeCtx).catch(() => {}); + + expect(warn).toHaveBeenCalledWith('mp.write.non_user', expect.any(Object)); + }); + }); + + describe('hasSecurityRole', () => { + it('returns true when permitted', async () => { + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, ['Administrators']); + + await expect(gate().hasSecurityRole()).resolves.toBe(true); + }); + + it('returns false instead of throwing when refused', async () => { + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, []); + + await expect(gate().hasSecurityRole()).resolves.toBe(false); + }); + + it('fails closed on an infrastructure error', async () => { + mockGetSession.mockResolvedValue(signedIn); + mockGetTableRecords.mockRejectedValue(new Error('boom')); + + await expect(gate().hasSecurityRole()).resolves.toBe(false); + }); + + it('does not log — it is an affordance check, not an enforcement point', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockGetSession.mockResolvedValue(signedIn); + mpReturns(42, []); + + await gate().hasSecurityRole(); + + expect(warn).not.toHaveBeenCalled(); + }); + }); + + it('escapes the userGuid into the dp_Users filter via validateGuid', async () => { + // A malformed/injected guid must be rejected before it reaches a $filter. + mockGetSession.mockResolvedValue({ user: { id: 'ba-1', userGuid: "' OR 1=1--" } }); + + await expect(gate().requireSecurityRole(readCtx)).rejects.toThrow(); + expect(mockGetTableRecords).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/authorizationService.ts b/src/services/authorizationService.ts new file mode 100644 index 0000000..e77415a --- /dev/null +++ b/src/services/authorizationService.ts @@ -0,0 +1,207 @@ +import { cache } from "react"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { MPHelper } from "@/lib/providers/ministry-platform"; +import { validateGuid } from "@/lib/validation"; + +/** + * Thrown when an authenticated caller is not permitted to perform an operation. + * + * Deliberately distinct from a generic `Error` so callers (and tests) can tell + * "you may not do this" apart from "something broke". Infrastructure failures + * must NEVER surface as this type — see `loadSecurityRoles`. + */ +export class UnauthorizedError extends Error { + readonly code = "UNAUTHORIZED"; + + constructor(message = "Not authorized") { + super(message); + this.name = "UnauthorizedError"; + } +} + +export interface AuthorizationContext { + /** MP table the operation targets, for the structured denial log. */ + table: string; + operation: "read" | "create" | "update" | "delete"; +} + +/** + * Per-REQUEST memoization via React `cache()`. + * + * The gate runs at up to three layers on a single request (page layout, server + * action, service method); this collapses that into one MP read. It is + * deliberately NOT a module-level or TTL cache: nothing crosses request + * boundaries, which is what keeps a role revoked in MP effective on the user's + * very next request rather than up to a TTL later. + * + * If `cache()` ever stops memoizing in a given execution context the only + * consequence is redundant MP reads — never a wrong answer. + */ +const resolveUserId = cache(async (userGuid: string): Promise<number | null> => { + const mp = new MPHelper(); + const records = await mp.getTableRecords<{ User_ID: number }>({ + table: "dp_Users", + select: "User_ID", + filter: `User_GUID = '${validateGuid(userGuid)}'`, + top: 1, + }); + // An empty result means "no such MP user" — a normal, fail-closed outcome. + // A thrown error means MP is unreachable and propagates untouched. + return records?.[0]?.User_ID ?? null; +}); + +const loadSecurityRoles = cache(async (userId: number): Promise<string[]> => { + const mp = new MPHelper(); + const records = await mp.getTableRecords<{ Role_Name: string }>({ + table: "dp_User_Roles", + select: "Role_ID_TABLE.Role_Name", + filter: `User_ID = ${userId}`, + }); + return (records ?? []) + .map((r) => r.Role_Name) + .filter((n): n is string => typeof n === "string" && n.trim().length > 0); +}); + +/** + * Roles permitted to use gated features, from `MP_SECURITY_ROLES` + * (comma-separated). Unset or blank means "any MP security role will do", which + * lets a deployment tighten access without a code change. + */ +function configuredRoles(): string[] { + return (process.env.MP_SECURITY_ROLES ?? "") + .split(",") + .map((r) => r.trim().toLowerCase()) + .filter((r) => r.length > 0); +} + +interface Decision { + permitted: boolean; + userId: number | null; + reason: "ok" | "no_session" | "no_user_guid" | "no_mp_user" | "no_role"; +} + +/** + * Authorization gate for everything that touches Ministry Platform data. + * + * WHY THIS EXISTS: MP's OIDC endpoint authenticates ANY `dp_Users` record, and + * this app reads MP with its own client-credentials service account + * (`dataplatform/scopes/all`). MP's per-user record security therefore never + * applies to what this app returns. A Better Auth session proves only that + * *some* MP user signed in — it is authentication, not authorization. Without + * this gate, any MP user in the domain could read every household address + * through the address-label tool, or reorder page fields for the entire domain + * through field management. + * + * POLICY: any MP user may sign in and use the app shell. The MP-data tools + * require an MP security role. Sign-in is deliberately NOT role-gated — a + * role-less user still gets a session, the header and a working sign-out, + * because refusing at sign-in strands them with no way out. + */ +export class AuthorizationService { + private static instance: AuthorizationService; + + static getInstance(): AuthorizationService { + if (!AuthorizationService.instance) { + AuthorizationService.instance = new AuthorizationService(); + } + return AuthorizationService.instance; + } + + private async decide(): Promise<Decision> { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) { + return { permitted: false, userId: null, reason: "no_session" }; + } + + const userGuid = (session.user as Record<string, unknown>).userGuid; + if (typeof userGuid !== "string" || userGuid.length === 0) { + return { permitted: false, userId: null, reason: "no_user_guid" }; + } + + let userId: number | null; + try { + userId = await resolveUserId(userGuid); + } catch (err) { + // Infrastructure failure. Rethrow rather than returning `permitted: false` + // so a caller can never mistake "MP is down" for "this user is not + // allowed" — the two demand very different responses. + throw err; + } + + if (userId === null) { + return { permitted: false, userId: null, reason: "no_mp_user" }; + } + + const roles = await loadSecurityRoles(userId); + if (roles.length === 0) { + return { permitted: false, userId, reason: "no_role" }; + } + + const allowed = configuredRoles(); + if (allowed.length === 0) { + // Blank config: holding any MP security role is sufficient. + return { permitted: true, userId, reason: "ok" }; + } + + const held = roles.map((r) => r.toLowerCase()); + const match = held.some((r) => allowed.includes(r)); + return match + ? { permitted: true, userId, reason: "ok" } + : { permitted: false, userId, reason: "no_role" }; + } + + /** + * ENFORCEMENT POINT. Throws `UnauthorizedError` if the caller may not perform + * the operation, and logs a structured denial. + * + * @returns the acting MP `User_ID` — the single authoritative source for + * `$userId` write attribution. Callers must use this value rather than one + * they resolved themselves or, worse, one supplied by the caller. + */ + async requireSecurityRole(context: AuthorizationContext): Promise<number> { + const decision = await this.decide(); + + if (!decision.permitted || decision.userId === null) { + const event = + context.operation === "read" + ? "mp.read.unauthorized" + : "mp.write.unauthorized"; + // Identifiers and shape only — never record content. + console.warn(event, { + table: context.table, + operation: context.operation, + reason: decision.reason, + userId: decision.userId ?? null, + }); + + if (decision.userId === null && decision.reason !== "no_session") { + console.warn("mp.write.non_user", { + table: context.table, + operation: context.operation, + reason: decision.reason, + }); + } + + throw new UnauthorizedError(); + } + + return decision.userId; + } + + /** + * Non-throwing, non-logging decision, for UI affordances and layout + * redirects. NEVER use this as enforcement — a server action is a callable + * POST endpoint whether or not the page that renders it was ever fetched. + */ + async hasSecurityRole(): Promise<boolean> { + try { + const decision = await this.decide(); + return decision.permitted; + } catch { + // Fail closed for UI purposes. The enforcement path still surfaces the + // underlying infrastructure error to the caller. + return false; + } + } +} diff --git a/src/services/familyService.ts b/src/services/familyService.ts index 6ce3f72..f3b9e98 100644 --- a/src/services/familyService.ts +++ b/src/services/familyService.ts @@ -1,4 +1,5 @@ import { MPHelper } from "@/lib/providers/ministry-platform"; +import { AuthorizationService } from "@/services/authorizationService"; import { escapeFilterString, validatePositiveInt, validateColumnName } from "@/lib/validation"; import { DomainTimezoneService } from "@/services/domainTimezoneService"; import type { @@ -100,6 +101,10 @@ export class FamilyService { } async searchContacts(term: string): Promise<ContactSearchResult[]> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contacts', + operation: 'read', + }); const trimmed = term.trim(); if (trimmed.length < 2) return []; const escaped = escapeFilterString(trimmed); @@ -138,6 +143,10 @@ export class FamilyService { recordId: number, contactIdField: string, ): Promise<number | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: tableName, + operation: 'read', + }); validatePositiveInt(recordId); validateColumnName(primaryKey); const fkPath = contactIdField.trim(); @@ -159,6 +168,10 @@ export class FamilyService { } async getHousehold(contactId: number): Promise<Household | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Households', + operation: 'read', + }); validatePositiveInt(contactId); const contactRows = await this.mp!.getTableRecords<{ @@ -302,6 +315,10 @@ export class FamilyService { } async getLookups(): Promise<FamilyLookups> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contacts', + operation: 'read', + }); type Row<K extends string, N extends string> = Record<K, number> & Record<N, string>; const [ congregations, @@ -414,6 +431,10 @@ export class FamilyService { } async getNextEnvelopeNumber(): Promise<number> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contacts', + operation: 'read', + }); // MAX() returns a single row even on an empty table — both ORDER BY DESC // TOP 1 and MAX() walk the index, but MAX is one round-trip with one row. // Note: Donors has no Congregation_ID column, so envelope numbers are @@ -427,7 +448,13 @@ export class FamilyService { return highest + 1; } - async saveHousehold(household: Household, userId: number): Promise<SaveProgress> { + async saveHousehold(household: Household): Promise<SaveProgress> { + // Gate FIRST; its return value is the ONLY source of write attribution for + // every record this multi-step save touches. + const userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Households', + operation: 'update', + }); const progress: SaveProgress = { mainAddressId: null, altAddressId: null, diff --git a/src/services/fieldManagementService.test.ts b/src/services/fieldManagementService.test.ts index b247e54..f9886ff 100644 --- a/src/services/fieldManagementService.test.ts +++ b/src/services/fieldManagementService.test.ts @@ -12,6 +12,32 @@ vi.mock('@/lib/providers/ministry-platform', () => ({ }, })); +/** + * The service layer now gates every MP-touching method through + * AuthorizationService. Mock it so these tests exercise the service logic + * rather than the gate; the gate has its own tests in + * `authorizationService.test.ts`, and refusal behaviour is asserted there. + * + * The stub returns 42 as the acting MP User_ID, which is also the only source + * of `$userId` write attribution — so assertions on `$userId` below are + * asserting that the service takes it from the gate. + */ +const { mockRequireSecurityRole, mockHasSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(async () => 42), + mockHasSecurityRole: vi.fn(async () => true), +})); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: mockHasSecurityRole, + }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + + import { FieldManagementService } from './fieldManagementService'; describe('FieldManagementService', () => { @@ -234,16 +260,21 @@ describe('FieldManagementService', () => { '@FieldLabel': 'Label', '@WritingAssistantEnabled': true, }, - undefined + { $userId: 42 } ); }); - it('should forward $userId as query param when userId is provided', async () => { + it('takes $userId from the authorization gate, not from the caller', async () => { mockExecuteProcedureWithBody.mockResolvedValue(undefined); const field = makeField(1); const service = await FieldManagementService.getInstance(); - await service.updatePageFieldOrder([field], 42); + await service.updatePageFieldOrder([field]); + + expect(mockRequireSecurityRole).toHaveBeenCalledWith({ + table: 'dp_Page_Fields', + operation: 'update', + }); expect(mockExecuteProcedureWithBody).toHaveBeenCalledWith( 'api_MPNextTools_UpdatePageFieldOrder', diff --git a/src/services/fieldManagementService.ts b/src/services/fieldManagementService.ts index f9ce2db..7631212 100644 --- a/src/services/fieldManagementService.ts +++ b/src/services/fieldManagementService.ts @@ -1,4 +1,5 @@ import { MPHelper } from "@/lib/providers/ministry-platform"; +import { AuthorizationService } from "@/services/authorizationService"; import type { TableMetadata } from "@/lib/providers/ministry-platform/types/provider.types"; export interface PageListItem { @@ -41,6 +42,10 @@ export class FieldManagementService { } public async getPages(): Promise<PageListItem[]> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: "dp_Pages", + operation: "read", + }); const result = await this.mp!.executeProcedureWithBody('api_MPNextTools_GetPages', {}); if (result && result.length > 0 && result[0].length > 0) { @@ -51,6 +56,10 @@ export class FieldManagementService { } public async getPageFields(pageId: number): Promise<PageField[]> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: "dp_Page_Fields", + operation: "read", + }); const result = await this.mp!.executeProcedureWithBody('api_MPNextTools_GetPageFields', { "@PageID": pageId, }); @@ -63,6 +72,10 @@ export class FieldManagementService { } public async getTableMetadata(tableName: string): Promise<TableMetadata | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: "dp_Tables", + operation: "read", + }); const tables = await this.mp!.getTables(tableName); if (tables.length === 0) return null; @@ -86,10 +99,17 @@ export class FieldManagementService { Depends_On_Field: string | null; Field_Label: string | null; Writing_Assistant_Enabled: boolean; - }[], - userId?: number + }[] ): Promise<void> { - const queryParams = userId !== undefined ? { $userId: userId } : undefined; + // Gate FIRST, and take write attribution from its return value. This is a + // domain-wide configuration write — it changes page layout for every user + // in the MP domain, not just the caller — so it must never run on a bare + // "a session exists" check. + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: "dp_Page_Fields", + operation: "update", + }); + const queryParams = { $userId }; for (let i = 0; i < fields.length; i += FieldManagementService.CONCURRENCY) { const batch = fields.slice(i, i + FieldManagementService.CONCURRENCY); diff --git a/src/services/groupService.test.ts b/src/services/groupService.test.ts index f948fac..6dba31d 100644 --- a/src/services/groupService.test.ts +++ b/src/services/groupService.test.ts @@ -17,6 +17,33 @@ vi.mock('@/lib/providers/ministry-platform', () => ({ }, })); +/** + * The service layer now gates every MP-touching method through + * AuthorizationService. Mock it so these tests exercise the service logic + * rather than the gate; the gate has its own tests in + * `authorizationService.test.ts`. + * + * The stub returns 42 as the acting MP User_ID, which is also the only source + * of `$userId` write attribution — so the `$userId: 42` assertions below are + * asserting that the service takes it from the gate rather than from a caller + * argument (there no longer is one). + */ +const { mockRequireSecurityRole, mockHasSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(async () => 42), + mockHasSecurityRole: vi.fn(async () => true), +})); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: mockHasSecurityRole, + }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + + import { GroupService } from './groupService'; import { DomainTimezoneService } from './domainTimezoneService'; @@ -351,7 +378,7 @@ describe('GroupService', () => { }; const service = await GroupService.getInstance(); - const result = await service.createGroup(formData, 42); + const result = await service.createGroup(formData); expect(mockCreateTableRecords).toHaveBeenCalledWith( 'Groups', @@ -383,7 +410,6 @@ describe('GroupService', () => { End_Date: '2024-12-31', Promotion_Date: null, } as any, // partial form data - 42, ); expect(mockUpdateTableRecords).toHaveBeenCalledWith( @@ -413,9 +439,9 @@ describe('GroupService', () => { mockUpdateTableRecords.mockResolvedValue([{ Group_ID: 7, Group_Name: 'X' }]); const service = await GroupService.getInstance(); - await service.updateGroup(7, { Start_Date: '2026-05-17' } as any, 1); - await service.updateGroup(7, { Start_Date: '2026-05-17' } as any, 1); - await service.updateGroup(7, { Start_Date: '2026-05-17' } as any, 1); + await service.updateGroup(7, { Start_Date: '2026-05-17' } as any); + await service.updateGroup(7, { Start_Date: '2026-05-17' } as any); + await service.updateGroup(7, { Start_Date: '2026-05-17' } as any); for (const call of mockUpdateTableRecords.mock.calls) { expect((call[1][0] as { Start_Date: string }).Start_Date).toBe('2026-05-17 00:00:00'); @@ -436,7 +462,7 @@ describe('GroupService', () => { const service = await GroupService.getInstance(); await expect( - service.createGroup({ Group_Name: 'Test' } as any, 1), + service.createGroup({ Group_Name: 'Test' } as any), ).rejects.toThrow('Create failed'); }); }); diff --git a/src/services/groupService.ts b/src/services/groupService.ts index d187124..9e25948 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -1,4 +1,5 @@ import { MPHelper } from '@/lib/providers/ministry-platform'; +import { AuthorizationService } from '@/services/authorizationService'; import { escapeFilterString, validatePositiveInt } from '@/lib/validation'; import { DomainTimezoneService } from '@/services/domainTimezoneService'; import type { @@ -56,6 +57,10 @@ export class GroupService { } async fetchAllLookups(): Promise<GroupWizardLookups> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Groups', + operation: 'read', + }); const [ groupTypes, ministries, @@ -160,6 +165,11 @@ export class GroupService { } async searchContacts(term: string): Promise<ContactSearchResult[]> { + // Reads names and email addresses for every contact matching the term. + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contacts', + operation: 'read', + }); const escaped = escapeFilterString(term); return this.mp!.getTableRecords<ContactSearchResult>({ table: 'Contacts', @@ -171,6 +181,10 @@ export class GroupService { } async searchGroups(term: string): Promise<GroupSearchResult[]> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Groups', + operation: 'read', + }); const escaped = escapeFilterString(term); return this.mp!.getTableRecords<GroupSearchResult>({ table: 'Groups', @@ -182,6 +196,10 @@ export class GroupService { } async getGroup(groupId: number): Promise<GetGroupResult | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Groups', + operation: 'read', + }); // Select all scalar fields plus display-name joins via FK table traversal. // Aliases (AS) keep the joined names on known keys so edit-mode can seed // the contact/group display maps without a second round trip. @@ -272,12 +290,16 @@ export class GroupService { async createGroup( data: GroupWizardFormData, - userId: number, ): Promise<{ Group_ID: number; Group_Name: string }> { + // Gate FIRST; its return value is the ONLY source of write attribution. + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Groups', + operation: 'create', + }); const apiData = await prepareForApi(data); const result = await this.mp!.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); return result[0] as unknown as { Group_ID: number; Group_Name: string }; } @@ -285,8 +307,11 @@ export class GroupService { async updateGroup( groupId: number, data: Partial<GroupWizardFormData>, - userId: number, ): Promise<{ Group_ID: number; Group_Name: string }> { + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Groups', + operation: 'update', + }); const apiData = { Group_ID: groupId, ...(await prepareForApi(data as GroupWizardFormData)), @@ -294,7 +319,7 @@ export class GroupService { const result = await this.mp!.updateTableRecords('Groups', [apiData], { partial: true, $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); return result[0] as unknown as { Group_ID: number; Group_Name: string }; } diff --git a/src/services/toolService.test.ts b/src/services/toolService.test.ts index 44a7400..69a093a 100644 --- a/src/services/toolService.test.ts +++ b/src/services/toolService.test.ts @@ -15,6 +15,33 @@ vi.mock('@/lib/providers/ministry-platform', () => { }; }); +/** + * The service layer now gates every MP-touching method through + * AuthorizationService. Mock it so these tests exercise the service logic + * rather than the gate; the gate has its own tests in + * `authorizationService.test.ts`. + * + * The stub returns 42 as the acting MP User_ID, which is also the only source + * of `$userId` write attribution — so the `$userId: 42` assertions below are + * asserting that the service takes it from the gate rather than from a caller + * argument (there no longer is one). + */ +const { mockRequireSecurityRole, mockHasSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(async () => 42), + mockHasSecurityRole: vi.fn(async () => true), +})); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: mockHasSecurityRole, + }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + + describe('ToolService', () => { beforeEach(() => { vi.clearAllMocks(); @@ -82,7 +109,7 @@ describe('ToolService', () => { ]); const service = await ToolService.getInstance(); - const result = await service.getSelectionRecordIds(270, 42, 292); + const result = await service.getSelectionRecordIds(270, 292); expect(mockExecuteProcedureWithBody).toHaveBeenCalledWith('api_Common_GetSelection', { '@SelectionID': 270, @@ -96,7 +123,7 @@ describe('ToolService', () => { mockExecuteProcedureWithBody.mockResolvedValueOnce([[]]); const service = await ToolService.getInstance(); - const result = await service.getSelectionRecordIds(270, 42, 292); + const result = await service.getSelectionRecordIds(270, 292); expect(result).toEqual([]); }); @@ -109,7 +136,7 @@ describe('ToolService', () => { ]); const service = await ToolService.getInstance(); - const result = await service.getSelectionRecordIds(270, 42, 292); + const result = await service.getSelectionRecordIds(270, 292); expect(result).toEqual([201, 202]); }); @@ -126,7 +153,7 @@ describe('ToolService', () => { ]); const service = await ToolService.getInstance(); - const result = await service.getUserTools(42); + const result = await service.getUserTools(); expect(mockExecuteProcedureWithBody).toHaveBeenCalledWith('api_Tools_GetUserTools', { '@UserId': 42, @@ -138,7 +165,7 @@ describe('ToolService', () => { mockExecuteProcedureWithBody.mockResolvedValueOnce([[]]); const service = await ToolService.getInstance(); - const result = await service.getUserTools(42); + const result = await service.getUserTools(); expect(result).toEqual([]); }); @@ -147,7 +174,7 @@ describe('ToolService', () => { mockExecuteProcedureWithBody.mockRejectedValueOnce(new Error('Access denied')); const service = await ToolService.getInstance(); - await expect(service.getUserTools(42)).rejects.toThrow('Access denied'); + await expect(service.getUserTools()).rejects.toThrow('Access denied'); }); }); @@ -433,18 +460,23 @@ describe('ToolService', () => { '@AdditionalData': 'extra', '@RoleIDs': '1,5', }, - undefined + { $userId: 42 } ); expect(result.tool).toEqual(toolRow); expect(result.pages).toEqual([]); expect(result.roles).toEqual([]); }); - it('forwards $userId as query param when userId is provided', async () => { + it('takes $userId from the authorization gate, not from the caller', async () => { mockExecuteProcedureWithBody.mockResolvedValueOnce([[toolRow], [], []]); const service = await ToolService.getInstance(); - await service.deployTool(baseInput, 42); + await service.deployTool(baseInput); + + expect(mockRequireSecurityRole).toHaveBeenCalledWith({ + table: 'dp_Tools', + operation: 'create', + }); expect(mockExecuteProcedureWithBody).toHaveBeenCalledWith( 'api_dev_DeployTool', @@ -473,7 +505,7 @@ describe('ToolService', () => { '@AdditionalData': null, '@RoleIDs': null, }), - undefined + { $userId: 42 } ); }); @@ -497,7 +529,7 @@ describe('ToolService', () => { '@LaunchInNewTab': 1, '@ShowOnMobile': 1, }), - undefined + { $userId: 42 } ); }); diff --git a/src/services/toolService.ts b/src/services/toolService.ts index 725d2ab..6f41f86 100644 --- a/src/services/toolService.ts +++ b/src/services/toolService.ts @@ -1,5 +1,6 @@ import { MPHelper } from "@/lib/providers/ministry-platform"; import { PageData } from "@/lib/tool-params"; +import { AuthorizationService } from "@/services/authorizationService"; import { validatePositiveInt, validateColumnName } from "@/lib/validation"; import { MP_FETCH_BATCH_SIZE } from "@/lib/constants"; @@ -122,6 +123,10 @@ export class ToolService { * @returns Promise<PageData | null> - The page data or null if not found */ public async getPageData(pageID: number): Promise<PageData | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Pages', + operation: 'read', + }); try { // Execute stored procedure to get page data // DomainID is automatically injected by MP API @@ -145,12 +150,19 @@ export class ToolService { * Retrieves the record IDs from a Ministry Platform selection. * Calls the api_Common_GetSelection stored procedure. * + * The acting user comes from the authorization gate, never from the caller: + * a selection belongs to a specific MP user, so accepting a `@UserID` from + * the request payload would let any caller read someone else's selection. + * * @param selectionId - The Selection ID - * @param userId - The Ministry Platform User ID * @param pageId - The Ministry Platform Page ID * @returns Promise<number[]> - Array of Record_IDs from the selection */ - public async getSelectionRecordIds(selectionId: number, userId: number, pageId: number): Promise<number[]> { + public async getSelectionRecordIds(selectionId: number, pageId: number): Promise<number[]> { + const userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Selections', + operation: 'read', + }); try { const result = await this.mp!.executeProcedureWithBody('api_Common_GetSelection', { '@SelectionID': selectionId, @@ -178,10 +190,13 @@ export class ToolService { * Retrieves the tool paths for a user based on their roles. * Domain ID is automatically injected by the MP API. * - * @param userId - The Ministry Platform User ID * @returns Promise<string[]> - Array of tool paths */ - public async getUserTools(userId: number): Promise<string[]> { + public async getUserTools(): Promise<string[]> { + const userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Tools', + operation: 'read', + }); try { const result = await this.mp!.executeProcedureWithBody('api_Tools_GetUserTools', { "@UserId": userId @@ -205,6 +220,10 @@ export class ToolService { * Display_Name / Table_Name and cap at 100 rows. */ public async listPages(search?: string): Promise<PageLookup[]> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Pages', + operation: 'read', + }); const result = await this.mp!.executeProcedureWithBody('api_MPNextTools_GetPages', {}); const rows = (result?.[0] as PageLookup[] | undefined) ?? []; @@ -225,6 +244,10 @@ export class ToolService { * credential pipeline — the MP API exposes dp_Roles directly to apiuser. */ public async listRoles(search?: string): Promise<RoleLookup[]> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Roles', + operation: 'read', + }); const term = search?.trim(); const filter = term ? `Role_Name LIKE '%${term.replace(/'/g, "''")}%'` @@ -245,7 +268,11 @@ export class ToolService { * credentials — this must not be reachable from production. DomainID is auto-injected * by the MP API. */ - public async deployTool(input: DeployToolInput, userId?: number): Promise<DeployToolResult> { + public async deployTool(input: DeployToolInput): Promise<DeployToolResult> { + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Tools', + operation: 'create', + }); if (!input.toolName.trim()) throw new Error('Tool Name is required'); if (!input.launchPage.trim()) throw new Error('Launch Page is required'); if (input.toolName.length > 30) throw new Error('Tool Name must be 30 characters or fewer'); @@ -266,8 +293,7 @@ export class ToolService { '@RoleIDs': input.roleIds.length ? input.roleIds.join(',') : null, }; - const queryParams = userId !== undefined ? { $userId: userId } : undefined; - const resultSets = await this.mp!.executeProcedureWithBody('api_dev_DeployTool', payload, queryParams); + const resultSets = await this.mp!.executeProcedureWithBody('api_dev_DeployTool', payload, { $userId }); const [toolRows, pageRows, roleRows] = resultSets ?? []; const tool = (toolRows?.[0] as DeployedToolRow | undefined); @@ -302,6 +328,10 @@ export class ToolService { contactIdField: string, recordIds: number[] ): Promise<ContactRecordResult> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contacts', + operation: 'read', + }); const envelope = { tableName, primaryKey, contactIdField }; validateColumnName(primaryKey);