From 506611c7ad74d6fdab256ca375568acf1ae0558f Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 12:58:43 -0700 Subject: [PATCH 1/4] feat(hub): add GitHub user authentication Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 13 +- hub/README.md | 4 +- .../application/authentication/index.tsx | 79 ++++ .../javascript/entrypoints/application.tsx | 4 +- .../pages/sign-in-page/index.test.tsx | 57 +++ .../javascript/pages/sign-in-page/index.tsx | 60 +++ .../javascript/pages/sign-in-page/stories.tsx | 34 ++ .../javascript/pages/sign-in-page/styles.css | 15 + .../javascript/pages/sign-in-page/types.ts | 14 + hub/docs/README.md | 2 +- hub/docs/architecture.md | 8 +- hub/docs/development.md | 6 +- hub/docs/identity.md | 22 +- hub/docs/logging.md | 2 + hub/docs/testing-and-operations.md | 4 +- hub/schema/authorization-attempt.prisma | 1 + hub/schema/identity-store-state.prisma | 3 + .../20261026195151_identity/migration.sql | 2 + hub/server/create-app/index.ts | 8 +- .../complete-github-authorization/index.ts | 156 ++++++++ .../create-admission-policy/index.ts | 42 ++ .../authentication/create-runtime/index.ts | 77 ++++ .../create-service/index.db.test.ts | 124 ++++++ .../create-service/index.test.ts | 365 ++++++++++++++++++ .../authentication/create-service/index.ts | 25 ++ .../authentication/create-service/types.ts | 49 +++ .../ensure-provider-revision/index.ts | 50 +++ .../get-current-session/index.ts | 17 + .../authentication/revoke-sessions/index.ts | 36 ++ .../domains/authentication/sign-out/index.ts | 18 + .../start-github-authorization/index.ts | 57 +++ .../domains/identity/create-records/index.ts | 13 + .../domains/identity/create-store/types.ts | 14 +- hub/server/index.ts | 5 +- .../express/create-rate-limit/index.ts | 37 ++ .../prepare-authentication-request/index.ts | 21 + .../respond-authentication-error/index.ts | 26 ++ .../check-repository-admission/constants.ts | 10 + .../check-repository-admission/index.ts | 57 +++ .../github/create-app-token/index.ts | 16 + .../create-installation-token/constants.ts | 3 + .../github/create-installation-token/index.ts | 13 + .../create-user-auth-client/index.test.ts | 153 ++++++++ .../github/create-user-auth-client/index.ts | 17 + .../github/create-user-auth-client/types.ts | 18 + .../create-user-authorization-url/index.ts | 19 + .../exchange-user-authorization/constants.ts | 6 + .../exchange-user-authorization/index.ts | 42 ++ .../github/github-api-error/index.ts | 5 + .../github/request-github-api/index.ts | 25 ++ .../github/request-github-user/constants.ts | 8 + .../github/request-github-user/index.ts | 39 ++ .../get-current-revision/index.ts | 12 + .../get-revision/index.ts | 8 + .../consume-attempt/index.ts | 2 + .../create-attempt/index.ts | 4 +- .../authorization-attempts/types.ts | 1 + .../advance-session-generation/index.ts | 10 + .../identity-store-state/get-state/index.ts | 10 + .../identity-store-state/types.ts | 3 + .../repositories/users/update-user/index.ts | 7 + .../api/auth/github/callback/get/index.ts | 49 +++ .../routes/api/auth/github/post/index.ts | 24 ++ hub/server/routes/api/session/delete/index.ts | 26 ++ hub/server/routes/api/session/get/index.ts | 50 +++ .../api/session/revoke-all/post/index.ts | 21 + .../routes/api/session/verify/post/index.ts | 27 ++ hub/server/routes/authentication.test.ts | 272 +++++++++++++ hub/server/routes/index.ts | 43 ++- hub/server/routes/types.ts | 9 + .../utils/authentication-cookies/index.ts | 20 + .../utils/authentication-error/index.ts | 9 + hub/server/utils/digest-secret/index.ts | 5 + hub/server/utils/read-cookie/index.ts | 8 + hub/server/utils/require-csrf-token/index.ts | 8 + hub/server/utils/secrets-match/index.ts | 7 + 76 files changed, 2511 insertions(+), 25 deletions(-) create mode 100644 hub/app/javascript/application/authentication/index.tsx create mode 100644 hub/app/javascript/pages/sign-in-page/index.test.tsx create mode 100644 hub/app/javascript/pages/sign-in-page/index.tsx create mode 100644 hub/app/javascript/pages/sign-in-page/stories.tsx create mode 100644 hub/app/javascript/pages/sign-in-page/styles.css create mode 100644 hub/app/javascript/pages/sign-in-page/types.ts create mode 100644 hub/server/domains/authentication/complete-github-authorization/index.ts create mode 100644 hub/server/domains/authentication/create-admission-policy/index.ts create mode 100644 hub/server/domains/authentication/create-runtime/index.ts create mode 100644 hub/server/domains/authentication/create-service/index.db.test.ts create mode 100644 hub/server/domains/authentication/create-service/index.test.ts create mode 100644 hub/server/domains/authentication/create-service/index.ts create mode 100644 hub/server/domains/authentication/create-service/types.ts create mode 100644 hub/server/domains/authentication/ensure-provider-revision/index.ts create mode 100644 hub/server/domains/authentication/get-current-session/index.ts create mode 100644 hub/server/domains/authentication/revoke-sessions/index.ts create mode 100644 hub/server/domains/authentication/sign-out/index.ts create mode 100644 hub/server/domains/authentication/start-github-authorization/index.ts create mode 100644 hub/server/integrations/express/create-rate-limit/index.ts create mode 100644 hub/server/integrations/express/prepare-authentication-request/index.ts create mode 100644 hub/server/integrations/express/respond-authentication-error/index.ts create mode 100644 hub/server/integrations/github/check-repository-admission/constants.ts create mode 100644 hub/server/integrations/github/check-repository-admission/index.ts create mode 100644 hub/server/integrations/github/create-app-token/index.ts create mode 100644 hub/server/integrations/github/create-installation-token/constants.ts create mode 100644 hub/server/integrations/github/create-installation-token/index.ts create mode 100644 hub/server/integrations/github/create-user-auth-client/index.test.ts create mode 100644 hub/server/integrations/github/create-user-auth-client/index.ts create mode 100644 hub/server/integrations/github/create-user-auth-client/types.ts create mode 100644 hub/server/integrations/github/create-user-authorization-url/index.ts create mode 100644 hub/server/integrations/github/exchange-user-authorization/constants.ts create mode 100644 hub/server/integrations/github/exchange-user-authorization/index.ts create mode 100644 hub/server/integrations/github/github-api-error/index.ts create mode 100644 hub/server/integrations/github/request-github-api/index.ts create mode 100644 hub/server/integrations/github/request-github-user/constants.ts create mode 100644 hub/server/integrations/github/request-github-user/index.ts create mode 100644 hub/server/repositories/authentication-providers/get-current-revision/index.ts create mode 100644 hub/server/repositories/authentication-providers/get-revision/index.ts create mode 100644 hub/server/repositories/identity-store-state/advance-session-generation/index.ts create mode 100644 hub/server/repositories/identity-store-state/get-state/index.ts create mode 100644 hub/server/repositories/identity-store-state/types.ts create mode 100644 hub/server/repositories/users/update-user/index.ts create mode 100644 hub/server/routes/api/auth/github/callback/get/index.ts create mode 100644 hub/server/routes/api/auth/github/post/index.ts create mode 100644 hub/server/routes/api/session/delete/index.ts create mode 100644 hub/server/routes/api/session/get/index.ts create mode 100644 hub/server/routes/api/session/revoke-all/post/index.ts create mode 100644 hub/server/routes/api/session/verify/post/index.ts create mode 100644 hub/server/routes/authentication.test.ts create mode 100644 hub/server/routes/types.ts create mode 100644 hub/server/utils/authentication-cookies/index.ts create mode 100644 hub/server/utils/authentication-error/index.ts create mode 100644 hub/server/utils/digest-secret/index.ts create mode 100644 hub/server/utils/read-cookie/index.ts create mode 100644 hub/server/utils/require-csrf-token/index.ts create mode 100644 hub/server/utils/secrets-match/index.ts diff --git a/hub/.env.example b/hub/.env.example index 3086c100..59b34228 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -4,19 +4,30 @@ PORT=3001 # API logging threshold: trace, debug, info, warn, error, fatal, or silent. LOG_LEVEL=info +# Public browser origin. Production requires HTTPS; local HTTP must use loopback. +HUB_ORIGIN=http://127.0.0.1:5173 + # Local development only. The devcontainer supplies its own database hostname. DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/replica # Only database-enabled tests require this URL and CREATEDB permission. TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres +# GitHub user authorization is optional until both values are configured. +# These credentials establish user identity and are separate from source integration. +# HUB_GITHUB_AUTH_CLIENT_ID= +# HUB_GITHUB_AUTH_CLIENT_SECRET= + +# GitHub proves identity by default. Select legacy-repository only for compatibility. +HUB_ADMISSION_POLICY=hub-managed + # Select the GitHub source-integration credential provider. See docs/identity.md. HUB_GITHUB_CREDENTIAL_PROVIDER=database # Generate separate values outside the database. See docs/identity.md. # HUB_GITHUB_CREDENTIAL_KEY= -# The environment provider is read-only and requires every GitHub integration setting below. +# The environment provider is read-only and requires every source-integration setting below. # GITHUB_APP_ID= # GITHUB_INSTALLATION_ID= # GITHUB_CLIENT_ID= diff --git a/hub/README.md b/hub/README.md index fba50194..b6bfa5d8 100644 --- a/hub/README.md +++ b/hub/README.md @@ -2,9 +2,9 @@ Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub. -The API has one route: `GET /api/health`. The frontend displays the existing Replica logo and supports light and dark themes. Existing components, features, and pages are kept in Storybook, but they are not connected to the new API. +The API exposes its health check and GitHub authentication and session routes. The frontend provides the minimal GitHub sign-in and sign-out surface with light and dark themes. Existing components, features, and pages remain in Storybook, but they are not connected to product APIs. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. Sign-in, saved data, live updates, background jobs, and deployment are not implemented. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. GitHub sign-in works when its dedicated client credentials are configured; invitations, membership administration, product authorization, saved product data, live updates, background jobs, and deployment are not implemented. Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior. diff --git a/hub/app/javascript/application/authentication/index.tsx b/hub/app/javascript/application/authentication/index.tsx new file mode 100644 index 00000000..cbffc8e0 --- /dev/null +++ b/hub/app/javascript/application/authentication/index.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from "react" + +import { SignInPage } from "@pages/sign-in-page" +import type { SignInPageUser } from "@pages/sign-in-page/types" + +interface SessionResponse { + authenticated: boolean + configured: boolean + csrfToken: string + user?: SignInPageUser +} + +async function fetchSession(): Promise { + const response = await fetch("/api/session", { credentials: "same-origin" }) + if (!response.ok) throw new Error("Session request failed") + return (await response.json()) as SessionResponse +} + +export function AuthenticationApplication() { + const [session, setSession] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchSession() + .then(setSession) + .catch(() => setError("Hub could not load your sign-in status.")) + .finally(() => setLoading(false)) + }, []) + + async function signIn() { + if (!session) return + setLoading(true) + setError(null) + try { + const response = await fetch("/api/auth/github", { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": session.csrfToken }, + }) + if (!response.ok) throw new Error("Sign-in request failed") + const data = (await response.json()) as { authorizationUrl: string } + window.location.assign(data.authorizationUrl) + } catch { + setError("Hub could not start GitHub sign-in.") + setLoading(false) + } + } + + async function signOut() { + if (!session) return + setLoading(true) + setError(null) + try { + const response = await fetch("/api/session", { + method: "DELETE", + credentials: "same-origin", + headers: { "X-CSRF-Token": session.csrfToken }, + }) + if (!response.ok) throw new Error("Sign-out request failed") + setSession(await fetchSession()) + } catch { + setError("Hub could not sign you out.") + } finally { + setLoading(false) + } + } + + return ( + void signIn()} + onSignOut={() => void signOut()} + /> + ) +} diff --git a/hub/app/javascript/entrypoints/application.tsx b/hub/app/javascript/entrypoints/application.tsx index ca589d06..fe075efb 100644 --- a/hub/app/javascript/entrypoints/application.tsx +++ b/hub/app/javascript/entrypoints/application.tsx @@ -2,7 +2,7 @@ import "@/tokens/base.css" import { StrictMode } from "react" import { createRoot } from "react-dom/client" -import { ReplicaLogo } from "@components/replica-logo" +import { AuthenticationApplication } from "@/application/authentication" import { HubThemeProvider } from "@hooks/use-hub-theme" const root = document.getElementById("root") @@ -11,7 +11,7 @@ if (!root) throw new Error("The Hub document is missing its React root.") createRoot(root).render( - + , ) diff --git a/hub/app/javascript/pages/sign-in-page/index.test.tsx b/hub/app/javascript/pages/sign-in-page/index.test.tsx new file mode 100644 index 00000000..d2d9ea47 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/index.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { SignInPage } from "." + +describe("SignInPage()", () => { + it("starts GitHub sign-in without presenting repository access as authentication", () => { + const onSignIn = vi.fn() + render( + undefined} + />, + ) + + fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" })) + + expect(onSignIn).toHaveBeenCalledOnce() + expect(screen.getByText(/Repository access is authorized separately/)).toBeVisible() + }) + + it("shows the provider-neutral session identity and sign-out control", () => { + render( + undefined} + onSignOut={() => undefined} + />, + ) + + expect(screen.getByText(/Signed in as/)).toHaveTextContent("Mona Lisa (@mona)") + expect(screen.getByRole("button", { name: "Sign out" })).toBeEnabled() + }) + + it("disables sign-in when GitHub user authorization is not configured", () => { + render( + undefined} + onSignOut={() => undefined} + />, + ) + + expect(screen.getByRole("button", { name: "Sign in with GitHub" })).toBeDisabled() + expect(screen.getByText(/has not been configured/)).toBeVisible() + }) +}) diff --git a/hub/app/javascript/pages/sign-in-page/index.tsx b/hub/app/javascript/pages/sign-in-page/index.tsx new file mode 100644 index 00000000..15907c79 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/index.tsx @@ -0,0 +1,60 @@ +import { Button, Heading, Stack, Text } from "@primer/react-brand" + +import { ReplicaLogo } from "@components/replica-logo" + +import type { SignInPageProps } from "./types" +import "./styles.css" + +export function SignInPage({ + configured, + loading, + user, + error, + onSignIn, + onSignOut, +}: SignInPageProps) { + return ( +
+ + + + Replica Hub + + {user ? ( + <> + + Signed in as {user.displayName ?? user.username} + {user.username ? ` (@${user.username})` : ""}. + + + + ) : ( + <> + + Sign in with GitHub to create a Hub session. Repository access is authorized + separately. + + + {!configured && ( + GitHub sign-in has not been configured for this Hub. + )} + + )} + {error && ( + + {error} + + )} + +
+ ) +} diff --git a/hub/app/javascript/pages/sign-in-page/stories.tsx b/hub/app/javascript/pages/sign-in-page/stories.tsx new file mode 100644 index 00000000..2e0baf07 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { SignInPage } from "." + +const meta = { + title: "Hub/Pages/Identity/Sign in", + component: SignInPage, + parameters: { layout: "fullscreen" }, + args: { + configured: true, + loading: false, + user: null, + error: null, + onSignIn: () => undefined, + onSignOut: () => undefined, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const SignedOut: Story = {} +export const SignedIn: Story = { + args: { + user: { + displayName: "Mona Lisa", + username: "mona", + avatarUrl: null, + }, + }, +} +export const Unconfigured: Story = { + args: { configured: false }, +} diff --git a/hub/app/javascript/pages/sign-in-page/styles.css b/hub/app/javascript/pages/sign-in-page/styles.css new file mode 100644 index 00000000..77bb16d8 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/styles.css @@ -0,0 +1,15 @@ +.sign-in-page { + display: grid; + min-block-size: 100vh; + place-items: center; + padding: 2rem; + text-align: center; +} + +.sign-in-page > * { + max-inline-size: 32rem; +} + +.sign-in-page__error { + color: var(--hub-fg-danger); +} diff --git a/hub/app/javascript/pages/sign-in-page/types.ts b/hub/app/javascript/pages/sign-in-page/types.ts new file mode 100644 index 00000000..d12b098c --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/types.ts @@ -0,0 +1,14 @@ +export interface SignInPageUser { + displayName: string | null + username: string | null + avatarUrl: string | null +} + +export interface SignInPageProps { + configured: boolean + loading: boolean + user: SignInPageUser | null + error: string | null + onSignIn: () => void + onSignOut: () => void +} diff --git a/hub/docs/README.md b/hub/docs/README.md index 85db3214..069c4073 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -7,7 +7,7 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Coding conventions](coding-conventions.md): frontend layers and interface rules. - [Development](development.md): local API and frontend setup. - [Database](database.md): Prisma schema conventions, migrations, and the shared client. -- [Identity](identity.md): credential storage and providers. +- [Identity](identity.md): GitHub user authentication, provider-neutral sessions, admission policy, and source-integration credentials. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. - [Storybook](storybook.md): retained presentation, fixtures, and screenshot evidence. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 901669d8..52f58790 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,15 +6,15 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -The API responds to a health check, and the frontend displays the logo with light and dark themes. Storybook keeps the existing components and pages available for reuse. +The API responds to a health check and provides GitHub authentication and Hub session routes. The frontend displays the sign-in state with light and dark themes. Storybook keeps the existing components and pages available for reuse. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity foundation](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. Sign-in routes, deployment, background jobs, and product APIs have not been built. This app cannot yet authenticate users, evaluate prototype access, find repositories, import Builds, host previews, save product data, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization and provider-neutral Hub sessions work when user-auth credentials are configured. Deployment, background jobs, and product APIs have not been built. This app cannot yet evaluate prototype access, find repositories, import Builds, host previews, save product data, or support reviews. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -The only route is `GET /api/health`, which returns `{ "status": "ok" }`. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` starts it at a local address. +`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication runtime and starts the API at a local address. The [database guide](database.md) defines the multi-file Prisma schema and shared client in integrations. The health route remains a process check, not a database readiness check. @@ -35,7 +35,7 @@ Keep the existing `app/javascript/` layout. Application code starts React and wi Imports follow the arrows above. Shared components, hooks, and utilities must not import pages, features, or application code. Keep browser and server code separate. Agree on the API before adding types shared between them. -The frontend displays the existing logo inside the theme provider. Existing pages and features use ordinary browser links. Their old destinations do not exist in the new API; keeping the interface does not make those features work. +The frontend displays the GitHub sign-in and session state inside the theme provider. Existing pages and features use ordinary browser links. Their old destinations do not exist in the new API; keeping the interface does not make those features work. ## Other projects diff --git a/hub/docs/development.md b/hub/docs/development.md index b91ecf9f..a49e7532 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -1,6 +1,6 @@ # Hub development -Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. Sign-in and production deployment are not configured. +Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. GitHub sign-in is optional in local development. Production deployment is not configured. ## Devcontainer @@ -20,7 +20,7 @@ cp .env.example .env npm ci --engine-strict ``` -The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. The existing health and frontend routes do not query the database. +The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. Set `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in; leave both unset to run Hub with sign-in disabled. ## Start the app @@ -36,7 +36,7 @@ In a second terminal, also from `hub/`, start Vite: npm run dev ``` -Open . Vite serves the frontend and forwards `/api` requests to Express at . `GET /api/health` returns JSON; other API routes have not been built. Both servers accept connections only from this machine. Stop each with Ctrl+C. +Open . Vite serves the sign-in surface and forwards `/api` requests to Express at . `GET /api/health` returns JSON. `GET /api/session` reports the browser session and supplies the CSRF value used by the sign-in and sign-out controls. Both servers accept connections only from this machine. Stop each with Ctrl+C. The API reads an optional `PORT` environment variable, defaulting to 3001. If you change it, update the local Vite proxy target to match. The editor forwards ports 3001, 5173, and 6006 from the devcontainer. API startup reads `.env` when present; Compose's environment takes precedence. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 01e1e040..53b6e2b2 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -1,6 +1,20 @@ # Hub identity -Hub persists a provider-neutral identity foundation. User authentication proves an external identity. Hub installation membership decides whether that user belongs to this Hub and whether they are an administrator. Later authorization work will let Hub own prototype access independently of repository permissions. +Hub uses GitHub user authorization to prove an external identity and creates provider-neutral browser sessions. Hub installation membership records administrative roles. Later authorization work will let Hub own prototype access independently of repository permissions. + +## GitHub user authentication + +Set `HUB_ORIGIN`, `HUB_GITHUB_AUTH_CLIENT_ID`, and `HUB_GITHUB_AUTH_CLIENT_SECRET` to enable GitHub sign-in. `HUB_ORIGIN` must be an HTTPS origin, except that local development may use HTTP on loopback. The callback is always `${HUB_ORIGIN}/api/auth/github/callback`. + +Hub stores a versioned authentication-provider revision with non-secret configuration and a fingerprint that changes when the client secret changes. The client secret remains in the process environment. Each authorization attempt stores digested state and browser bindings, a PKCE verifier, the provider revision, the current session-revocation generation, its purpose, and a ten-minute expiry. + +The callback consumes an attempt once, checks the same-browser binding and current provider and revocation state, exchanges the code with PKCE, and reads the authenticated GitHub profile. Hub maps GitHub's immutable numeric account ID to `ExternalIdentity.subject`. The user access token exists only while requesting that profile; Hub never stores it. + +Hub sessions use a random opaque cookie and store only its digest. Authentication and session cookies are `HttpOnly`, `SameSite=Lax`, path-scoped, and `Secure` on HTTPS. State-changing routes require the configured origin and a matching CSRF cookie and header. Authentication routes omit request payloads from logs. + +`HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, and no repository permission check gates sign-in. `legacy-repository` is an explicit compatibility policy that uses the separate GitHub source integration to require access to at least one installed repository. The GitHub authentication adapter never reads installation credentials or performs repository authorization. + +Sign-out revokes the current database session. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. ## Persisted boundaries @@ -23,12 +37,12 @@ GitHub source access is an optional integration boundary, not the global identit | `database` (default) | Generate `HUB_GITHUB_CREDENTIAL_KEY` with `openssl rand -hex 32`. The provider encrypts GitHub integration credentials before storage and authenticates their context when reading. | | `environment` | Set every `GITHUB_*` value, including `GITHUB_INSTALLATION_ID`. This provider is read-only and ignores database credentials. | -Both providers currently validate an App ID, installation ID, client ID, client secret, and RSA private key. IDs are positive decimal strings. The key must contain at least 2048 bits. +Both providers currently validate an App ID, installation ID, client ID, client secret, and RSA private key. IDs are positive decimal strings. The key must contain at least 2048 bits. A deployment may use the same GitHub App for user authorization and source integration, but it supplies and rotates the two configurations independently. Database credentials use AES-256-GCM with contextual additional authenticated data. Keep the encryption key outside the database and back it up separately. Losing or replacing it makes saved credentials unreadable. Invalid provider settings or unreadable ciphertext cause errors; Hub never falls back to another provider. -## Foundation scope +## Current scope -This layer provides persistence only. It does not add HTTP sign-in routes, production OIDC or SCIM flows, prototype or product models, an authorization evaluator, GitHub permission synchronization, artifact ingestion, or UI. GitHub authentication configuration and GitHub source-integration credentials remain separate so either capability can change without redefining Hub identity. +This layer adds GitHub sign-in, session restoration, sign-out, fresh verification, global session revocation, and the minimal frontend needed to use them. It does not add invitations or membership administration, prototype authorization, GitHub permission synchronization, artifact ingestion, OIDC, account linking, SCIM, or product APIs. Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/logging.md b/hub/docs/logging.md index 47fca2c8..d6580315 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -40,6 +40,8 @@ router.use(omitRequestLogPayload) This omits `params`, `query`, and `body` while keeping request metadata. +Authentication and session routes install this omission before their handlers run. OAuth codes, state values, CSRF values, cookies, PKCE material, and provider responses therefore never enter automatic request payload records. + ## Structured-field safety Application fields, `request.log` fields, and automatic payloads share the same sanitizer. It copies data without changing the request or invoking getters or custom serialization methods. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index d637b72a..724dc1a0 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -16,7 +16,7 @@ npm run storybook:build `npm test` runs API and utility tests in Node.js and frontend tests in jsdom, which provides a browser-like environment. Neither requires PostgreSQL. To run a focused API check, use `npm test -- server/create-app/index.test.ts`. HTTP tests listen on temporary loopback ports and close their servers afterward. -The API health check proves only that Express responds. Routes that have not been built must return 404 rather than pretending to work. +The API health check proves only that Express responds. Authentication route tests inject provider clients and identity services; they never require live GitHub credentials or make real GitHub requests. Routes that have not been built must return 404 rather than pretending to work. Installation generates Prisma Client without a database connection. Client tests check reuse across development reloads, production behavior, and errors for missing configuration. @@ -53,7 +53,7 @@ Use `.test.ts` or `.test.tsx` for ordinary cases. Use `.db.test.ts` or `.db.test Start the PostgreSQL service as described in [Development](development.md) and set `TEST_DATABASE_URL` using `.env.example`. The devcontainer supplies this variable. Use a local database user with permission to create and drop databases, never a production connection. -The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. Identity repository cases exercise external identity linking and uniqueness, installation membership, provider-aware one-use attempts, session context, GitHub integration credential independence, transaction rollback, and concurrent store locks. +The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. Identity repository cases exercise external identity linking and uniqueness, installation membership, provider-aware one-use attempts, session context, GitHub integration credential independence, transaction rollback, and concurrent store locks. Authentication cases also cover PKCE, browser binding, provider revisions, revocation races, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. Database-enabled jsdom tests use the browser setup and database hooks together. They can arrange records through server code, but frontend code must not import server modules. The setup does not start an API or connect frontend requests to one. diff --git a/hub/schema/authorization-attempt.prisma b/hub/schema/authorization-attempt.prisma index f3743ace..251c6fde 100644 --- a/hub/schema/authorization-attempt.prisma +++ b/hub/schema/authorization-attempt.prisma @@ -14,6 +14,7 @@ model AuthorizationAttempt { pkceVerifier String @map("pkce_verifier") nonce String? purpose String + sessionGeneration Int @default(0) @map("session_generation") expiresAt DateTime @map("expires_at") @db.Timestamptz(3) // Timestamp columns diff --git a/hub/schema/identity-store-state.prisma b/hub/schema/identity-store-state.prisma index 06db17be..1b5ca274 100644 --- a/hub/schema/identity-store-state.prisma +++ b/hub/schema/identity-store-state.prisma @@ -2,6 +2,9 @@ model IdentityStoreState { // ID columns id String @id @db.Uuid + // Attribute columns + sessionGeneration Int @default(0) @map("session_generation") + // Timestamp columns createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) diff --git a/hub/schema/migrations/20261026195151_identity/migration.sql b/hub/schema/migrations/20261026195151_identity/migration.sql index 025776e7..602477f6 100644 --- a/hub/schema/migrations/20261026195151_identity/migration.sql +++ b/hub/schema/migrations/20261026195151_identity/migration.sql @@ -26,6 +26,7 @@ CREATE TABLE "authentication_provider_revisions" ( -- CreateTable CREATE TABLE "identity_store_states" ( "id" UUID NOT NULL, + "session_generation" INTEGER NOT NULL DEFAULT 0, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -111,6 +112,7 @@ CREATE TABLE "authorization_attempts" ( "pkce_verifier" TEXT NOT NULL, "nonce" TEXT, "purpose" TEXT NOT NULL, + "session_generation" INTEGER NOT NULL DEFAULT 0, "expires_at" TIMESTAMPTZ(3) NOT NULL, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index a3954deb..5ea4c5f6 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -5,15 +5,19 @@ import { handleError } from "../integrations/express/handle-error/index.ts" import { respondNotFound } from "../integrations/express/respond-not-found/index.ts" import { createRequestLogger } from "../integrations/pino/create-request-logger/index.ts" import { routes } from "../routes/index.ts" +import type { AuthenticationRoutes } from "../routes/types.ts" /** Give every endpoint the same request logging and safe error responses. */ -export function createApp({ logger }: { logger?: Logger } = {}) { +export function createApp({ + authentication, + logger, +}: { authentication?: AuthenticationRoutes; logger?: Logger } = {}) { const app = express() app.disable("x-powered-by") app.use(createRequestLogger(logger)) app.use(express.json()) - routes(app) + routes(app, authentication) app.use(respondNotFound) app.use(handleError) diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts new file mode 100644 index 00000000..b938c2e6 --- /dev/null +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -0,0 +1,156 @@ +import { randomBytes } from "node:crypto" + +import type { + AuthenticationDependencies, + AuthorizationPurpose, +} from "#server/domains/authentication/create-service/types" +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" +import { secretsMatch } from "#server/utils/secrets-match" + +export async function completeGitHubAuthorization( + dependencies: AuthenticationDependencies, + code: string, + state: string, + browserToken: string, +): Promise<{ sessionToken?: string; purpose: AuthorizationPurpose }> { + const { admissionPolicy, configuration, github, now, providerRevisionId, store } = dependencies + if (!configuration || !github || !providerRevisionId) { + throw new AuthenticationError( + 503, + "authentication_unconfigured", + "GitHub authentication is not configured.", + ) + } + const attempt = await store.lock((records) => + records.consumeAuthorizationAttempt(digestSecret(state)), + ) + if ( + !attempt || + !browserToken || + !secretsMatch(digestSecret(browserToken), attempt.browserDigest) || + attempt.authenticationProviderRevisionId !== providerRevisionId + ) { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid or expired.", + ) + } + let profile: GitHubUserProfile + let admitted: boolean + try { + profile = await github.identify(code, attempt.pkceVerifier) + admitted = attempt.purpose === "verify" ? true : await admissionPolicy.admit(profile) + } catch (error) { + await store.lock((records) => + records.audit({ + event: "authentication.provider_failed", + actorUserId: null, + sessionId: attempt.originatingSessionId, + authenticationProviderRevisionId: providerRevisionId, + }), + ) + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "authentication_provider_failed", + "The authentication provider failed.", + ) + } + if (!admitted) { + await store.lock((records) => + records.audit({ + event: "authentication.admission_denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + }), + ) + throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") + } + return store.lock(async (records) => { + const [currentProvider, stateRecord] = await Promise.all([ + records.currentAuthenticationProviderRevision("github"), + records.identityStoreState(), + ]) + if ( + currentProvider?.id !== providerRevisionId || + stateRecord.sessionGeneration !== attempt.sessionGeneration + ) { + throw new AuthenticationError( + 401, + "authorization_superseded", + "The authorization request was superseded.", + ) + } + if (attempt.purpose === "verify") { + const session = attempt.originatingSessionId + ? await records.sessionById(attempt.originatingSessionId) + : null + if ( + !session || + session.externalIdentity.provider !== "github" || + session.externalIdentity.issuer !== configuration.issuer || + session.externalIdentity.subject !== profile.subject + ) { + throw new AuthenticationError(403, "identity_mismatch", "Verify the same GitHub identity.") + } + await records.verifySession(session.id, now()) + await records.audit({ + event: "session.verify", + actorUserId: session.userId, + sessionId: session.id, + authenticationProviderRevisionId: providerRevisionId, + }) + return { purpose: "verify" } + } + let identity = await records.externalIdentity("github", configuration.issuer, profile.subject) + if (!identity) { + const user = await records.createUser({ + displayName: profile.displayName ?? profile.username, + avatarUrl: profile.avatarUrl, + }) + identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: configuration.issuer, + subject: profile.subject, + username: profile.username, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }) + } else { + await records.updateUser(identity.userId, { + displayName: profile.displayName ?? profile.username, + avatarUrl: profile.avatarUrl, + }) + identity = await records.saveExternalIdentity({ + userId: identity.userId, + provider: "github", + issuer: configuration.issuer, + subject: profile.subject, + username: profile.username, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }) + } + const sessionToken = randomBytes(32).toString("base64url") + const session = await records.createSession({ + userId: identity.userId, + externalIdentityId: identity.id, + authenticationProviderRevisionId: providerRevisionId, + tokenDigest: digestSecret(sessionToken), + signedInAt: now(), + freshAuthorizedAt: now(), + }) + await records.audit({ + event: "session.sign_in", + actorUserId: identity.userId, + sessionId: session.id, + authenticationProviderRevisionId: providerRevisionId, + }) + return { sessionToken, purpose: "sign-in" } + }) +} diff --git a/hub/server/domains/authentication/create-admission-policy/index.ts b/hub/server/domains/authentication/create-admission-policy/index.ts new file mode 100644 index 00000000..ad4059a7 --- /dev/null +++ b/hub/server/domains/authentication/create-admission-policy/index.ts @@ -0,0 +1,42 @@ +import type { AdmissionPolicy } from "#server/domains/authentication/create-service/types" +import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider" +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import { checkRepositoryAdmission } from "#server/integrations/github/check-repository-admission" +import { AuthenticationError } from "#server/utils/authentication-error" + +export function createAdmissionPolicy( + environment: NodeJS.ProcessEnv, + store: IdentityStore, +): AdmissionPolicy { + const selected = environment.HUB_ADMISSION_POLICY ?? "hub-managed" + if (selected === "hub-managed") { + return { name: "hub-managed", admit: () => Promise.resolve(true) } + } + if (selected !== "legacy-repository") { + throw new Error("HUB_ADMISSION_POLICY must be hub-managed or legacy-repository.") + } + const provider = createGitHubIntegrationCredentialProvider(environment) + return { + name: "legacy-repository", + admit: async (profile) => { + try { + const credentials = provider.read(await store.read.githubIntegrationState()) + if (!credentials) { + throw new AuthenticationError( + 503, + "source_integration_unconfigured", + "GitHub source integration is not configured.", + ) + } + return await checkRepositoryAdmission(credentials, profile) + } catch (error) { + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + }, + } +} diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts new file mode 100644 index 00000000..71606f0f --- /dev/null +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -0,0 +1,77 @@ +import { createAdmissionPolicy } from "#server/domains/authentication/create-admission-policy" +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import type { AuthenticationSettings } from "#server/domains/authentication/create-service/types" +import { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { prisma } from "#server/integrations/prisma" + +function readOrigin(environment: NodeJS.ProcessEnv): URL { + const origin = new URL(environment.HUB_ORIGIN ?? "http://127.0.0.1:5173") + const secure = origin.protocol === "https:" + if ( + !["https:", "http:"].includes(origin.protocol) || + origin.origin !== origin.href.replace(/\/$/, "") || + origin.username || + origin.password || + (!secure && + (environment.NODE_ENV === "production" || + !["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname))) + ) { + throw new Error( + "Set HUB_ORIGIN to an HTTPS origin, or a loopback HTTP origin outside production.", + ) + } + return origin +} + +function readGitHubAuthentication( + environment: NodeJS.ProcessEnv, + origin: URL, +): GitHubUserAuthConfiguration | null { + const clientId = environment.HUB_GITHUB_AUTH_CLIENT_ID + const clientSecret = environment.HUB_GITHUB_AUTH_CLIENT_SECRET + if (!clientId && !clientSecret) return null + if ( + !clientId || + !clientSecret || + clientId.length > 255 || + clientSecret.length < 20 || + clientSecret.length > 1024 + ) { + throw new Error("Set both HUB_GITHUB_AUTH_CLIENT_ID and HUB_GITHUB_AUTH_CLIENT_SECRET.") + } + return { + clientId, + clientSecret, + callbackUrl: `${origin.origin}/api/auth/github/callback`, + issuer: "https://github.com", + } +} + +export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv) { + const origin = readOrigin(environment) + const configuration = readGitHubAuthentication(environment, origin) + const store = createIdentityStore(prisma) + const providerRevision = configuration + ? await ensureGitHubProviderRevision(store, configuration) + : null + const admissionPolicy = createAdmissionPolicy(environment, store) + const settings: AuthenticationSettings = { + origin: origin.origin, + secureCookies: origin.protocol === "https:", + configured: configuration !== null, + admissionPolicy: admissionPolicy.name, + } + return { + service: createAuthenticationService({ + store, + github: configuration ? createGitHubUserAuthClient(configuration) : null, + configuration, + providerRevisionId: providerRevision?.id ?? null, + admissionPolicy, + }), + settings, + } +} diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts new file mode 100644 index 00000000..510dfce9 --- /dev/null +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { digestSecret } from "#server/utils/digest-secret" + +type IdentityStore = ReturnType + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + return { prisma, store: createIdentityStore(prisma) } +} + +async function createAdministrator(store: IdentityStore) { + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: "Synthetic administrator", + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "octocat", + displayName: "Synthetic administrator", + avatarUrl: null, + }) + const provider = await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + }) + await records.saveMembership({ + userId: user.id, + status: "active", + role: "administrator", + source: "manual", + }) + return { identity, provider, user } + }) +} + +async function createSession( + store: IdentityStore, + graph: Awaited>, + token: string, +) { + const authorizedAt = new Date() + return store.lock((records) => + records.createSession({ + userId: graph.user.id, + externalIdentityId: graph.identity.id, + authenticationProviderRevisionId: graph.provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: authorizedAt, + }), + ) +} + +describe("createAuthenticationService() database behavior", () => { + it("rolls back generation, session deletion, and audit together when revocation fails", async () => { + const { store } = await database() + const graph = await createAdministrator(store) + const session = await createSession(store, graph, "rollback-token") + + await expect( + store.lock(async (records) => { + await records.advanceSessionGeneration() + await records.deleteSessions() + await records.audit({ + event: "session.revoke_all", + actorUserId: graph.user.id, + sessionId: session.id, + authenticationProviderRevisionId: graph.provider.id, + }) + throw new Error("revocation failed") + }), + ).rejects.toThrow("revocation failed") + + expect(await store.read.identityStoreState()).toEqual({ sessionGeneration: 0 }) + expect(await store.read.session(digestSecret("rollback-token"))).toMatchObject({ + id: session.id, + }) + expect(await store.read.audits()).toEqual([]) + }) + + it("serializes concurrent revoke-all requests so generation advances exactly once", async () => { + const { store } = await database() + const graph = await createAdministrator(store) + await createSession(store, graph, "administrator-token") + await createSession(store, graph, "other-token") + const service = createAuthenticationService({ + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(true) }, + }) + + const results = await Promise.allSettled([ + service.revokeAll("administrator-token"), + service.revokeAll("administrator-token"), + ]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1) + expect(await store.read.identityStoreState()).toEqual({ sessionGeneration: 1 }) + expect(await store.read.session(digestSecret("administrator-token"))).toBeNull() + expect(await store.read.session(digestSecret("other-token"))).toBeNull() + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "session.revoke_all", + actorUserId: graph.user.id, + }), + ]) + }) +}) diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts new file mode 100644 index 00000000..a5512919 --- /dev/null +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it, vi } from "vitest" +import type { Mocked } from "vitest" + +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import type { GitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client/types" +import { digestSecret } from "#server/utils/digest-secret" + +const now = new Date("2026-10-27T18:00:00.000Z") +const configuration = { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com" as const, +} +const providerRevision = { + id: "provider-revision", + authenticationProviderId: "provider", + revision: 1, + issuer: configuration.issuer, + configuration: {}, + authenticationProvider: { + id: "provider", + key: "github", + type: "github", + enabled: true, + }, +} + +type MockIdentityRecords = Mocked + +function records(overrides: Partial = {}) { + const fixture = { + currentAuthenticationProviderRevision: vi.fn().mockResolvedValue(providerRevision), + identityStoreState: vi.fn().mockResolvedValue({ sessionGeneration: 3 }), + createAuthorizationAttempt: vi.fn().mockResolvedValue(undefined), + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(null), + externalIdentity: vi.fn().mockResolvedValue(null), + createUser: vi.fn(), + updateUser: vi.fn(), + saveExternalIdentity: vi.fn(), + createSession: vi.fn(), + verifySession: vi.fn(), + deleteSession: vi.fn(), + deleteSessions: vi.fn(), + advanceSessionGeneration: vi.fn(), + session: vi.fn().mockResolvedValue(null), + sessionById: vi.fn().mockResolvedValue(null), + membership: vi.fn().mockResolvedValue(null), + audit: vi.fn(), + } + return Object.assign(fixture, overrides) +} + +type TestIdentityRecords = ReturnType + +function store(identityRecords: TestIdentityRecords): IdentityStore { + const typedRecords = identityRecords as unknown as IdentityRecords + return { + read: typedRecords, + lock: (operation) => operation(typedRecords), + } +} + +function dependencies( + identityRecords: TestIdentityRecords, + overrides: Partial< + Pick + > = {}, +) { + const github: Mocked = { + authorizationUrl: vi.fn( + (state, verifier) => `https://github.example/authorize?state=${state}&verifier=${verifier}`, + ), + identify: vi.fn(), + } + return { + store: store(identityRecords), + github, + configuration, + providerRevisionId: providerRevision.id, + admissionPolicy: { + name: "hub-managed" as const, + admit: vi.fn().mockResolvedValue(true), + }, + now: () => now, + ...overrides, + } +} + +function attempt(overrides: Record = {}) { + return { + id: "attempt", + authenticationProviderRevisionId: providerRevision.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in" as const, + sessionGeneration: 3, + expiresAt: new Date(now.getTime() + 60_000), + ...overrides, + } +} + +describe("createAuthenticationService()", () => { + it("starts a short-lived browser-bound authorization with state and an S256-ready verifier", async () => { + const identityRecords = records() + const options = dependencies(identityRecords) + const service = createAuthenticationService(options) + + const result = await service.start("sign-in") + + const authorizationUrl = new URL(result.authorizationUrl) + const state = authorizationUrl.searchParams.get("state") + const verifier = authorizationUrl.searchParams.get("verifier") + expect(state).toMatch(/^[\w-]{43}$/) + expect(result.browserToken).toMatch(/^[\w-]{43}$/) + expect(verifier).toMatch(/^[\w-]{43}$/) + expect(identityRecords.createAuthorizationAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + stateDigest: digestSecret(state!), + browserDigest: digestSecret(result.browserToken), + pkceVerifier: verifier, + purpose: "sign-in", + sessionGeneration: 3, + expiresAt: new Date(now.getTime() + 10 * 60_000), + }), + ) + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalledWith( + expect.objectContaining({ stateDigest: state, browserDigest: result.browserToken }), + ) + }) + + it("consumes an attempt before rejecting the wrong browser so neither replay can exchange a code", async () => { + let available = attempt() + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn(() => { + const consumed = available + available = null as never + return Promise.resolve(consumed) + }), + }) + const options = dependencies(identityRecords) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "wrong-browser")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + expect(options.github.identify.mock.calls).toHaveLength(0) + }) + + it("fails closed when the store reports an expired or unknown authorization attempt", async () => { + const identityRecords = records() + const options = dependencies(identityRecords) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + expect(options.github.identify.mock.calls).toHaveLength(0) + }) + + it("updates the existing immutable GitHub identity and creates a session without repository data", async () => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "old-login", + displayName: "Old name", + avatarUrl: null, + user: { id: "user", displayName: "Old name", avatarUrl: null }, + } + const updatedIdentity = { + ...existingIdentity, + username: "new-login", + displayName: "New name", + avatarUrl: "https://avatars.example/42", + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + updateUser: vi.fn().mockResolvedValue(updatedIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(updatedIdentity), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const options = dependencies(identityRecords) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "new-login", + displayName: "New name", + avatarUrl: "https://avatars.example/42", + }) + const service = createAuthenticationService(options) + + const result = await service.complete("one-use-code", "state", "browser-token") + + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.saveExternalIdentity).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user", subject: "42", username: "new-login" }), + ) + expect(identityRecords.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + externalIdentityId: "identity", + tokenDigest: digestSecret(result.sessionToken!), + }), + ) + expect(JSON.stringify(vi.mocked(identityRecords.createSession).mock.calls)).not.toContain( + "one-use-code", + ) + }) + + it("lets a compatibility admission policy deny sign-in before creating identity records", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + const admissionPolicy = { + name: "legacy-repository" as const, + admit: vi.fn().mockResolvedValue(false), + } + const options = dependencies(identityRecords, { admissionPolicy }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: null, + avatarUrl: null, + }) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + expect(admissionPolicy.admit).toHaveBeenCalledWith(expect.objectContaining({ subject: "42" })) + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + + it("refreshes authorization only when GitHub returns the session's same external identity", async () => { + const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "digest", + signedInAt: now, + freshAuthorizedAt: null, + user: { id: "user", displayName: "User", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: null, + avatarUrl: null, + }, + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi + .fn() + .mockResolvedValue(attempt({ purpose: "verify", originatingSessionId: session.id })), + sessionById: vi.fn().mockResolvedValue(session), + }) + const options = dependencies(identityRecords) + options.github.identify.mockResolvedValue({ + subject: "99", + username: "different-user", + displayName: null, + avatarUrl: null, + }) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "identity_mismatch", + }) + expect(identityRecords.verifySession).not.toHaveBeenCalled() + + options.github.identify.mockResolvedValue({ + subject: "42", + username: "renamed-user", + displayName: null, + avatarUrl: null, + }) + identityRecords.consumeAuthorizationAttempt.mockResolvedValueOnce( + attempt({ purpose: "verify", originatingSessionId: session.id }), + ) + await expect(service.complete("code", "state", "browser-token")).resolves.toEqual({ + purpose: "verify", + }) + expect(identityRecords.verifySession).toHaveBeenCalledWith(session.id, now) + }) + + it("signs out only the current session and requires fresh administrator access to revoke all", async () => { + const current = { + id: "current-session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: digestSecret("session-token"), + signedInAt: now, + freshAuthorizedAt: new Date(now.getTime() - 11 * 60_000), + user: { id: "user", displayName: "Administrator", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: null, + avatarUrl: null, + }, + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + session: vi.fn().mockResolvedValue(current), + membership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "administrator", + source: "manual", + }), + }) + const service = createAuthenticationService(dependencies(identityRecords)) + + await service.signOut("session-token") + expect(identityRecords.deleteSession).toHaveBeenCalledWith(current.id) + expect(identityRecords.deleteSessions).not.toHaveBeenCalled() + + await expect(service.revokeAll("session-token")).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + expect(identityRecords.advanceSessionGeneration).not.toHaveBeenCalled() + + current.freshAuthorizedAt = now + await service.revokeAll("session-token") + expect(identityRecords.advanceSessionGeneration).toHaveBeenCalledOnce() + expect(identityRecords.deleteSessions).toHaveBeenCalledOnce() + }) +}) diff --git a/hub/server/domains/authentication/create-service/index.ts b/hub/server/domains/authentication/create-service/index.ts new file mode 100644 index 00000000..ea67bf62 --- /dev/null +++ b/hub/server/domains/authentication/create-service/index.ts @@ -0,0 +1,25 @@ +import { completeGitHubAuthorization } from "#server/domains/authentication/complete-github-authorization" +import type { + AuthenticationDependencies, + AuthenticationService, +} from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { revokeSessions } from "#server/domains/authentication/revoke-sessions" +import { signOut } from "#server/domains/authentication/sign-out" +import { startGitHubAuthorization } from "#server/domains/authentication/start-github-authorization" + +export function createAuthenticationService( + options: Omit & { now?: () => Date }, +): AuthenticationService { + const dependencies: AuthenticationDependencies = { + ...options, + now: options.now ?? (() => new Date()), + } + return { + start: startGitHubAuthorization.bind(null, dependencies), + complete: completeGitHubAuthorization.bind(null, dependencies), + current: getCurrentSession.bind(null, dependencies), + signOut: signOut.bind(null, dependencies), + revokeAll: revokeSessions.bind(null, dependencies), + } +} diff --git a/hub/server/domains/authentication/create-service/types.ts b/hub/server/domains/authentication/create-service/types.ts new file mode 100644 index 00000000..da14ff5b --- /dev/null +++ b/hub/server/domains/authentication/create-service/types.ts @@ -0,0 +1,49 @@ +import type { IdentityStore, SessionContext } from "#server/domains/identity/create-store/types" +import type { + GitHubUserAuthClient, + GitHubUserAuthConfiguration, + GitHubUserProfile, +} from "#server/integrations/github/create-user-auth-client/types" +import type { InstallationMembershipRecord } from "#server/repositories/installation-memberships/types" + +export type AuthorizationPurpose = "sign-in" | "verify" + +export interface AdmissionPolicy { + name: "hub-managed" | "legacy-repository" + admit(profile: GitHubUserProfile): Promise +} + +export interface AuthenticationSettings { + origin: string + secureCookies: boolean + configured: boolean + admissionPolicy: AdmissionPolicy["name"] +} + +export interface AuthenticationContext extends SessionContext { + membership: InstallationMembershipRecord | null +} + +export interface AuthenticationService { + start( + purpose: AuthorizationPurpose, + sessionToken?: string, + ): Promise<{ authorizationUrl: string; browserToken: string }> + complete( + code: string, + state: string, + browserToken: string, + ): Promise<{ sessionToken?: string; purpose: AuthorizationPurpose }> + current(sessionToken: string): Promise + signOut(sessionToken: string): Promise + revokeAll(sessionToken: string): Promise +} + +export interface AuthenticationDependencies { + store: IdentityStore + github: GitHubUserAuthClient | null + configuration: GitHubUserAuthConfiguration | null + providerRevisionId: string | null + admissionPolicy: AdmissionPolicy + now: () => Date +} diff --git a/hub/server/domains/authentication/ensure-provider-revision/index.ts b/hub/server/domains/authentication/ensure-provider-revision/index.ts new file mode 100644 index 00000000..af7cf76a --- /dev/null +++ b/hub/server/domains/authentication/ensure-provider-revision/index.ts @@ -0,0 +1,50 @@ +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { digestSecret } from "#server/utils/digest-secret" + +function storedConfiguration(configuration: GitHubUserAuthConfiguration): Record { + return { + clientId: configuration.clientId, + callbackUrl: configuration.callbackUrl, + fingerprint: digestSecret( + `${configuration.clientId}\0${configuration.clientSecret}\0${configuration.callbackUrl}`, + ), + } +} + +function matches(value: unknown, expected: Record): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + return ( + "clientId" in value && + value.clientId === expected.clientId && + "callbackUrl" in value && + value.callbackUrl === expected.callbackUrl && + "fingerprint" in value && + value.fingerprint === expected.fingerprint + ) +} + +export function ensureGitHubProviderRevision( + store: IdentityStore, + configuration: GitHubUserAuthConfiguration, +) { + return store.lock(async (records) => { + const current = await records.currentAuthenticationProviderRevision("github") + const stored = storedConfiguration(configuration) + if ( + current?.authenticationProvider.type === "github" && + current.issuer === configuration.issuer && + matches(current.configuration, stored) + ) { + return current + } + return records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: (current?.revision ?? 0) + 1, + issuer: configuration.issuer, + configuration: stored, + }) + }) +} diff --git a/hub/server/domains/authentication/get-current-session/index.ts b/hub/server/domains/authentication/get-current-session/index.ts new file mode 100644 index 00000000..933104a6 --- /dev/null +++ b/hub/server/domains/authentication/get-current-session/index.ts @@ -0,0 +1,17 @@ +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" + +export async function getCurrentSession( + dependencies: AuthenticationDependencies, + sessionToken: string, + records: IdentityRecords = dependencies.store.read, +) { + const session = sessionToken ? await records.session(digestSecret(sessionToken)) : null + if (!session) { + throw new AuthenticationError(401, "sign_in_required", "Sign in to continue.") + } + const membership = await records.membership(session.userId) + return { ...session, membership } +} diff --git a/hub/server/domains/authentication/revoke-sessions/index.ts b/hub/server/domains/authentication/revoke-sessions/index.ts new file mode 100644 index 00000000..4363e2eb --- /dev/null +++ b/hub/server/domains/authentication/revoke-sessions/index.ts @@ -0,0 +1,36 @@ +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { AuthenticationError } from "#server/utils/authentication-error" + +const freshAuthorizationMilliseconds = 10 * 60_000 + +export async function revokeSessions( + dependencies: AuthenticationDependencies, + sessionToken: string, +): Promise { + const { now, store } = dependencies + await store.lock(async (records) => { + const current = await getCurrentSession(dependencies, sessionToken, records) + if (current.membership?.status !== "active" || current.membership.role !== "administrator") { + throw new AuthenticationError(403, "administrator_required", "Administrator access required.") + } + if ( + !current.freshAuthorizedAt || + now().getTime() - current.freshAuthorizedAt.getTime() > freshAuthorizationMilliseconds + ) { + throw new AuthenticationError( + 403, + "fresh_authorization_required", + "Fresh authorization required.", + ) + } + await records.advanceSessionGeneration() + await records.deleteSessions() + await records.audit({ + event: "session.revoke_all", + actorUserId: current.userId, + sessionId: current.id, + authenticationProviderRevisionId: current.authenticationProviderRevisionId, + }) + }) +} diff --git a/hub/server/domains/authentication/sign-out/index.ts b/hub/server/domains/authentication/sign-out/index.ts new file mode 100644 index 00000000..dc4cbc4e --- /dev/null +++ b/hub/server/domains/authentication/sign-out/index.ts @@ -0,0 +1,18 @@ +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" + +export async function signOut( + dependencies: AuthenticationDependencies, + sessionToken: string, +): Promise { + await dependencies.store.lock(async (records) => { + const current = await getCurrentSession(dependencies, sessionToken, records) + await records.deleteSession(current.id) + await records.audit({ + event: "session.sign_out", + actorUserId: current.userId, + sessionId: current.id, + authenticationProviderRevisionId: current.authenticationProviderRevisionId, + }) + }) +} diff --git a/hub/server/domains/authentication/start-github-authorization/index.ts b/hub/server/domains/authentication/start-github-authorization/index.ts new file mode 100644 index 00000000..afde2351 --- /dev/null +++ b/hub/server/domains/authentication/start-github-authorization/index.ts @@ -0,0 +1,57 @@ +import { randomBytes } from "node:crypto" + +import type { + AuthenticationDependencies, + AuthorizationPurpose, +} from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" + +export async function startGitHubAuthorization( + dependencies: AuthenticationDependencies, + purpose: AuthorizationPurpose, + sessionToken?: string, +) { + const { configuration, github, now, providerRevisionId, store } = dependencies + if (!configuration || !github || !providerRevisionId) { + throw new AuthenticationError( + 503, + "authentication_unconfigured", + "GitHub authentication is not configured.", + ) + } + const state = randomBytes(32).toString("base64url") + const browserToken = randomBytes(32).toString("base64url") + const pkceVerifier = randomBytes(32).toString("base64url") + await store.lock(async (records) => { + const provider = await records.currentAuthenticationProviderRevision("github") + if (provider?.id !== providerRevisionId) { + throw new AuthenticationError( + 409, + "provider_changed", + "GitHub authentication changed. Start again.", + ) + } + const session = + purpose === "verify" + ? await getCurrentSession(dependencies, sessionToken ?? "", records) + : null + const stateRecord = await records.identityStoreState() + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: providerRevisionId, + originatingSessionId: session?.id ?? null, + stateDigest: digestSecret(state), + browserDigest: digestSecret(browserToken), + pkceVerifier, + nonce: null, + purpose, + sessionGeneration: stateRecord.sessionGeneration, + expiresAt: new Date(now().getTime() + 10 * 60_000), + }) + }) + return { + authorizationUrl: github.authorizationUrl(state, pkceVerifier), + browserToken, + } +} diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index 82411d9c..451ae87d 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -1,4 +1,6 @@ import { createAuthenticationProviderRevision } from "#server/repositories/authentication-providers/create-provider-revision" +import { getCurrentAuthenticationProviderRevision } from "#server/repositories/authentication-providers/get-current-revision" +import { getAuthenticationProviderRevision } from "#server/repositories/authentication-providers/get-revision" import { consumeAuthorizationAttempt } from "#server/repositories/authorization-attempts/consume-attempt" import { createAuthorizationAttempt } from "#server/repositories/authorization-attempts/create-attempt" import { getExternalIdentity } from "#server/repositories/external-identities/get-external-identity" @@ -6,6 +8,8 @@ import { saveExternalIdentity } from "#server/repositories/external-identities/s import { advanceGitHubIntegrationGeneration } from "#server/repositories/github-integration-state/advance-generation" import { getGitHubIntegrationState } from "#server/repositories/github-integration-state/get-state" import { saveGitHubIntegrationCredentials } from "#server/repositories/github-integration-state/save-credentials" +import { advanceSessionGeneration } from "#server/repositories/identity-store-state/advance-session-generation" +import { getIdentityStoreState } from "#server/repositories/identity-store-state/get-state" import { getInstallationMembership } from "#server/repositories/installation-memberships/get-membership" import { saveInstallationMembership } from "#server/repositories/installation-memberships/save-membership" import { getSecurityAudits } from "#server/repositories/security-audits/get-audits" @@ -19,22 +23,31 @@ import { invalidateFreshness } from "#server/repositories/sessions/invalidate-fr import { verifySession } from "#server/repositories/sessions/verify-session" import { createUser } from "#server/repositories/users/create-user" import { getUser } from "#server/repositories/users/get-user" +import { updateUser } from "#server/repositories/users/update-user" import type { IdentityRecords } from "#server/domains/identity/create-store/types" import type { Prisma } from "../../../generated/prisma/client.ts" export function createIdentityRecords(database: Prisma.TransactionClient): IdentityRecords { return { + identityStoreState: getIdentityStoreState.bind(null, database), + advanceSessionGeneration: advanceSessionGeneration.bind(null, database), githubIntegrationState: getGitHubIntegrationState.bind(null, database), saveGitHubIntegrationCredentials: saveGitHubIntegrationCredentials.bind(null, database), advanceGitHubIntegrationGeneration: advanceGitHubIntegrationGeneration.bind(null, database), user: getUser.bind(null, database), createUser: createUser.bind(null, database), + updateUser: updateUser.bind(null, database), externalIdentity: getExternalIdentity.bind(null, database), saveExternalIdentity: saveExternalIdentity.bind(null, database), membership: getInstallationMembership.bind(null, database), saveMembership: saveInstallationMembership.bind(null, database), createAuthenticationProviderRevision: createAuthenticationProviderRevision.bind(null, database), + currentAuthenticationProviderRevision: getCurrentAuthenticationProviderRevision.bind( + null, + database, + ), + authenticationProviderRevision: getAuthenticationProviderRevision.bind(null, database), session: getSession.bind(null, database), sessionById: getSessionById.bind(null, database), createSession: createSession.bind(null, database), diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 378667d2..7ffb9d19 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -12,6 +12,7 @@ import type { import type { AuditInput } from "#server/repositories/security-audits/types" import type { HubSession } from "#server/repositories/sessions/types" import type { HubUser, UserInput } from "#server/repositories/users/types" +import type { IdentityStoreState } from "#server/repositories/identity-store-state/types" export interface AuthenticationProviderRevisionRecord { id: string @@ -34,11 +35,14 @@ export interface SessionContext extends HubSession { } export interface IdentityRecords { + identityStoreState(): Promise + advanceSessionGeneration(): Promise githubIntegrationState(): Promise saveGitHubIntegrationCredentials(encryptedCredentials: string): Promise advanceGitHubIntegrationGeneration(): Promise user(id: string): Promise createUser(input: UserInput): Promise + updateUser(id: string, input: UserInput): Promise externalIdentity( provider: string, issuer: string, @@ -52,6 +56,10 @@ export interface IdentityRecords { createAuthenticationProviderRevision( input: AuthenticationProviderInput, ): Promise + currentAuthenticationProviderRevision( + key: string, + ): Promise + authenticationProviderRevision(id: string): Promise session(tokenDigest: string): Promise sessionById(id: string): Promise createSession(input: Omit): Promise @@ -59,7 +67,11 @@ export interface IdentityRecords { invalidateFreshness(): Promise deleteSession(id: string): Promise deleteSessions(): Promise - createAuthorizationAttempt(input: Omit): Promise + createAuthorizationAttempt( + input: Omit & { + sessionGeneration?: number + }, + ): Promise consumeAuthorizationAttempt(stateDigest: string): Promise audit(input: AuditInput): Promise audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> diff --git a/hub/server/index.ts b/hub/server/index.ts index 64bafe13..af5b0cbc 100644 --- a/hub/server/index.ts +++ b/hub/server/index.ts @@ -1,6 +1,7 @@ import "dotenv/config" import { createApp } from "./create-app/index.ts" +import { createAuthenticationRuntime } from "./domains/authentication/create-runtime/index.ts" import { log } from "./integrations/pino/log/index.ts" const port = Number(process.env.PORT ?? 3001) @@ -8,6 +9,8 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("PORT must be an integer between 1 and 65535.") } -createApp().listen(port, "127.0.0.1", () => { +const authentication = await createAuthenticationRuntime(process.env) + +createApp({ authentication }).listen(port, "127.0.0.1", () => { log("info", "Hub API listening", { host: "127.0.0.1", port }) }) diff --git a/hub/server/integrations/express/create-rate-limit/index.ts b/hub/server/integrations/express/create-rate-limit/index.ts new file mode 100644 index 00000000..5170ffd2 --- /dev/null +++ b/hub/server/integrations/express/create-rate-limit/index.ts @@ -0,0 +1,37 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +export function createRateLimit({ + limit, + windowMilliseconds, + maxEntries = 10_000, +}: { + limit: number + windowMilliseconds: number + maxEntries?: number +}) { + const entries = new Map() + + return function rateLimit(request: Request, _response: Response, next: NextFunction): void { + const now = Date.now() + const key = request.ip || request.socket.remoteAddress || "unknown" + for (const [entryKey, value] of entries) { + if (value.resetAt <= now) entries.delete(entryKey) + } + if (!entries.has(key) && entries.size >= maxEntries) { + const oldest = entries.keys().next().value + if (oldest) entries.delete(oldest) + } + const current = entries.get(key) + const entry = + !current || current.resetAt <= now ? { count: 0, resetAt: now + windowMilliseconds } : current + entry.count++ + entries.set(key, entry) + if (entry.count > limit) { + next(new AuthenticationError(429, "rate_limited", "Too many authentication requests.")) + return + } + next() + } +} diff --git a/hub/server/integrations/express/prepare-authentication-request/index.ts b/hub/server/integrations/express/prepare-authentication-request/index.ts new file mode 100644 index 00000000..d5f45fd7 --- /dev/null +++ b/hub/server/integrations/express/prepare-authentication-request/index.ts @@ -0,0 +1,21 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +export function prepareAuthenticationRequest( + expectedOrigin: string, + request: Request, + response: Response, + next: NextFunction, +): void { + response.set("Cache-Control", "private, no-store") + response.set("Referrer-Policy", "no-referrer") + if ( + !["GET", "HEAD", "OPTIONS"].includes(request.method) && + request.get("origin") !== expectedOrigin + ) { + next(new AuthenticationError(403, "origin_failed", "The request origin did not match.")) + return + } + next() +} diff --git a/hub/server/integrations/express/respond-authentication-error/index.ts b/hub/server/integrations/express/respond-authentication-error/index.ts new file mode 100644 index 00000000..778502c8 --- /dev/null +++ b/hub/server/integrations/express/respond-authentication-error/index.ts @@ -0,0 +1,26 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +export function respondAuthenticationError( + error: unknown, + _request: Request, + response: Response, + next: NextFunction, +): void { + if (!(error instanceof AuthenticationError)) { + next(error) + return + } + const message = + error.status === 401 + ? "Authentication required" + : error.status === 403 + ? "Request not allowed" + : error.status === 429 + ? "Too many requests" + : error.status >= 500 + ? "Authentication unavailable" + : "Authentication failed" + response.status(error.status).json({ error: message }) +} diff --git a/hub/server/integrations/github/check-repository-admission/constants.ts b/hub/server/integrations/github/check-repository-admission/constants.ts new file mode 100644 index 00000000..45291b89 --- /dev/null +++ b/hub/server/integrations/github/check-repository-admission/constants.ts @@ -0,0 +1,10 @@ +import { z } from "zod" + +export const installedRepositoriesSchema = z.object({ + repositories: z.array(z.object({ full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/) })), +}) + +export const collaboratorPermissionSchema = z.object({ + user: z.object({ id: z.number().int().positive() }), + permission: z.enum(["admin", "maintain", "write", "triage", "read", "none"]), +}) diff --git a/hub/server/integrations/github/check-repository-admission/index.ts b/hub/server/integrations/github/check-repository-admission/index.ts new file mode 100644 index 00000000..014c4ea1 --- /dev/null +++ b/hub/server/integrations/github/check-repository-admission/index.ts @@ -0,0 +1,57 @@ +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import { createInstallationToken } from "#server/integrations/github/create-installation-token" +import { + collaboratorPermissionSchema, + installedRepositoriesSchema, +} from "#server/integrations/github/check-repository-admission/constants" +import { GitHubApiError } from "#server/integrations/github/github-api-error" +import { requestGitHubApi } from "#server/integrations/github/request-github-api" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import { AuthenticationError } from "#server/utils/authentication-error" + +async function hasPermission( + token: string, + profile: GitHubUserProfile, + repository: string, +): Promise { + let response: unknown + try { + response = await requestGitHubApi( + `/repos/${repository}/collaborators/${encodeURIComponent(profile.username)}/permission`, + token, + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return false + throw error + } + const permission = collaboratorPermissionSchema.parse(response) + if (String(permission.user.id) !== profile.subject) { + throw new AuthenticationError( + 403, + "identity_mismatch", + "The repository permission identity did not match.", + ) + } + return permission.permission !== "none" +} + +export async function checkRepositoryAdmission( + credentials: GitHubCredentials, + profile: GitHubUserProfile, +): Promise { + const token = await createInstallationToken(credentials) + for (let page = 1; page <= 100; page++) { + const response = installedRepositoriesSchema.parse( + await requestGitHubApi(`/installation/repositories?per_page=100&page=${page}`, token), + ) + for (const repository of response.repositories) { + if (await hasPermission(token, profile, repository.full_name)) return true + } + if (response.repositories.length < 100) return false + } + throw new AuthenticationError( + 503, + "repository_limit", + "Repository admission exceeded the supported page limit.", + ) +} diff --git a/hub/server/integrations/github/create-app-token/index.ts b/hub/server/integrations/github/create-app-token/index.ts new file mode 100644 index 00000000..8423e0c8 --- /dev/null +++ b/hub/server/integrations/github/create-app-token/index.ts @@ -0,0 +1,16 @@ +import { sign } from "node:crypto" + +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function createAppToken(credentials: GitHubCredentials): string { + const now = Math.floor(Date.now() / 1000) + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url") + const payload = Buffer.from( + JSON.stringify({ iat: now - 60, exp: now + 540, iss: credentials.appId }), + ).toString("base64url") + const unsigned = `${header}.${payload}` + const signature = sign("RSA-SHA256", Buffer.from(unsigned), credentials.privateKey).toString( + "base64url", + ) + return `${unsigned}.${signature}` +} diff --git a/hub/server/integrations/github/create-installation-token/constants.ts b/hub/server/integrations/github/create-installation-token/constants.ts new file mode 100644 index 00000000..1c01e5df --- /dev/null +++ b/hub/server/integrations/github/create-installation-token/constants.ts @@ -0,0 +1,3 @@ +import { z } from "zod" + +export const installationTokenSchema = z.object({ token: z.string().min(1) }) diff --git a/hub/server/integrations/github/create-installation-token/index.ts b/hub/server/integrations/github/create-installation-token/index.ts new file mode 100644 index 00000000..f7c36b28 --- /dev/null +++ b/hub/server/integrations/github/create-installation-token/index.ts @@ -0,0 +1,13 @@ +import { createAppToken } from "#server/integrations/github/create-app-token" +import { installationTokenSchema } from "#server/integrations/github/create-installation-token/constants" +import { requestGitHubApi } from "#server/integrations/github/request-github-api" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export async function createInstallationToken(credentials: GitHubCredentials): Promise { + const response = await requestGitHubApi( + `/app/installations/${credentials.installationId}/access_tokens`, + createAppToken(credentials), + "POST", + ) + return installationTokenSchema.parse(response).token +} diff --git a/hub/server/integrations/github/create-user-auth-client/index.test.ts b/hub/server/integrations/github/create-user-auth-client/index.test.ts new file mode 100644 index 00000000..85fb1e34 --- /dev/null +++ b/hub/server/integrations/github/create-user-auth-client/index.test.ts @@ -0,0 +1,153 @@ +import { createHash } from "node:crypto" +import { describe, expect, it, vi } from "vitest" + +import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" + +const configuration = { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com" as const, +} + +describe("createGitHubUserAuthClient()", () => { + it("builds a state-bound S256 authorization URL without repository scopes", () => { + const client = createGitHubUserAuthClient(configuration) + const verifier = "pkce-verifier-fixture" + + const url = new URL(client.authorizationUrl("state-fixture", verifier)) + + expect(url.origin + url.pathname).toBe("https://github.com/login/oauth/authorize") + expect(Object.fromEntries(url.searchParams)).toEqual({ + client_id: configuration.clientId, + redirect_uri: configuration.callbackUrl, + state: "state-fixture", + code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + }) + expect(url.searchParams.has("scope")).toBe(false) + }) + + it("keeps the user access token transient while returning the immutable GitHub subject", async () => { + const accessToken = "github-user-access-token-fixture" + const requests: Array<{ input: string; init?: RequestInit }> = [] + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request, init?: RequestInit) => { + const requestUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url + requests.push({ input: requestUrl, init }) + if (requests.length === 1) { + return Promise.resolve( + new Response(JSON.stringify({ access_token: accessToken, token_type: "bearer" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + } + return Promise.resolve( + new Response( + JSON.stringify({ + id: 42, + login: "octocat", + name: "The Octocat", + avatar_url: "https://avatars.githubusercontent.com/u/42", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + }), + ) + const client = createGitHubUserAuthClient(configuration) + + const profile = await client.identify("one-use-code", "pkce-verifier") + + expect(profile).toEqual({ + subject: "42", + username: "octocat", + displayName: "The Octocat", + avatarUrl: "https://avatars.githubusercontent.com/u/42", + }) + const tokenRequest = requests[0] + const userRequest = requests[1] + expect(tokenRequest).toBeDefined() + expect(userRequest).toBeDefined() + expect(tokenRequest!).toMatchObject({ + input: "https://github.com/login/oauth/access_token", + init: { + method: "POST", + redirect: "error", + }, + }) + const tokenBody = tokenRequest!.init?.body + if (typeof tokenBody !== "string") throw new Error("Expected a JSON token request body") + expect(JSON.parse(tokenBody) as unknown).toMatchObject({ + code: "one-use-code", + code_verifier: "pkce-verifier", + redirect_uri: configuration.callbackUrl, + }) + expect(userRequest!).toMatchObject({ + input: "https://api.github.com/user", + init: { redirect: "error" }, + }) + expect(new Headers(userRequest!.init?.headers).get("authorization")).toBe( + `Bearer ${accessToken}`, + ) + expect(JSON.stringify(profile)).not.toContain(accessToken) + }) + + it("rejects malformed token and profile responses instead of constructing a partial identity", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: 7 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ) + const client = createGitHubUserAuthClient(configuration) + + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 401, + code: "github_denied", + }) + + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "token", token_type: "bearer" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: "mutable-login", login: "octocat" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 503, + code: "github_invalid_response", + }) + }) + + it("maps network and non-success responses to generic availability failures", async () => { + const client = createGitHubUserAuthClient(configuration) + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("token leaked in transport error"))) + + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 503, + code: "github_unavailable", + message: "GitHub is unavailable.", + }) + + vi.mocked(fetch).mockResolvedValue(new Response("private upstream failure", { status: 502 })) + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 503, + code: "github_unavailable", + message: "GitHub is unavailable.", + }) + }) +}) diff --git a/hub/server/integrations/github/create-user-auth-client/index.ts b/hub/server/integrations/github/create-user-auth-client/index.ts new file mode 100644 index 00000000..fe323255 --- /dev/null +++ b/hub/server/integrations/github/create-user-auth-client/index.ts @@ -0,0 +1,17 @@ +import { createUserAuthorizationUrl } from "#server/integrations/github/create-user-authorization-url" +import type { + GitHubUserAuthClient, + GitHubUserAuthConfiguration, +} from "#server/integrations/github/create-user-auth-client/types" +import { exchangeUserAuthorization } from "#server/integrations/github/exchange-user-authorization" +import { requestGitHubUser } from "#server/integrations/github/request-github-user" + +export function createGitHubUserAuthClient( + configuration: GitHubUserAuthConfiguration, +): GitHubUserAuthClient { + return { + authorizationUrl: createUserAuthorizationUrl.bind(null, configuration), + identify: async (code, pkceVerifier) => + requestGitHubUser(await exchangeUserAuthorization(configuration, code, pkceVerifier)), + } +} diff --git a/hub/server/integrations/github/create-user-auth-client/types.ts b/hub/server/integrations/github/create-user-auth-client/types.ts new file mode 100644 index 00000000..f47e0447 --- /dev/null +++ b/hub/server/integrations/github/create-user-auth-client/types.ts @@ -0,0 +1,18 @@ +export interface GitHubUserAuthConfiguration { + clientId: string + clientSecret: string + callbackUrl: string + issuer: "https://github.com" +} + +export interface GitHubUserProfile { + subject: string + username: string + displayName: string | null + avatarUrl: string | null +} + +export interface GitHubUserAuthClient { + authorizationUrl(state: string, pkceVerifier: string): string + identify(code: string, pkceVerifier: string): Promise +} diff --git a/hub/server/integrations/github/create-user-authorization-url/index.ts b/hub/server/integrations/github/create-user-authorization-url/index.ts new file mode 100644 index 00000000..80010614 --- /dev/null +++ b/hub/server/integrations/github/create-user-authorization-url/index.ts @@ -0,0 +1,19 @@ +import { createHash } from "node:crypto" + +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" + +export function createUserAuthorizationUrl( + configuration: GitHubUserAuthConfiguration, + state: string, + pkceVerifier: string, +): string { + const url = new URL("https://github.com/login/oauth/authorize") + url.search = new URLSearchParams({ + client_id: configuration.clientId, + redirect_uri: configuration.callbackUrl, + state, + code_challenge: createHash("sha256").update(pkceVerifier).digest("base64url"), + code_challenge_method: "S256", + }).toString() + return url.href +} diff --git a/hub/server/integrations/github/exchange-user-authorization/constants.ts b/hub/server/integrations/github/exchange-user-authorization/constants.ts new file mode 100644 index 00000000..e40d8a1f --- /dev/null +++ b/hub/server/integrations/github/exchange-user-authorization/constants.ts @@ -0,0 +1,6 @@ +import { z } from "zod" + +export const accessTokenSchema = z.object({ + access_token: z.string().min(1), + token_type: z.literal("bearer"), +}) diff --git a/hub/server/integrations/github/exchange-user-authorization/index.ts b/hub/server/integrations/github/exchange-user-authorization/index.ts new file mode 100644 index 00000000..0271af76 --- /dev/null +++ b/hub/server/integrations/github/exchange-user-authorization/index.ts @@ -0,0 +1,42 @@ +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { accessTokenSchema } from "#server/integrations/github/exchange-user-authorization/constants" +import { AuthenticationError } from "#server/utils/authentication-error" + +export async function exchangeUserAuthorization( + configuration: GitHubUserAuthConfiguration, + code: string, + pkceVerifier: string, +): Promise { + let response: Response + try { + response = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: configuration.clientId, + client_secret: configuration.clientSecret, + code, + code_verifier: pkceVerifier, + redirect_uri: configuration.callbackUrl, + }), + signal: AbortSignal.timeout(10_000), + redirect: "error", + }) + } catch { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + if (!response.ok) { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + let body: unknown + try { + body = await response.json() + } catch { + throw new AuthenticationError(503, "github_invalid_response", "GitHub returned invalid data.") + } + const parsed = accessTokenSchema.safeParse(body) + if (!parsed.success) { + throw new AuthenticationError(401, "github_denied", "GitHub denied authorization.") + } + return parsed.data.access_token +} diff --git a/hub/server/integrations/github/github-api-error/index.ts b/hub/server/integrations/github/github-api-error/index.ts new file mode 100644 index 00000000..cf23cbc9 --- /dev/null +++ b/hub/server/integrations/github/github-api-error/index.ts @@ -0,0 +1,5 @@ +export class GitHubApiError extends Error { + constructor(readonly status: number) { + super("GitHub API request failed.") + } +} diff --git a/hub/server/integrations/github/request-github-api/index.ts b/hub/server/integrations/github/request-github-api/index.ts new file mode 100644 index 00000000..8d8eece3 --- /dev/null +++ b/hub/server/integrations/github/request-github-api/index.ts @@ -0,0 +1,25 @@ +import { GitHubApiError } from "#server/integrations/github/github-api-error" + +export async function requestGitHubApi( + path: string, + token: string, + method = "GET", +): Promise { + let response: Response + try { + response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(10_000), + redirect: "error", + }) + } catch { + throw new GitHubApiError(0) + } + if (!response.ok) throw new GitHubApiError(response.status) + return response.json() +} diff --git a/hub/server/integrations/github/request-github-user/constants.ts b/hub/server/integrations/github/request-github-user/constants.ts new file mode 100644 index 00000000..7b5e7b7b --- /dev/null +++ b/hub/server/integrations/github/request-github-user/constants.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const githubUserSchema = z.object({ + id: z.number().int().positive(), + login: z.string().min(1).max(255), + name: z.string().max(255).nullable(), + avatar_url: z.url().nullable(), +}) diff --git a/hub/server/integrations/github/request-github-user/index.ts b/hub/server/integrations/github/request-github-user/index.ts new file mode 100644 index 00000000..104afe4a --- /dev/null +++ b/hub/server/integrations/github/request-github-user/index.ts @@ -0,0 +1,39 @@ +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import { githubUserSchema } from "#server/integrations/github/request-github-user/constants" +import { AuthenticationError } from "#server/utils/authentication-error" + +export async function requestGitHubUser(accessToken: string): Promise { + let response: Response + try { + response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(10_000), + redirect: "error", + }) + } catch { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + if (!response.ok) { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + let body: unknown + try { + body = await response.json() + } catch { + throw new AuthenticationError(503, "github_invalid_response", "GitHub returned invalid data.") + } + const parsed = githubUserSchema.safeParse(body) + if (!parsed.success) { + throw new AuthenticationError(503, "github_invalid_response", "GitHub returned invalid data.") + } + return { + subject: String(parsed.data.id), + username: parsed.data.login, + displayName: parsed.data.name, + avatarUrl: parsed.data.avatar_url, + } +} diff --git a/hub/server/repositories/authentication-providers/get-current-revision/index.ts b/hub/server/repositories/authentication-providers/get-current-revision/index.ts new file mode 100644 index 00000000..6d202814 --- /dev/null +++ b/hub/server/repositories/authentication-providers/get-current-revision/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getCurrentAuthenticationProviderRevision( + database: Prisma.TransactionClient, + key: string, +) { + return database.authenticationProviderRevision.findFirst({ + where: { authenticationProvider: { key, enabled: true } }, + orderBy: { revision: "desc" }, + include: { authenticationProvider: true }, + }) +} diff --git a/hub/server/repositories/authentication-providers/get-revision/index.ts b/hub/server/repositories/authentication-providers/get-revision/index.ts new file mode 100644 index 00000000..6abb080a --- /dev/null +++ b/hub/server/repositories/authentication-providers/get-revision/index.ts @@ -0,0 +1,8 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getAuthenticationProviderRevision(database: Prisma.TransactionClient, id: string) { + return database.authenticationProviderRevision.findUnique({ + where: { id }, + include: { authenticationProvider: true }, + }) +} diff --git a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts index 669ddec7..638dab02 100644 --- a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts +++ b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts @@ -22,6 +22,7 @@ export async function consumeAuthorizationAttempt( pkce_verifier AS "pkceVerifier", nonce, purpose, + session_generation AS "sessionGeneration", expires_at AS "expiresAt", expires_at > clock_timestamp() AS valid ` @@ -43,6 +44,7 @@ export async function consumeAuthorizationAttempt( pkceVerifier: attempt.pkceVerifier, nonce: attempt.nonce, purpose: attempt.purpose, + sessionGeneration: attempt.sessionGeneration, expiresAt: attempt.expiresAt, } } diff --git a/hub/server/repositories/authorization-attempts/create-attempt/index.ts b/hub/server/repositories/authorization-attempts/create-attempt/index.ts index 9660c8cf..cab6fc09 100644 --- a/hub/server/repositories/authorization-attempts/create-attempt/index.ts +++ b/hub/server/repositories/authorization-attempts/create-attempt/index.ts @@ -4,7 +4,9 @@ import type { Prisma } from "../../../generated/prisma/client.ts" export async function createAuthorizationAttempt( database: Prisma.TransactionClient, - data: Omit, + data: Omit & { + sessionGeneration?: number + }, ) { await database.authorizationAttempt.deleteMany({ where: { expiresAt: { lte: new Date() } } }) await database.authorizationAttempt.create({ data }) diff --git a/hub/server/repositories/authorization-attempts/types.ts b/hub/server/repositories/authorization-attempts/types.ts index 3687dc29..54e18768 100644 --- a/hub/server/repositories/authorization-attempts/types.ts +++ b/hub/server/repositories/authorization-attempts/types.ts @@ -7,5 +7,6 @@ export interface AuthorizationAttempt { pkceVerifier: string nonce: string | null purpose: "sign-in" | "verify" | "link-identity" + sessionGeneration: number expiresAt: Date } diff --git a/hub/server/repositories/identity-store-state/advance-session-generation/index.ts b/hub/server/repositories/identity-store-state/advance-session-generation/index.ts new file mode 100644 index 00000000..c4be0fba --- /dev/null +++ b/hub/server/repositories/identity-store-state/advance-session-generation/index.ts @@ -0,0 +1,10 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function advanceSessionGeneration(database: Prisma.TransactionClient) { + await database.identityStoreState.update({ + where: { id: staticIdentityStoreRecordId }, + data: { sessionGeneration: { increment: 1 } }, + }) +} diff --git a/hub/server/repositories/identity-store-state/get-state/index.ts b/hub/server/repositories/identity-store-state/get-state/index.ts new file mode 100644 index 00000000..2633c9ca --- /dev/null +++ b/hub/server/repositories/identity-store-state/get-state/index.ts @@ -0,0 +1,10 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function getIdentityStoreState(database: Prisma.TransactionClient) { + return database.identityStoreState.findUniqueOrThrow({ + where: { id: staticIdentityStoreRecordId }, + select: { sessionGeneration: true }, + }) +} diff --git a/hub/server/repositories/identity-store-state/types.ts b/hub/server/repositories/identity-store-state/types.ts new file mode 100644 index 00000000..fb896ced --- /dev/null +++ b/hub/server/repositories/identity-store-state/types.ts @@ -0,0 +1,3 @@ +export interface IdentityStoreState { + sessionGeneration: number +} diff --git a/hub/server/repositories/users/update-user/index.ts b/hub/server/repositories/users/update-user/index.ts new file mode 100644 index 00000000..81b5e698 --- /dev/null +++ b/hub/server/repositories/users/update-user/index.ts @@ -0,0 +1,7 @@ +import type { UserInput } from "#server/repositories/users/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function updateUser(database: Prisma.TransactionClient, id: string, input: UserInput) { + return database.user.update({ where: { id }, data: input }) +} diff --git a/hub/server/routes/api/auth/github/callback/get/index.ts b/hub/server/routes/api/auth/github/callback/get/index.ts new file mode 100644 index 00000000..6e65d07c --- /dev/null +++ b/hub/server/routes/api/auth/github/callback/get/index.ts @@ -0,0 +1,49 @@ +import { randomBytes } from "node:crypto" +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { AuthenticationError } from "#server/utils/authentication-error" +import { readCookie } from "#server/utils/read-cookie" + +function validCallbackValue(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 1_000 +} + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + const failureUrl = `${authentication.settings.origin}/?authentication=failed` + response.clearCookie(cookies.authorization, cookies.privateOptions) + const { code, state } = request.query + if ( + request.query.error !== undefined || + !validCallbackValue(code) || + !validCallbackValue(state) || + !/^[\w-]{43}$/.test(state) + ) { + response.redirect(303, failureUrl) + return + } + try { + const result = await authentication.service.complete( + code, + state, + readCookie(request.headers.cookie, cookies.authorization), + ) + if (result.sessionToken) { + response.cookie(cookies.session, result.sessionToken, cookies.privateOptions) + } + response.cookie(cookies.csrf, randomBytes(32).toString("base64url"), cookies.csrfOptions) + response.redirect(303, `${authentication.settings.origin}/?authentication=complete`) + } catch (error) { + if (error instanceof AuthenticationError) { + response.redirect(303, failureUrl) + return + } + throw error + } +} diff --git a/hub/server/routes/api/auth/github/post/index.ts b/hub/server/routes/api/auth/github/post/index.ts new file mode 100644 index 00000000..dc98fd36 --- /dev/null +++ b/hub/server/routes/api/auth/github/post/index.ts @@ -0,0 +1,24 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const attempt = await authentication.service.start("sign-in") + response.cookie(cookies.authorization, attempt.browserToken, { + ...cookies.privateOptions, + maxAge: 10 * 60_000, + }) + response.json({ authorizationUrl: attempt.authorizationUrl }) +} diff --git a/hub/server/routes/api/session/delete/index.ts b/hub/server/routes/api/session/delete/index.ts new file mode 100644 index 00000000..bdc2c946 --- /dev/null +++ b/hub/server/routes/api/session/delete/index.ts @@ -0,0 +1,26 @@ +import { randomBytes } from "node:crypto" +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +async function deleteSession( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + await authentication.service.signOut(readCookie(request.headers.cookie, cookies.session)) + response.clearCookie(cookies.session, cookies.privateOptions) + response.clearCookie(cookies.authorization, cookies.privateOptions) + response.cookie(cookies.csrf, randomBytes(32).toString("base64url"), cookies.csrfOptions) + response.status(204).end() +} + +export { deleteSession as delete } diff --git a/hub/server/routes/api/session/get/index.ts b/hub/server/routes/api/session/get/index.ts new file mode 100644 index 00000000..3e6cde1b --- /dev/null +++ b/hub/server/routes/api/session/get/index.ts @@ -0,0 +1,50 @@ +import { randomBytes } from "node:crypto" +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { AuthenticationError } from "#server/utils/authentication-error" +import { readCookie } from "#server/utils/read-cookie" + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + let csrfToken = readCookie(request.headers.cookie, cookies.csrf) + if (!csrfToken) { + csrfToken = randomBytes(32).toString("base64url") + response.cookie(cookies.csrf, csrfToken, cookies.csrfOptions) + } + const sessionToken = readCookie(request.headers.cookie, cookies.session) + try { + const current = await authentication.service.current(sessionToken) + response.json({ + authenticated: true, + configured: authentication.settings.configured, + admissionPolicy: authentication.settings.admissionPolicy, + csrfToken, + user: { + id: current.user.id, + displayName: current.user.displayName, + avatarUrl: current.user.avatarUrl, + provider: current.externalIdentity.provider, + username: current.externalIdentity.username, + }, + session: { + id: current.id, + signedInAt: current.signedInAt, + freshAuthorizedAt: current.freshAuthorizedAt, + }, + }) + } catch (error) { + if (!(error instanceof AuthenticationError) || error.status !== 401) throw error + response.json({ + authenticated: false, + configured: authentication.settings.configured, + admissionPolicy: authentication.settings.admissionPolicy, + csrfToken, + }) + } +} diff --git a/hub/server/routes/api/session/revoke-all/post/index.ts b/hub/server/routes/api/session/revoke-all/post/index.ts new file mode 100644 index 00000000..6736ae19 --- /dev/null +++ b/hub/server/routes/api/session/revoke-all/post/index.ts @@ -0,0 +1,21 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + await authentication.service.revokeAll(readCookie(request.headers.cookie, cookies.session)) + response.clearCookie(cookies.session, cookies.privateOptions) + response.status(204).end() +} diff --git a/hub/server/routes/api/session/verify/post/index.ts b/hub/server/routes/api/session/verify/post/index.ts new file mode 100644 index 00000000..fac5b672 --- /dev/null +++ b/hub/server/routes/api/session/verify/post/index.ts @@ -0,0 +1,27 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const attempt = await authentication.service.start( + "verify", + readCookie(request.headers.cookie, cookies.session), + ) + response.cookie(cookies.authorization, attempt.browserToken, { + ...cookies.privateOptions, + maxAge: 10 * 60_000, + }) + response.json({ authorizationUrl: attempt.authorizationUrl }) +} diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts new file mode 100644 index 00000000..41e4c5a5 --- /dev/null +++ b/hub/server/routes/authentication.test.ts @@ -0,0 +1,272 @@ +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { describe, expect, it, onTestFinished, vi } from "vitest" +import type { Mocked } from "vitest" + +import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { AuthenticationRoutes } from "#server/routes/types" +import { createLogger } from "#server/integrations/pino/create-logger" +import { AuthenticationError } from "#server/utils/authentication-error" + +import { createApp } from "../create-app/index.ts" + +function authentication( + overrides: Partial> = {}, + secureCookies = true, +) { + const service = Object.assign( + { + start: vi.fn().mockResolvedValue({ + authorizationUrl: "https://github.com/login/oauth/authorize?state=redacted", + browserToken: "browser-token", + }), + complete: vi.fn().mockResolvedValue({ + purpose: "sign-in", + sessionToken: "session-token", + }), + current: vi + .fn() + .mockRejectedValue( + new AuthenticationError(401, "sign_in_required", "Sign in to continue."), + ), + signOut: vi.fn().mockResolvedValue(undefined), + revokeAll: vi.fn().mockResolvedValue(undefined), + }, + overrides, + ) + return { + settings: { + origin: "https://hub.example", + secureCookies, + configured: true, + admissionPolicy: "hub-managed" as const, + }, + service, + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +async function serve(runtime: AuthenticationRoutes, logger = createLogger()) { + const server = createServer(createApp({ authentication: runtime, logger })) + server.listen(0, "127.0.0.1") + await once(server, "listening") + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + const { port } = server.address() as AddressInfo + return `http://127.0.0.1:${port}` +} + +const csrfToken = "c".repeat(43) +const sessionToken = "s".repeat(43) +const browserToken = "b".repeat(43) +const csrfHeaders = { + origin: "https://hub.example", + cookie: `__Host-replica-csrf=${csrfToken}; __Host-replica-session=${sessionToken}`, + "x-csrf-token": csrfToken, +} + +describe("authentication routes", () => { + it("sets host-bound secure cookies and omits repository scope during GitHub initiation", async () => { + const runtime = authentication({ + start: vi.fn().mockResolvedValue({ + authorizationUrl: + "https://github.com/login/oauth/authorize?client_id=client&state=state&code_challenge=challenge&code_challenge_method=S256", + browserToken, + }), + }) + const baseUrl = await serve(runtime) + + const response = await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: csrfHeaders, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + authorizationUrl: + "https://github.com/login/oauth/authorize?client_id=client&state=state&code_challenge=challenge&code_challenge_method=S256", + }) + expect(response.headers.get("set-cookie")).toMatch( + new RegExp( + `__Host-replica-authorization=${browserToken}; Max-Age=600; Path=/; Expires=[^;]+; HttpOnly; Secure; SameSite=Lax`, + "i", + ), + ) + expect(runtime.service.start).toHaveBeenCalledWith("sign-in") + }) + + it("rejects state-changing requests unless both the origin and CSRF token match", async () => { + const runtime = authentication() + const baseUrl = await serve(runtime) + + const wrongOrigin = await fetch(`${baseUrl}/api/session`, { + method: "DELETE", + headers: { ...csrfHeaders, origin: "https://attacker.example" }, + }) + expect(wrongOrigin.status).toBe(403) + expect(await wrongOrigin.json()).toEqual({ error: "Request not allowed" }) + + const wrongCsrf = await fetch(`${baseUrl}/api/session`, { + method: "DELETE", + headers: { ...csrfHeaders, "x-csrf-token": "wrong-token" }, + }) + expect(wrongCsrf.status).toBe(403) + expect(await wrongCsrf.json()).toEqual({ error: "Request not allowed" }) + expect(runtime.service.signOut).not.toHaveBeenCalled() + }) + + it("rotates browser state on sign-out and delegates revoke-all only with a protected request", async () => { + const runtime = authentication() + const baseUrl = await serve(runtime) + + const signOut = await fetch(`${baseUrl}/api/session`, { + method: "DELETE", + headers: csrfHeaders, + }) + expect(signOut.status).toBe(204) + expect(runtime.service.signOut).toHaveBeenCalledWith(sessionToken) + const signOutCookies = signOut.headers.get("set-cookie") ?? "" + expect(signOutCookies).toContain("__Host-replica-session=") + expect(signOutCookies).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT") + expect(signOutCookies).toMatch(/__Host-replica-csrf=[\w-]{43}/) + + const revokeAll = await fetch(`${baseUrl}/api/session/revoke-all`, { + method: "POST", + headers: csrfHeaders, + }) + expect(revokeAll.status).toBe(204) + expect(runtime.service.revokeAll).toHaveBeenCalledWith(sessionToken) + }) + + it("redirects callback failures generically and never logs OAuth secrets", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const runtime = authentication({ + complete: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 401, + "invalid_authorization", + "private callback failure containing a token", + ), + ), + }) + const baseUrl = await serve(runtime, logger) + const state = "s".repeat(43) + + const response = await fetch( + `${baseUrl}/api/auth/github/callback?code=private-code&state=${state}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(response.status).toBe(303) + expect(response.headers.get("location")).toBe("https://hub.example/?authentication=failed") + expect(response.headers.get("set-cookie")).toMatch( + /__Host-replica-authorization=; Path=\/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax/i, + ) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(JSON.stringify(records)).not.toMatch( + /private-code|bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb|callback failure|token/, + ) + }) + + it("allows the initiation limit and rejects the next request without calling the service", async () => { + const runtime = authentication() + const baseUrl = await serve(runtime) + + const responses = [] + for (let request = 0; request < 21; request++) { + responses.push( + await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: csrfHeaders, + }), + ) + } + + expect(responses.slice(0, 20).every(({ status }) => status === 200)).toBe(true) + const limitedResponse = responses[20] + expect(limitedResponse).toBeDefined() + expect(limitedResponse!.status).toBe(429) + expect(await limitedResponse!.json()).toEqual({ error: "Too many requests" }) + expect(runtime.service.start).toHaveBeenCalledTimes(20) + }) + + it("returns session state without exposing the session token and creates a readable CSRF cookie", async () => { + const runtime = authentication({ + current: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: "revision", + tokenDigest: "stored-digest", + signedInAt: new Date("2026-10-27T18:00:00.000Z"), + freshAuthorizedAt: null, + user: { id: "user", displayName: "The Octocat", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "octocat", + displayName: "The Octocat", + avatarUrl: null, + }, + authenticationProviderRevision: { + id: "revision", + authenticationProviderId: "provider", + revision: 1, + issuer: "https://github.com", + configuration: {}, + authenticationProvider: { + id: "provider", + key: "github", + type: "github", + enabled: true, + }, + }, + membership: null, + }), + }) + const baseUrl = await serve(runtime) + + const response = await fetch(`${baseUrl}/api/session`, { + headers: { cookie: `__Host-replica-session=${sessionToken}` }, + }) + const body: unknown = await response.json() + + expect(response.status).toBe(200) + expect(body).toMatchObject({ + authenticated: true, + configured: true, + admissionPolicy: "hub-managed", + user: { id: "user", provider: "github", username: "octocat" }, + session: { id: "session", freshAuthorizedAt: null }, + }) + expect(isRecord(body) ? body.csrfToken : undefined).toMatch(/^[\w-]{43}$/) + expect(response.headers.get("set-cookie")).toMatch( + /__Host-replica-csrf=[\w-]{43}; Path=\/; Secure; SameSite=Lax/i, + ) + expect(response.headers.get("set-cookie")).not.toContain("HttpOnly") + expect(JSON.stringify(body)).not.toContain(sessionToken) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") + }) +}) diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index e5216064..06a3d8ac 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -1,8 +1,49 @@ import type { Express } from "express" +import { createRateLimit } from "#server/integrations/express/create-rate-limit" +import { prepareAuthenticationRequest } from "#server/integrations/express/prepare-authentication-request" +import { respondAuthenticationError } from "#server/integrations/express/respond-authentication-error" +import { omitRequestLogPayload } from "#server/integrations/pino/omit-request-log-payload" +import { get as handleApiAuthGithubCallbackGet } from "#server/routes/api/auth/github/callback/get" +import { post as handleApiAuthGithubPost } from "#server/routes/api/auth/github/post" import { get as handleApiHealthGet } from "./api/health/get/index.ts" +import { delete as handleApiSessionDelete } from "#server/routes/api/session/delete" +import { get as handleApiSessionGet } from "#server/routes/api/session/get" +import { post as handleApiSessionRevokeAllPost } from "#server/routes/api/session/revoke-all/post" +import { post as handleApiSessionVerifyPost } from "#server/routes/api/session/verify/post" +import type { AuthenticationRoutes } from "#server/routes/types" /** Keep endpoint registration separate from the app's shared middleware. */ -export function routes(app: Express): void { +export function routes(app: Express, authentication?: AuthenticationRoutes): void { app.get("/api/health", handleApiHealthGet) + if (!authentication) return + + app.use( + ["/api/auth", "/api/session"], + prepareAuthenticationRequest.bind(null, authentication.settings.origin), + omitRequestLogPayload, + ) + const initiationLimit = createRateLimit({ + limit: 20, + windowMilliseconds: 5 * 60_000, + }) + const callbackLimit = createRateLimit({ + limit: 60, + windowMilliseconds: 5 * 60_000, + }) + app.post("/api/auth/github", initiationLimit, handleApiAuthGithubPost.bind(null, authentication)) + app.get( + "/api/auth/github/callback", + callbackLimit, + handleApiAuthGithubCallbackGet.bind(null, authentication), + ) + app.get("/api/session", handleApiSessionGet.bind(null, authentication)) + app.delete("/api/session", handleApiSessionDelete.bind(null, authentication)) + app.post( + "/api/session/verify", + initiationLimit, + handleApiSessionVerifyPost.bind(null, authentication), + ) + app.post("/api/session/revoke-all", handleApiSessionRevokeAllPost.bind(null, authentication)) + app.use(respondAuthenticationError) } diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts new file mode 100644 index 00000000..eadccf7d --- /dev/null +++ b/hub/server/routes/types.ts @@ -0,0 +1,9 @@ +import type { + AuthenticationService, + AuthenticationSettings, +} from "#server/domains/authentication/create-service/types" + +export interface AuthenticationRoutes { + service: AuthenticationService + settings: AuthenticationSettings +} diff --git a/hub/server/utils/authentication-cookies/index.ts b/hub/server/utils/authentication-cookies/index.ts new file mode 100644 index 00000000..9faf37e6 --- /dev/null +++ b/hub/server/utils/authentication-cookies/index.ts @@ -0,0 +1,20 @@ +export function authenticationCookies(secure: boolean) { + const prefix = secure ? "__Host-" : "" + return { + session: `${prefix}replica-session`, + authorization: `${prefix}replica-authorization`, + csrf: `${prefix}replica-csrf`, + privateOptions: { + httpOnly: true, + secure, + sameSite: "lax", + path: "/", + } as const, + csrfOptions: { + httpOnly: false, + secure, + sameSite: "lax", + path: "/", + } as const, + } +} diff --git a/hub/server/utils/authentication-error/index.ts b/hub/server/utils/authentication-error/index.ts new file mode 100644 index 00000000..456811d5 --- /dev/null +++ b/hub/server/utils/authentication-error/index.ts @@ -0,0 +1,9 @@ +export class AuthenticationError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message) + } +} diff --git a/hub/server/utils/digest-secret/index.ts b/hub/server/utils/digest-secret/index.ts new file mode 100644 index 00000000..19742d88 --- /dev/null +++ b/hub/server/utils/digest-secret/index.ts @@ -0,0 +1,5 @@ +import { createHash } from "node:crypto" + +export function digestSecret(value: string): string { + return createHash("sha256").update(value).digest("base64url") +} diff --git a/hub/server/utils/read-cookie/index.ts b/hub/server/utils/read-cookie/index.ts new file mode 100644 index 00000000..56974d8d --- /dev/null +++ b/hub/server/utils/read-cookie/index.ts @@ -0,0 +1,8 @@ +export function readCookie(header: string | undefined, name: string): string { + const values = (header ?? "") + .split(";") + .map((part) => part.trim()) + .filter((part) => part.startsWith(`${name}=`)) + const value = values.length === 1 ? (values[0]?.slice(name.length + 1) ?? "") : "" + return /^[\w-]{43}$/.test(value) ? value : "" +} diff --git a/hub/server/utils/require-csrf-token/index.ts b/hub/server/utils/require-csrf-token/index.ts new file mode 100644 index 00000000..5ace370e --- /dev/null +++ b/hub/server/utils/require-csrf-token/index.ts @@ -0,0 +1,8 @@ +import { AuthenticationError } from "#server/utils/authentication-error" +import { secretsMatch } from "#server/utils/secrets-match" + +export function requireCsrfToken(cookieToken: string, headerToken: string): void { + if (!cookieToken || !secretsMatch(cookieToken, headerToken)) { + throw new AuthenticationError(403, "csrf_failed", "The CSRF token did not match.") + } +} diff --git a/hub/server/utils/secrets-match/index.ts b/hub/server/utils/secrets-match/index.ts new file mode 100644 index 00000000..24ad8b46 --- /dev/null +++ b/hub/server/utils/secrets-match/index.ts @@ -0,0 +1,7 @@ +import { timingSafeEqual } from "node:crypto" + +export function secretsMatch(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) +} From a3248542ea4168f907ff33bbd8bff1ccd72230cb Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 13:07:34 -0700 Subject: [PATCH 2/4] fix(hub): harden auth request boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 3 + hub/docs/development.md | 2 + hub/docs/logging.md | 4 +- hub/server/create-app/index.ts | 1 + .../authentication/create-runtime/index.ts | 2 + .../authentication/create-service/types.ts | 1 + .../read-trusted-proxy-hops/index.test.ts | 16 +++++ .../express/read-trusted-proxy-hops/index.ts | 11 ++++ hub/server/routes/authentication.test.ts | 64 ++++++++++++++++++- hub/server/routes/index.ts | 2 +- 10 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts create mode 100644 hub/server/integrations/express/read-trusted-proxy-hops/index.ts diff --git a/hub/.env.example b/hub/.env.example index 59b34228..3b33a5dc 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -7,6 +7,9 @@ LOG_LEVEL=info # Public browser origin. Production requires HTTPS; local HTTP must use loopback. HUB_ORIGIN=http://127.0.0.1:5173 +# Keep 0 unless the API is reachable only through this exact number of trusted proxies. +HUB_TRUSTED_PROXY_HOPS=0 + # Local development only. The devcontainer supplies its own database hostname. DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/replica diff --git a/hub/docs/development.md b/hub/docs/development.md index a49e7532..43ffbe47 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -40,6 +40,8 @@ Open . Vite serves the sign-in surface and forwards `/api The API reads an optional `PORT` environment variable, defaulting to 3001. If you change it, update the local Vite proxy target to match. The editor forwards ports 3001, 5173, and 6006 from the devcontainer. API startup reads `.env` when present; Compose's environment takes precedence. +Hub ignores forwarded client-address headers by default and rate-limits the direct connection address. Keep `HUB_TRUSTED_PROXY_HOPS=0` for local development and any deployment where clients can reach the API directly. A deployment whose API is reachable only through a fixed proxy chain may set the exact number of trusted hops. Every trusted proxy must replace or append the client address correctly, and the network must prevent clients from bypassing the first trusted proxy. Never enable proxy trust only to accept arbitrary `Forwarded` or `X-Forwarded-For` headers. + ## Database lifecycle PostgreSQL stores data in a named Compose volume. Stopping or rebuilding the devcontainer preserves it. For a local Node.js setup, stop the database with `docker compose -f .devcontainer/compose.yaml down`; this also preserves data. Do not add `--volumes` unless you intend to permanently delete the local database. diff --git a/hub/docs/logging.md b/hub/docs/logging.md index d6580315..a76a1461 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -30,7 +30,7 @@ Each request produces one final record with `reqId`, `method`, `route`, `statusC Automatic records exclude raw URLs, headers, cookies, uploads, response bodies, and error details. JSON parsing uses Express's default 100 KiB limit; rejected bodies are not logged. The Express error handler returns generic JSON errors, or closes the connection if headers were sent, without exposing raw errors. -Authentication routers should install `omitRequestLogPayload` before their handlers so callback payloads are excluded by default: +Authentication routers must install `omitRequestLogPayload` before their origin checks, rate limits, and handlers so every auth-specific early response excludes the payload: ```ts import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" @@ -40,7 +40,7 @@ router.use(omitRequestLogPayload) This omits `params`, `query`, and `body` while keeping request metadata. -Authentication and session routes install this omission before their handlers run. OAuth codes, state values, CSRF values, cookies, PKCE material, and provider responses therefore never enter automatic request payload records. +Authentication and session routes install this omission before any auth-specific middleware that can reject the request. OAuth codes, state values, CSRF values, cookies, PKCE material, provider responses, and unrelated private fields therefore never enter automatic request payload records. ## Structured-field safety diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index 5ea4c5f6..72883a86 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -14,6 +14,7 @@ export function createApp({ }: { authentication?: AuthenticationRoutes; logger?: Logger } = {}) { const app = express() app.disable("x-powered-by") + app.set("trust proxy", authentication?.settings.trustedProxyHops ?? 0) app.use(createRequestLogger(logger)) app.use(express.json()) diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 71606f0f..63be7add 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -5,6 +5,7 @@ import { ensureGitHubProviderRevision } from "#server/domains/authentication/ens import { createIdentityStore } from "#server/domains/identity/create-store" import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" import { prisma } from "#server/integrations/prisma" function readOrigin(environment: NodeJS.ProcessEnv): URL { @@ -63,6 +64,7 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv secureCookies: origin.protocol === "https:", configured: configuration !== null, admissionPolicy: admissionPolicy.name, + trustedProxyHops: readTrustedProxyHops(environment), } return { service: createAuthenticationService({ diff --git a/hub/server/domains/authentication/create-service/types.ts b/hub/server/domains/authentication/create-service/types.ts index da14ff5b..19700b9c 100644 --- a/hub/server/domains/authentication/create-service/types.ts +++ b/hub/server/domains/authentication/create-service/types.ts @@ -18,6 +18,7 @@ export interface AuthenticationSettings { secureCookies: boolean configured: boolean admissionPolicy: AdmissionPolicy["name"] + trustedProxyHops: number } export interface AuthenticationContext extends SessionContext { diff --git a/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts b/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts new file mode 100644 index 00000000..394d99be --- /dev/null +++ b/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest" + +import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" + +describe("readTrustedProxyHops()", () => { + it("ignores forwarded client addresses unless an exact proxy count is configured", () => { + expect(readTrustedProxyHops({})).toBe(0) + expect(readTrustedProxyHops({ HUB_TRUSTED_PROXY_HOPS: "2" })).toBe(2) + }) + + it.each(["-1", "1.5", "11", "edge", " 1"])("rejects the invalid proxy count %s", (value) => { + expect(() => readTrustedProxyHops({ HUB_TRUSTED_PROXY_HOPS: value })).toThrow( + "HUB_TRUSTED_PROXY_HOPS", + ) + }) +}) diff --git a/hub/server/integrations/express/read-trusted-proxy-hops/index.ts b/hub/server/integrations/express/read-trusted-proxy-hops/index.ts new file mode 100644 index 00000000..67b8bdaf --- /dev/null +++ b/hub/server/integrations/express/read-trusted-proxy-hops/index.ts @@ -0,0 +1,11 @@ +export function readTrustedProxyHops(environment: NodeJS.ProcessEnv): number { + const value = environment.HUB_TRUSTED_PROXY_HOPS ?? "0" + if (!/^(?:0|[1-9]\d*)$/.test(value)) { + throw new Error("HUB_TRUSTED_PROXY_HOPS must be an integer from 0 through 10.") + } + const hops = Number(value) + if (hops > 10) { + throw new Error("HUB_TRUSTED_PROXY_HOPS must be an integer from 0 through 10.") + } + return hops +} diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index 41e4c5a5..69b2fe9c 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -14,6 +14,7 @@ import { createApp } from "../create-app/index.ts" function authentication( overrides: Partial> = {}, secureCookies = true, + trustedProxyHops = 0, ) { const service = Object.assign( { @@ -41,6 +42,7 @@ function authentication( secureCookies, configured: true, admissionPolicy: "hub-managed" as const, + trustedProxyHops, }, service, } @@ -123,6 +125,33 @@ describe("authentication routes", () => { expect(runtime.service.signOut).not.toHaveBeenCalled() }) + it("omits rejected auth payloads before origin middleware can finish the request", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const runtime = authentication() + const baseUrl = await serve(runtime, logger) + + const response = await fetch(`${baseUrl}/api/auth/github?note=query-private-value`, { + method: "POST", + headers: { + ...csrfHeaders, + origin: "https://attacker.example", + "content-type": "application/json", + }, + body: JSON.stringify({ note: "body-private-value" }), + }) + + expect(response.status).toBe(403) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).not.toHaveProperty("query") + expect(records[0]).not.toHaveProperty("body") + expect(JSON.stringify(records)).not.toMatch(/query-private-value|body-private-value/) + }) + it("rotates browser state on sign-out and delegates revoke-all only with a protected request", async () => { const runtime = authentication() const baseUrl = await serve(runtime) @@ -195,7 +224,10 @@ describe("authentication routes", () => { responses.push( await fetch(`${baseUrl}/api/auth/github`, { method: "POST", - headers: csrfHeaders, + headers: { + ...csrfHeaders, + "x-forwarded-for": `198.51.100.${request + 1}`, + }, }), ) } @@ -208,6 +240,36 @@ describe("authentication routes", () => { expect(runtime.service.start).toHaveBeenCalledTimes(20) }) + it("separates rate limits by forwarded client only behind the configured proxy boundary", async () => { + const runtime = authentication({}, true, 1) + const baseUrl = await serve(runtime) + + const responses = [] + for (let request = 0; request < 20; request++) { + responses.push( + await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: { + ...csrfHeaders, + "x-forwarded-for": "198.51.100.10", + }, + }), + ) + } + responses.push( + await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: { + ...csrfHeaders, + "x-forwarded-for": "203.0.113.20", + }, + }), + ) + + expect(responses.every(({ status }) => status === 200)).toBe(true) + expect(runtime.service.start).toHaveBeenCalledTimes(21) + }) + it("returns session state without exposing the session token and creates a readable CSRF cookie", async () => { const runtime = authentication({ current: vi.fn().mockResolvedValue({ diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index 06a3d8ac..9a55b16f 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -20,8 +20,8 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi app.use( ["/api/auth", "/api/session"], - prepareAuthenticationRequest.bind(null, authentication.settings.origin), omitRequestLogPayload, + prepareAuthenticationRequest.bind(null, authentication.settings.origin), ) const initiationLimit = createRateLimit({ limit: 20, From e6112602326bd0b7576f42194dcc98ebece3b48a Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 13:13:06 -0700 Subject: [PATCH 3/4] fix(hub): omit auth payloads before parsing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/logging.md | 6 ++-- hub/server/create-app/index.ts | 2 ++ hub/server/routes/authentication.test.ts | 36 ++++++++++++++++++++++++ hub/server/routes/index.ts | 2 -- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/hub/docs/logging.md b/hub/docs/logging.md index a76a1461..e66f3d3c 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -30,17 +30,17 @@ Each request produces one final record with `reqId`, `method`, `route`, `statusC Automatic records exclude raw URLs, headers, cookies, uploads, response bodies, and error details. JSON parsing uses Express's default 100 KiB limit; rejected bodies are not logged. The Express error handler returns generic JSON errors, or closes the connection if headers were sent, without exposing raw errors. -Authentication routers must install `omitRequestLogPayload` before their origin checks, rate limits, and handlers so every auth-specific early response excludes the payload: +`createApp()` installs `omitRequestLogPayload` for authentication and session paths after request logging but before JSON parsing. This ordering covers parser failures as well as later origin checks, rate limits, and handlers: ```ts import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" -router.use(omitRequestLogPayload) +app.use(["/api/auth", "/api/session"], omitRequestLogPayload) ``` This omits `params`, `query`, and `body` while keeping request metadata. -Authentication and session routes install this omission before any auth-specific middleware that can reject the request. OAuth codes, state values, CSRF values, cookies, PKCE material, provider responses, and unrelated private fields therefore never enter automatic request payload records. +OAuth codes, state values, CSRF values, cookies, PKCE material, provider responses, and unrelated private fields therefore never enter automatic request payload records, including when JSON is malformed or too large. ## Structured-field safety diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index 72883a86..ef54e6b7 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -4,6 +4,7 @@ import type { Logger } from "pino" import { handleError } from "../integrations/express/handle-error/index.ts" import { respondNotFound } from "../integrations/express/respond-not-found/index.ts" import { createRequestLogger } from "../integrations/pino/create-request-logger/index.ts" +import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" import { routes } from "../routes/index.ts" import type { AuthenticationRoutes } from "../routes/types.ts" @@ -16,6 +17,7 @@ export function createApp({ app.disable("x-powered-by") app.set("trust proxy", authentication?.settings.trustedProxyHops ?? 0) app.use(createRequestLogger(logger)) + app.use(["/api/auth", "/api/session"], omitRequestLogPayload) app.use(express.json()) routes(app, authentication) diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index 69b2fe9c..4308650e 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -152,6 +152,42 @@ describe("authentication routes", () => { expect(JSON.stringify(records)).not.toMatch(/query-private-value|body-private-value/) }) + it.each([ + { + name: "malformed", + body: '{"note":"body-private-value"', + status: 400, + }, + { + name: "oversized", + body: JSON.stringify({ note: "body-private-value".repeat(100_000) }), + status: 413, + }, + ])("omits auth payloads when the JSON parser rejects a $name body", async ({ body, status }) => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const baseUrl = await serve(authentication(), logger) + + const response = await fetch(`${baseUrl}/api/auth/github?note=query-private-value`, { + method: "POST", + headers: { + ...csrfHeaders, + "content-type": "application/json", + }, + body, + }) + + expect(response.status).toBe(status) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).not.toHaveProperty("query") + expect(records[0]).not.toHaveProperty("body") + expect(JSON.stringify(records)).not.toMatch(/query-private-value|body-private-value/) + }) + it("rotates browser state on sign-out and delegates revoke-all only with a protected request", async () => { const runtime = authentication() const baseUrl = await serve(runtime) diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index 9a55b16f..d1af83ee 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -3,7 +3,6 @@ import type { Express } from "express" import { createRateLimit } from "#server/integrations/express/create-rate-limit" import { prepareAuthenticationRequest } from "#server/integrations/express/prepare-authentication-request" import { respondAuthenticationError } from "#server/integrations/express/respond-authentication-error" -import { omitRequestLogPayload } from "#server/integrations/pino/omit-request-log-payload" import { get as handleApiAuthGithubCallbackGet } from "#server/routes/api/auth/github/callback/get" import { post as handleApiAuthGithubPost } from "#server/routes/api/auth/github/post" import { get as handleApiHealthGet } from "./api/health/get/index.ts" @@ -20,7 +19,6 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi app.use( ["/api/auth", "/api/session"], - omitRequestLogPayload, prepareAuthenticationRequest.bind(null, authentication.settings.origin), ) const initiationLimit = createRateLimit({ From b4b959760969ac8af16df4a6e24ec20dd4b0205c Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 18:13:32 -0700 Subject: [PATCH 4/4] fix(hub): align session revocation freshness Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/identity.md | 2 +- .../authentication/create-service/index.test.ts | 12 +++++++++--- .../authentication/fresh-authorization/constants.ts | 1 + .../domains/authentication/revoke-sessions/index.ts | 11 +++++++---- 4 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 hub/server/domains/authentication/fresh-authorization/constants.ts diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 53b6e2b2..72fd0947 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -14,7 +14,7 @@ Hub sessions use a random opaque cookie and store only its digest. Authenticatio `HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, and no repository permission check gates sign-in. `legacy-repository` is an explicit compatibility policy that uses the separate GitHub source integration to require access to at least one installed repository. The GitHub authentication adapter never reads installation credentials or performs repository authorization. -Sign-out revokes the current database session. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. +Sign-out revokes the current database session. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with verification less than five minutes old may revoke all sessions; revocation also supersedes callbacks that started earlier. ## Persisted boundaries diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index a5512919..55555c8b 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -313,7 +313,7 @@ describe("createAuthenticationService()", () => { expect(identityRecords.verifySession).toHaveBeenCalledWith(session.id, now) }) - it("signs out only the current session and requires fresh administrator access to revoke all", async () => { + it("signs out only the current session and requires administrator proof strictly younger than five minutes to revoke all", async () => { const current = { id: "current-session", userId: "user", @@ -321,7 +321,7 @@ describe("createAuthenticationService()", () => { authenticationProviderRevisionId: providerRevision.id, tokenDigest: digestSecret("session-token"), signedInAt: now, - freshAuthorizedAt: new Date(now.getTime() - 11 * 60_000), + freshAuthorizedAt: new Date(now.getTime() - 5 * 60_000), user: { id: "user", displayName: "Administrator", avatarUrl: null }, externalIdentity: { id: "identity", @@ -357,7 +357,13 @@ describe("createAuthenticationService()", () => { }) expect(identityRecords.advanceSessionGeneration).not.toHaveBeenCalled() - current.freshAuthorizedAt = now + current.freshAuthorizedAt = new Date(now.getTime() + 1) + await expect(service.revokeAll("session-token")).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + + current.freshAuthorizedAt = new Date(now.getTime() - (5 * 60_000 - 1)) await service.revokeAll("session-token") expect(identityRecords.advanceSessionGeneration).toHaveBeenCalledOnce() expect(identityRecords.deleteSessions).toHaveBeenCalledOnce() diff --git a/hub/server/domains/authentication/fresh-authorization/constants.ts b/hub/server/domains/authentication/fresh-authorization/constants.ts new file mode 100644 index 00000000..471e4c61 --- /dev/null +++ b/hub/server/domains/authentication/fresh-authorization/constants.ts @@ -0,0 +1 @@ +export const administratorFreshAuthorizationLifetimeMilliseconds = 5 * 60_000 diff --git a/hub/server/domains/authentication/revoke-sessions/index.ts b/hub/server/domains/authentication/revoke-sessions/index.ts index 4363e2eb..1488055e 100644 --- a/hub/server/domains/authentication/revoke-sessions/index.ts +++ b/hub/server/domains/authentication/revoke-sessions/index.ts @@ -1,9 +1,8 @@ import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import { administratorFreshAuthorizationLifetimeMilliseconds } from "#server/domains/authentication/fresh-authorization/constants" import { getCurrentSession } from "#server/domains/authentication/get-current-session" import { AuthenticationError } from "#server/utils/authentication-error" -const freshAuthorizationMilliseconds = 10 * 60_000 - export async function revokeSessions( dependencies: AuthenticationDependencies, sessionToken: string, @@ -14,9 +13,13 @@ export async function revokeSessions( if (current.membership?.status !== "active" || current.membership.role !== "administrator") { throw new AuthenticationError(403, "administrator_required", "Administrator access required.") } + const freshAuthorizationAge = current.freshAuthorizedAt + ? now().getTime() - current.freshAuthorizedAt.getTime() + : null if ( - !current.freshAuthorizedAt || - now().getTime() - current.freshAuthorizedAt.getTime() > freshAuthorizationMilliseconds + freshAuthorizationAge === null || + freshAuthorizationAge < 0 || + freshAuthorizationAge >= administratorFreshAuthorizationLifetimeMilliseconds ) { throw new AuthenticationError( 403,