diff --git a/hub/.env.example b/hub/.env.example index ed8cba81..33368342 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -4,8 +4,40 @@ 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 + +# 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 # 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 + +# Generate separate host-held values. Bootstrap can enroll the first administrator once. +# Recovery works only when no active administrator remains and never reopens bootstrap. +# HUB_BOOTSTRAP_SECRET= +# HUB_ADMIN_RECOVERY_SECRET= + +# 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 source-integration setting below. +# GITHUB_APP_ID= +# GITHUB_INSTALLATION_ID= +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= +# GITHUB_PRIVATE_KEY= diff --git a/hub/README.md b/hub/README.md index fba50194..2ea2df92 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, GitHub authentication, sessions, and installation membership administration. The frontend provides GitHub sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new 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. GitHub sign-in works when its dedicated client credentials are configured. Hub-owned admission, invitations, membership roles and lifecycle, administrator recovery, and session revocation are implemented. Prototype authorization, saved prototype 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.test.tsx b/hub/app/javascript/application/authentication/index.test.tsx new file mode 100644 index 00000000..b836be47 --- /dev/null +++ b/hub/app/javascript/application/authentication/index.test.tsx @@ -0,0 +1,250 @@ +import { ThemeProvider } from "@primer/react-brand" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { AuthenticationApplication } from "." + +const administratorSession = { + authenticated: true, + configured: true, + csrfToken: "csrf-token", + user: { + id: "administrator", + provider: "github", + username: "administrator", + displayName: "Administrator", + avatarUrl: null, + }, + membership: { + id: "administrator-membership", + role: "administrator", + status: "active", + }, +} + +const administration = { + memberships: { + memberships: [], + nextCursor: null, + }, + invitations: { + invitations: [ + { + id: "invitation", + provider: "github", + issuer: "https://github.com", + subject: "42", + role: "member", + expiresAt: "2026-11-05T18:00:00.000Z", + consumedAt: null, + revokedAt: null, + }, + ], + nextCursor: null, + }, +} + +function renderApplication(fetch: ReturnType) { + vi.stubGlobal("fetch", fetch) + render( + + + , + ) +} + +describe("AuthenticationApplication()", () => { + it("loads and appends later membership and invitation pages from their cursors", async () => { + const firstPage = { + memberships: { + memberships: [ + { + id: "first-membership", + status: "active", + role: "member", + user: { + id: "first-user", + displayName: "First member", + externalIdentities: [], + }, + }, + ], + nextCursor: "membership-cursor", + }, + invitations: { + invitations: [ + { + id: "first-invitation", + provider: "github", + issuer: "https://github.com", + subject: "first-subject", + role: "member", + expiresAt: "2026-11-05T18:00:00.000Z", + consumedAt: null, + revokedAt: null, + }, + ], + nextCursor: "invitation-cursor", + }, + } + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(administratorSession)) + .mockResolvedValueOnce(Response.json(firstPage)) + .mockResolvedValueOnce( + Response.json({ + memberships: { + memberships: [ + { + id: "second-membership", + status: "suspended", + role: "member", + user: { + id: "second-user", + displayName: "Second member", + externalIdentities: [], + }, + }, + ], + nextCursor: null, + }, + invitations: firstPage.invitations, + }), + ) + .mockResolvedValueOnce( + Response.json({ + memberships: firstPage.memberships, + invitations: { + invitations: [ + { + id: "second-invitation", + provider: "github", + issuer: "https://github.com", + subject: "second-subject", + role: "member", + expiresAt: "2026-11-06T18:00:00.000Z", + consumedAt: null, + revokedAt: null, + }, + ], + nextCursor: null, + }, + }), + ) + renderApplication(fetch) + + fireEvent.click(await screen.findByRole("button", { name: "Load more memberships" })) + expect(await screen.findByText("Second member")).toBeInTheDocument() + expect(screen.getByText("First member")).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith("/api/administration?membershipsCursor=membership-cursor", { + credentials: "same-origin", + }) + + fireEvent.click(screen.getByRole("button", { name: "Load more invitations" })) + expect(await screen.findByText("second-subject")).toBeInTheDocument() + expect(screen.getByText("first-subject")).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith("/api/administration?invitationsCursor=invitation-cursor", { + credentials: "same-origin", + }) + expect( + screen.queryByRole("button", { name: /^Load more (memberships|invitations)$/ }), + ).not.toBeInTheDocument() + }) + + it("starts same-identity verification when an administration mutation needs fresh authorization", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(administratorSession)) + .mockResolvedValueOnce(Response.json(administration)) + .mockResolvedValueOnce( + Response.json( + { + error: "fresh_authorization_required", + message: "Verify your identity again.", + }, + { status: 403 }, + ), + ) + .mockResolvedValueOnce(Response.json({ authorizationUrl: "https://github.example/verify" })) + renderApplication(fetch) + + fireEvent.click(await screen.findByRole("button", { name: "Revoke invitation" })) + + await waitFor(() => + expect(fetch).toHaveBeenCalledWith( + "/api/session/verify", + expect.objectContaining({ + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": "csrf-token" }, + }), + ), + ) + expect(screen.queryByText("fresh_authorization_required")).not.toBeInTheDocument() + }) + + it("shows the server-readable message for other administration failures", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(administratorSession)) + .mockResolvedValueOnce(Response.json(administration)) + .mockResolvedValueOnce( + Response.json( + { + error: "invitation_changed", + message: "The invitation is no longer available.", + }, + { status: 409 }, + ), + ) + renderApplication(fetch) + + fireEvent.click(await screen.findByRole("button", { name: "Revoke invitation" })) + + expect(await screen.findByRole("alert")).toHaveTextContent( + "The invitation is no longer available.", + ) + expect(screen.getByRole("alert")).not.toHaveTextContent("invitation_changed") + }) + + it.each([ + { + name: "an expired session", + status: 401, + error: "sign_in_required", + message: "Sign in to continue.", + }, + { + name: "lost administrator access", + status: 403, + error: "administrator_required", + message: "Administrator access is required.", + }, + ])("reloads session state and removes stale controls after $name", async (failure) => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(administratorSession)) + .mockResolvedValueOnce(Response.json(administration)) + .mockResolvedValueOnce( + Response.json( + { error: failure.error, message: failure.message }, + { status: failure.status }, + ), + ) + .mockResolvedValueOnce( + Response.json({ + authenticated: false, + configured: true, + csrfToken: "replacement-csrf-token", + }), + ) + renderApplication(fetch) + + fireEvent.click(await screen.findByRole("button", { name: "Revoke invitation" })) + + expect(await screen.findByRole("alert")).toHaveTextContent(failure.message) + expect(screen.queryByRole("heading", { name: "Hub membership" })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Revoke invitation" })).not.toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith("/api/session", { credentials: "same-origin" }) + }) +}) diff --git a/hub/app/javascript/application/authentication/index.tsx b/hub/app/javascript/application/authentication/index.tsx new file mode 100644 index 00000000..cc264ffa --- /dev/null +++ b/hub/app/javascript/application/authentication/index.tsx @@ -0,0 +1,268 @@ +import { useEffect, useState } from "react" + +import { SignInPage } from "@pages/sign-in-page" +import type { SignInPageUser } from "@pages/sign-in-page/types" +import { MembershipAdministrationPage } from "@pages/membership-administration-page" +import type { + InvitationRecord, + MembershipRecord, +} from "@pages/membership-administration-page/types" + +interface SessionResponse { + authenticated: boolean + configured: boolean + csrfToken: string + user?: SignInPageUser + membership?: { + id: string + role: "member" | "administrator" + status: "active" + } +} + +interface AdministrationResponse { + memberships: { + memberships: MembershipRecord[] + nextCursor: string | null + } + invitations: { + invitations: InvitationRecord[] + nextCursor: string | null + } +} + +interface AdministrationErrorResponse { + error?: string + message?: string +} + +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 +} + +async function fetchAdministration(cursors?: { + memberships?: string | null + invitations?: string | null +}): Promise { + const query = new URLSearchParams() + if (cursors?.memberships) query.set("membershipsCursor", cursors.memberships) + if (cursors?.invitations) query.set("invitationsCursor", cursors.invitations) + const response = await fetch(`/api/administration${query.size ? `?${query}` : ""}`, { + credentials: "same-origin", + }) + if (!response.ok) throw new Error("Administration request failed") + return (await response.json()) as AdministrationResponse +} + +export function AuthenticationApplication() { + const [session, setSession] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [administration, setAdministration] = useState(null) + + useEffect(() => { + fetchSession() + .then(async (value) => { + setSession(value) + if (value.membership?.role === "administrator") { + setAdministration(await fetchAdministration()) + } + }) + .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 startEnrollment(purpose: "bootstrap" | "recovery", secret: string) { + if (!session) return + setLoading(true) + setError(null) + try { + const response = await fetch(`/api/auth/${purpose}`, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": session.csrfToken, + }, + body: JSON.stringify({ secret }), + }) + if (!response.ok) throw new Error("Enrollment is unavailable.") + const data = (await response.json()) as { authorizationUrl: string } + window.location.assign(data.authorizationUrl) + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Enrollment is unavailable.") + 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()) + setAdministration(null) + } catch { + setError("Hub could not sign you out.") + } finally { + setLoading(false) + } + } + + async function updateAdministration(path: string, method: string, body?: unknown) { + if (!session) return + setLoading(true) + setError(null) + try { + const response = await fetch(path, { + method, + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": session.csrfToken, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + if (!response.ok) { + const data = (await response.json()) as AdministrationErrorResponse + if (data.error === "fresh_authorization_required") { + const verification = await fetch("/api/session/verify", { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": session.csrfToken }, + }) + if (!verification.ok) { + throw new Error("Hub could not start identity verification.") + } + const verificationData = (await verification.json()) as { authorizationUrl: string } + window.location.assign(verificationData.authorizationUrl) + return + } + if (response.status === 401 || data.error === "administrator_required") { + setAdministration(null) + setSession(await fetchSession()) + } + throw new Error(data.message ?? "Administration request failed") + } + const current = await fetchSession() + setSession(current) + if (current.membership?.role === "administrator") { + setAdministration(await fetchAdministration()) + } else { + setAdministration(null) + } + } catch (requestError) { + setError( + requestError instanceof Error ? requestError.message : "Hub could not update membership.", + ) + } finally { + setLoading(false) + } + } + + async function loadMoreAdministration(collection: "memberships" | "invitations") { + if (!administration) return + const cursor = + collection === "memberships" + ? administration.memberships.nextCursor + : administration.invitations.nextCursor + if (!cursor) return + setLoading(true) + setError(null) + try { + const page = await fetchAdministration({ [collection]: cursor }) + setAdministration((current) => { + if (!current) return current + if (collection === "memberships") { + return { + ...current, + memberships: { + memberships: [...current.memberships.memberships, ...page.memberships.memberships], + nextCursor: page.memberships.nextCursor, + }, + } + } + return { + ...current, + invitations: { + invitations: [...current.invitations.invitations, ...page.invitations.invitations], + nextCursor: page.invitations.nextCursor, + }, + } + }) + } catch { + setError("Hub could not load more administration records.") + } finally { + setLoading(false) + } + } + + if (session?.authenticated && session.membership?.role === "administrator" && administration) { + return ( + + void updateAdministration("/api/administration/invitations", "POST", input) + } + onChange={(membershipId, input) => + void updateAdministration( + `/api/administration/memberships/${membershipId}`, + "PATCH", + input, + ) + } + onRevokeInvitation={(invitationId) => + void updateAdministration(`/api/administration/invitations/${invitationId}`, "DELETE") + } + onLoadMoreMemberships={() => void loadMoreAdministration("memberships")} + onLoadMoreInvitations={() => void loadMoreAdministration("invitations")} + onSignOut={() => void signOut()} + /> + ) + } + + return ( + void signIn()} + onSignOut={() => void signOut()} + onBootstrap={(secret) => void startEnrollment("bootstrap", secret)} + onRecovery={(secret) => void startEnrollment("recovery", secret)} + /> + ) +} 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/membership-administration-page/index.test.tsx b/hub/app/javascript/pages/membership-administration-page/index.test.tsx new file mode 100644 index 00000000..62c7c732 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/index.test.tsx @@ -0,0 +1,91 @@ +import { ThemeProvider } from "@primer/react-brand" +import { fireEvent, render, screen, within } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { MembershipAdministrationPage } from "." + +describe("MembershipAdministrationPage()", () => { + it("defaults new invitations to the member role", () => { + const onInvite = vi.fn() + render( + + + , + ) + + fireEvent.change(screen.getByRole("textbox", { name: "Immutable subject" }), { + target: { value: "42" }, + }) + fireEvent.submit(screen.getByRole("button", { name: "Create invitation" }).closest("form")!) + + expect(onInvite).toHaveBeenCalledWith({ + provider: "github", + issuer: "https://github.com", + subject: "42", + role: "member", + }) + }) + + it("offers restoration for suspension but no lifecycle actions after removal", () => { + const onChange = vi.fn() + render( + + + , + ) + + const suspended = screen.getByText("Suspended member").closest("li")! + fireEvent.click(within(suspended).getByRole("button", { name: "Restore" })) + expect(onChange).toHaveBeenCalledWith("suspended", { status: "active" }) + + const removed = screen.getByText("Removed administrator").closest("li")! + expect(within(removed).queryByRole("button")).not.toBeInTheDocument() + }) +}) diff --git a/hub/app/javascript/pages/membership-administration-page/index.tsx b/hub/app/javascript/pages/membership-administration-page/index.tsx new file mode 100644 index 00000000..8cebcfe5 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/index.tsx @@ -0,0 +1,229 @@ +import { useState } from "react" + +import { Button, FormControl, Heading, Select, Stack, Text, TextInput } from "@primer/react-brand" + +import type { MembershipAdministrationPageProps } from "./types" +import "./styles.css" + +export function MembershipAdministrationPage({ + memberships, + invitations, + hasMoreMemberships, + hasMoreInvitations, + loading, + error, + onInvite, + onChange, + onRevokeInvitation, + onLoadMoreMemberships, + onLoadMoreInvitations, + onSignOut, +}: MembershipAdministrationPageProps) { + const [provider, setProvider] = useState("github") + const [issuer, setIssuer] = useState("https://github.com") + const [subject, setSubject] = useState("") + const [role, setRole] = useState<"member" | "administrator">("member") + + return ( +
+
+
+ + Hub membership + + + Hub admission and installation roles are independent of repository access. + +
+ +
+ + {error ? ( + + {error} + + ) : null} + +
+ + Invite a provider identity + +
{ + event.preventDefault() + onInvite({ provider, issuer, subject, role }) + }} + > + + Provider + setProvider(event.target.value)} + /> + + + Issuer + setIssuer(event.target.value)} + /> + + + Immutable subject + setSubject(event.target.value)} + /> + + + Role + + + +
+
+ +
+ + Memberships + +
    + {memberships.map((membership) => { + const identity = membership.user.externalIdentities[0] + return ( +
  • + + + {membership.user.displayName ?? + identity?.username ?? + identity?.subject ?? + membership.user.id} + + + {identity + ? `${identity.provider} · ${identity.issuer} · ${identity.subject}` + : "No external identity"} + + + {membership.role} · {membership.status} + + {membership.status !== "removed" ? ( +
    + + + +
    + ) : null} +
    +
  • + ) + })} +
+ {hasMoreMemberships ? ( + + ) : null} +
+ +
+ + Invitations + +
    + {invitations.map((invitation) => ( +
  • + {invitation.subject} + + {invitation.provider} · {invitation.issuer} · {invitation.role} + + {!invitation.consumedAt && !invitation.revokedAt ? ( + + ) : ( + {invitation.consumedAt ? "Activated" : "Revoked"} + )} +
  • + ))} +
+ {hasMoreInvitations ? ( + + ) : null} +
+
+ ) +} diff --git a/hub/app/javascript/pages/membership-administration-page/stories.tsx b/hub/app/javascript/pages/membership-administration-page/stories.tsx new file mode 100644 index 00000000..fc52dd2f --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { MembershipAdministrationPage } from "." + +const meta = { + title: "Hub/Pages/Identity/Membership administration", + component: MembershipAdministrationPage, + parameters: { layout: "fullscreen" }, + args: { + loading: false, + error: null, + memberships: [ + { + id: "membership-1", + role: "administrator", + status: "active", + user: { + id: "user-1", + displayName: "Mona Lisa", + externalIdentities: [ + { + id: "identity-1", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "mona", + }, + ], + }, + }, + { + id: "membership-2", + role: "member", + status: "suspended", + user: { + id: "user-2", + displayName: "Ada Lovelace", + externalIdentities: [ + { + id: "identity-2", + provider: "oidc", + issuer: "https://login.example", + subject: "ada-1843", + username: "ada", + }, + ], + }, + }, + ], + invitations: [ + { + id: "invitation-1", + provider: "github", + issuer: "https://github.com", + subject: "99", + role: "administrator", + expiresAt: "2026-11-03T20:00:00.000Z", + consumedAt: null, + revokedAt: null, + }, + ], + hasMoreMemberships: false, + hasMoreInvitations: false, + onInvite: () => undefined, + onChange: () => undefined, + onRevokeInvitation: () => undefined, + onLoadMoreMemberships: () => undefined, + onLoadMoreInvitations: () => undefined, + onSignOut: () => undefined, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} +export const MoreRecordsAvailable: Story = { + args: { + hasMoreMemberships: true, + hasMoreInvitations: true, + }, +} diff --git a/hub/app/javascript/pages/membership-administration-page/styles.css b/hub/app/javascript/pages/membership-administration-page/styles.css new file mode 100644 index 00000000..56fe5d45 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/styles.css @@ -0,0 +1,49 @@ +.membership-administration-page { + display: grid; + gap: 2rem; + max-inline-size: 72rem; + margin-inline: auto; + padding: 2rem; +} + +.membership-administration-page__header { + display: flex; + gap: 1rem; + align-items: start; + justify-content: space-between; +} + +.membership-administration-page__form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1rem; + align-items: end; + margin-block-start: 1rem; +} + +.membership-administration-page__list { + display: grid; + gap: 0.75rem; + padding: 0; + list-style: none; +} + +.membership-administration-page__list > li { + padding: 1rem; + border: 0.0625rem solid var(--hub-border-default); + border-radius: 0.5rem; +} + +.membership-administration-page__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.membership-administration-page__error { + color: var(--hub-fg-danger); +} + +.membership-administration-page__danger:not(:disabled) { + color: var(--hub-fg-danger); +} diff --git a/hub/app/javascript/pages/membership-administration-page/types.ts b/hub/app/javascript/pages/membership-administration-page/types.ts new file mode 100644 index 00000000..7623ba7a --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/types.ts @@ -0,0 +1,52 @@ +export interface MembershipIdentity { + id: string + provider: string + issuer: string + subject: string + username: string | null +} + +export interface MembershipRecord { + id: string + status: "active" | "suspended" | "removed" + role: "member" | "administrator" + user: { + id: string + displayName: string | null + externalIdentities: MembershipIdentity[] + } +} + +export interface InvitationRecord { + id: string + provider: string + issuer: string + subject: string + role: "member" | "administrator" + expiresAt: string + consumedAt: string | null + revokedAt: string | null +} + +export interface MembershipAdministrationPageProps { + memberships: MembershipRecord[] + invitations: InvitationRecord[] + hasMoreMemberships: boolean + hasMoreInvitations: boolean + loading: boolean + error: string | null + onInvite: (input: { + provider: string + issuer: string + subject: string + role: "member" | "administrator" + }) => void + onChange: ( + membershipId: string, + input: { role?: "member" | "administrator"; status?: "active" | "suspended" | "removed" }, + ) => void + onRevokeInvitation: (invitationId: string) => void + onLoadMoreMemberships: () => void + onLoadMoreInvitations: () => void + onSignOut: () => void +} 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..b16bd1bd --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/index.test.tsx @@ -0,0 +1,63 @@ +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} + onBootstrap={() => undefined} + onRecovery={() => 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} + onBootstrap={() => undefined} + onRecovery={() => 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} + onBootstrap={() => undefined} + onRecovery={() => 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..53b8fd7c --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/index.tsx @@ -0,0 +1,110 @@ +import { useState } from "react" + +import { Button, FormControl, Heading, Stack, Text, TextInput } 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, + onBootstrap, + onRecovery, +}: SignInPageProps) { + const [bootstrapSecret, setBootstrapSecret] = useState("") + const [recoverySecret, setRecoverySecret] = useState("") + 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. + )} + {configured ? ( +
+
+ Enroll the first administrator + + Bootstrap secret + setBootstrapSecret(event.target.value)} + /> + + +
+
+ Recover administration + + Recovery secret + setRecoverySecret(event.target.value)} + /> + + +
+
+ ) : null} + + )} + {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..a2fbc192 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/stories.tsx @@ -0,0 +1,36 @@ +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, + onBootstrap: () => undefined, + onRecovery: () => 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..e0632f58 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/styles.css @@ -0,0 +1,39 @@ +.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); +} + +.sign-in-page__enrollment { + display: grid; + gap: 0.75rem; + inline-size: min(100%, 28rem); + text-align: start; +} + +.sign-in-page__enrollment details { + display: grid; + gap: 0.75rem; + padding: 0.75rem; + border: 0.0625rem solid var(--hub-border-default); + border-radius: 0.5rem; +} + +.sign-in-page__enrollment summary { + cursor: pointer; + font-weight: 600; +} + +.sign-in-page__enrollment details[open] > * + * { + margin-block-start: 0.75rem; +} 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..e32f4081 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/types.ts @@ -0,0 +1,16 @@ +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 + onBootstrap: (secret: string) => void + onRecovery: (secret: string) => void +} diff --git a/hub/docs/README.md b/hub/docs/README.md index ec3bd8fe..55894251 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -7,6 +7,8 @@ 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): GitHub user authentication, provider-neutral sessions, admission policy, and source-integration credentials. +- [Membership and administration](membership-and-administration.md): Hub admission, invitations, roles, lifecycle, bootstrap, and recovery. - [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 eb7d24c5..cd180278 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, Hub sessions, and installation membership administration. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page 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 database has no product models yet. Sign-in, deployment, background jobs, and product APIs have not been built. This app cannot yet 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, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. Deployment, background jobs, and prototype 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. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. 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 and administration 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 GitHub sign-in and session state inside the theme provider. An active administrator sees the minimal membership and invitation page. Existing prototype 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/backend-architecture.md b/hub/docs/backend-architecture.md index 60976740..4a410ae1 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -37,6 +37,16 @@ Export route handlers by HTTP method, such as `get()`, and alias them to descrip Add directories when their functions are needed. Do not add empty implementations just to fill out the structure. +Keep function implementations flat. A composition factory may bind imported functions to dependencies, but must not contain their implementations. Put schemas and constants in a colocated `constants.ts`, and types in `types.ts`. + +Keep domain contracts with their domains, GitHub types and errors with that integration, and database inputs with their repositories. Keep only general helpers in utilities. + ## Database access The [shared Prisma client](database.md#shared-client) lives in `server/integrations/prisma/index.ts`. Domains may pass it to repositories for database queries without letting repositories import integrations. Keep model definitions, naming, and migrations consistent with the [database conventions](database.md#schema-conventions). + +Compose repository functions in domains. The identity store binds standalone queries to a database client or transaction there; a repository must not import its neighboring queries to build that store. + +`staticIdentityStoreRecordId` identifies the row used to serialize identity-store transactions. It is not a user, provider configuration, or GitHub installation. Keep it with the identity-store state repository. + +GitHub source-integration credentials use a separate fixed state row. Authentication-provider configuration must not read or write that credential state. diff --git a/hub/docs/coding-conventions.md b/hub/docs/coding-conventions.md index 31360d5d..cd990769 100644 --- a/hub/docs/coding-conventions.md +++ b/hub/docs/coding-conventions.md @@ -9,8 +9,12 @@ Follow the [database conventions](database.md#schema-conventions) for Prisma mod - Use TypeScript for new application code and tests. - Put each standalone function or component in a kebab-case directory with `index.ts` or `index.tsx`, colocated tests, types, and styles. - Export at most one function per file and import exact directories rather than sibling-export barrels. +- Keep helpers in their own function directories. Put schemas and constants in `constants.ts` and types in `types.ts`. - Group package, alias, and relative imports separately. -- Omit `/index.ts` and `/index.tsx` from imports in frontend code and Vitest tests. Keep explicit `.ts` file paths in server runtime and Node-loaded tooling, including Storybook configuration. Native Node does not resolve directory imports; the server compiler rewrites its paths for production. +- Use `@/` for browser code and `#server/` for API code. Import function directories without `/index.ts`, such as `#server/utils/digest-secret`. Import colocated constants and types from their named files. +- [Package imports](../package.json) resolve `#server/` to source with Node's `development` condition and to compiled JavaScript otherwise. `npm run dev:api` selects that condition; `npm start` uses compiled files. TypeScript and Vitest resolve source through the same mappings. Native Node package aliases must start with `#`; `@/` remains browser-only. +- Keep explicit `.ts` paths for relative imports in native Node modules and tooling, including Storybook configuration. The server compiler rewrites relative extensions for the build. +- Prefer named alias imports such as `#server/integrations/github/identify-user/constants` over relative paths when a mapping exists. Vite resolves extensionless relative paths, but native Node does not. TypeScript `paths` alone cannot make `@/server/...` work in Node or rewrite it in emitted JavaScript. - Keep HTTP parsing and responses outside domains. Validate input and return explicit errors when introducing real API behavior. ## Frontend layers diff --git a/hub/docs/database.md b/hub/docs/database.md index 6cb52918..f77a896c 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -1,6 +1,6 @@ # Hub database -Hub uses PostgreSQL and Prisma. Product models and migrations have not been added. +Hub uses PostgreSQL and Prisma. The initial migration stores the provider-neutral [identity foundation](identity.md); product models have not been added. [Compose](../.devcontainer/compose.yaml) pins the development database image. [package.json](../package.json) pins the Prisma packages to the same version. Dependency updates must follow the [seven-day dependency policy](../../docs/testing-and-review.md#update-dependencies). diff --git a/hub/docs/development.md b/hub/docs/development.md index b91ecf9f..dfe38c65 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. Set separate `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values to exercise first-administrator enrollment and no-administrator recovery. ## Start the app @@ -36,10 +36,12 @@ 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. +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/identity.md b/hub/docs/identity.md new file mode 100644 index 00000000..08691490 --- /dev/null +++ b/hub/docs/identity.md @@ -0,0 +1,57 @@ +# Hub identity + +Hub uses GitHub user authorization to prove an external identity. Hub then applies its own installation admission and membership rules before creating a provider-neutral browser session. Repository permissions do not grant installation membership. + +## 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, then Hub admits only an active member, an exact provider assignment with a usable invitation, or an authorized bootstrap or recovery enrollment. `legacy-repository` is an explicit compatibility policy that uses the separate GitHub source integration to create a member assignment after repository admission. The GitHub authentication adapter never reads installation credentials or performs repository authorization. + +Sign-out revokes the current database session. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. 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 + +- `User` is the Hub-owned account. It does not contain a provider identifier or administrator flag. +- `ExternalIdentity` links a provider, issuer, and subject to one Hub user. One user may link several identities. The issuer is part of uniqueness so separate OIDC tenants may reuse subjects safely. +- `InstallationMembership` stores a user's `member` or `administrator` role and `active`, `suspended`, or terminal `removed` status for this Hub installation. +- `MembershipInvitation` targets an `ExternalIdentity` assignment by provider, issuer, and immutable subject. Activation consumes the invitation once and creates or reactivates the linked user's membership. +- `AuthenticationProvider` and its versioned revisions store provider-neutral authentication configuration. An OIDC-only configuration does not require GitHub source-integration credentials. +- `Session` records the Hub user, external identity, and authentication-provider revision that established it. GitHub source-integration credential rotation does not change session identity context. +- `AuthorizationAttempt` stores digested state and browser bindings, a PKCE verifier, an optional OIDC nonce, provider revision, purpose, optional originating session, and expiry. Consumption deletes the record in the same transaction so concurrent callbacks cannot reuse it. +- `SecurityAudit` stores the event and minimal optional identity context. Audit records do not create the future prototype authorization model. + +Store operations can share one database transaction and lock a fixed store-state row. Related membership, invitation, session, and audit changes therefore commit or roll back together. Overlapping final-administrator changes and invitation activations observe committed state. + +## Installation admission and administration + +Set separate host-held `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values. Bootstrap can enroll the first administrator once. Its durable completion marker remains after expired authorization attempts are cleaned up. Recovery is available only when no active administrator remains and never resets bootstrap. + +Administrators invite an identity by provider, issuer, and immutable subject. Mutable usernames, display names, and email addresses do not activate invitations. Invitations expire after seven days, can be revoked before use, and are consumed by one matching authentication callback. Removed memberships require a new invitation. Suspended memberships must be restored by an administrator. + +Administrator mutations require an active administrator session verified less than five minutes ago. Hub prevents concurrent demotion, suspension, or removal from eliminating the final active administrator. See [Installation membership and administration](membership-and-administration.md). + +## GitHub source-integration credentials + +GitHub source access is an optional integration boundary, not the global identity boundary. Set `HUB_GITHUB_CREDENTIAL_PROVIDER` in the [environment example](../.env.example): + +| Provider | Credentials | +| --- | --- | +| `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. 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. + +## Current scope + +This layer adds GitHub sign-in, installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. It does not add prototype, repository, workspace, or artifact roles; GitHub membership synchronization; artifact ingestion; production OIDC; account linking; SCIM; or prototype 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 a32eb149..e66f3d3c 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -30,18 +30,22 @@ 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. -For private free text or other sensitive payloads, exclude all payload fields before sending a response: +`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 -response.locals.omitLogPayload = true +import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" + +app.use(["/api/auth", "/api/session"], omitRequestLogPayload) ``` This omits `params`, `query`, and `body` while keeping request metadata. +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 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. -Keys containing password, passwd, passphrase, secret, token, authorization, cookie, API key, private key, or session ID become `[REDACTED]`. Matching ignores case and punctuation and includes nested objects and arrays. Exclude payloads whose sensitive fields do not match these rules. +Keys containing password, passwd, passphrase, secret, token, authorization, cookie, API key, private key, or session ID become `[REDACTED]`. Automatic request payloads also redact OAuth and OIDC `code`, `state`, `nonce`, and `code_verifier` fields. Matching ignores case and punctuation and includes nested objects and arrays. Exclude payloads whose sensitive fields do not match these rules. Structured fields have [size, depth, and entry limits](../server/utils/logging/sanitize-log-value/constants.ts). Markers replace truncated data, cycles, getters, and unsupported values. These limits do not apply to messages or custom bindings and do not replace endpoint validation or privacy decisions. diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md new file mode 100644 index 00000000..1a1a8cb5 --- /dev/null +++ b/hub/docs/membership-and-administration.md @@ -0,0 +1,62 @@ +# Installation membership and administration + +Hub owns installation admission after an authentication provider proves an identity. Repository access and installation membership are separate decisions. + +## Membership contract + +Each Hub user has at most one installation membership. + +| State | Result | +| --- | --- | +| Active member | May create and restore a Hub session. Product access remains a separate future decision. | +| Active administrator | Has member access and may use installation administration after fresh identity verification. | +| Suspended | Cannot create or use a Hub session. An administrator may restore the membership. | +| Removed | Cannot create or use a Hub session. The retained record preserves audit linkage, and return requires a new invitation. | + +Changing a membership to suspended or removed deletes that user's sessions in the same transaction. Every protected request also reloads membership, so a stale cookie cannot preserve access. Hub prevents overlapping administrator changes from removing the final active administrator. + +## Invitations + +An invitation targets an external identity assignment: + +```text +provider + issuer + immutable subject -> ExternalIdentity -> User -> InstallationMembership +``` + +An administrator may invite a member or administrator. Hub never matches mutable login, display-name, or email fields. The invitation expires after seven days, can be revoked before activation, and can be consumed once. A matching authentication callback activates the membership and creates the session in one serialized operation. + +Hub rejects invitations for active or suspended assignments. A removed assignment may receive a new invitation. + +## Bootstrap and recovery + +Set two independent host-held secrets: + +- `HUB_BOOTSTRAP_SECRET` authorizes the first administrator enrollment once. +- `HUB_ADMIN_RECOVERY_SECRET` authorizes recovery only when no active administrator remains. + +Both routes verify the secret before starting a browser-bound, one-use provider authorization. The callback must complete in that browser. Hub stores neither secret in the database. Bootstrap has a durable completion marker and cannot reopen after attempt cleanup. Recovery does not change that marker. + +Keep both values outside Git and transfer them through the deployment owner's approved secret-management process. + +## Administration API + +The minimal administration surface uses: + +- `GET /api/administration` +- `POST /api/administration/invitations` +- `DELETE /api/administration/invitations/:id` +- `PATCH /api/administration/memberships/:id` +- `POST /api/auth/bootstrap` +- `POST /api/auth/recovery` + +The administration response returns membership and invitation pages with separate `nextCursor` values. Pass `membershipsCursor` or `invitationsCursor` to continue the corresponding collection without hiding older active records behind retained history. + +State-changing requests require the configured origin and matching CSRF cookie/header. Membership and invitation changes also require an active administrator session verified less than five minutes ago. Administration errors return stable codes such as `administrator_required`, `last_administrator`, and `invitation_changed`. + +## Security records + +Membership operations write a structured security record with a bounded action, outcome, actor, session, target membership or invitation, and fixed reason context. Success records commit with their state changes. Denied administrator mutations roll back partial changes before Hub saves the denial reason in a separate transaction. Records never include host secrets, provider tokens, request payloads, mutable identity labels, or exception text. + +## Non-goals + +This boundary does not define prototype, repository, workspace, or artifact roles. It does not synchronize GitHub organization or repository permissions, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 4730dc20..a2bf5958 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. @@ -34,6 +34,8 @@ Arrange each case's inputs and records inside that case. Act through its public Tests must release resources they create, such as servers, files, sockets, and event listeners. Use `try/finally` or register disposal with Vitest's `onTestFinished` when creating the resource. Clearing the module cache only affects subsequent imports. Reset any deliberately shared fixture state before each case. +Keep shared server test helpers under the owning function's `test/` directory. TypeScript checks them, but the API build excludes them. + ## Parallel database tests Each test run uses separate databases so parallel tests cannot change another worker's records or clear the development database. Vitest runs files in parallel in [forked worker processes](https://vitest.dev/config/pool). Cases within a worker run sequentially, including cases marked concurrent, so database resets cannot erase another case's records. Use `--maxWorkers` to set file-level parallelism. @@ -51,13 +53,15 @@ 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. There are no database test cases yet; an empty run does not prepare or exercise the databases. +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 and administration cases also cover PKCE, browser binding, provider revisions, invitation activation, one-use bootstrap and recovery boundaries, last-administrator races, targeted session revocation, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. + +A dedicated migration case applies the foundation migration, seeds retained lifecycle and security records, then applies the next migration. This catches upgrade behavior that an empty final schema cannot prove, including audit-outcome backfills and lifecycle constraints. 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. The database setup follows this lifecycle: -1. Before workers start, setup creates a template database with a unique name for each project and run. It applies the current Prisma schema with `prisma db push`, then copies the template for every worker slot. +1. Before workers start, setup creates a template database with a unique name for each project and run. It applies committed migrations with `prisma migrate deploy`, then copies the template for every worker slot. 2. Each worker receives its own `DATABASE_URL` before application modules load. Setup uses Vitest's [`VITEST_POOL_ID`](https://vitest.dev/guide/migration/jest#envs), which stays within the configured worker limit. 3. Before every case, setup empties application tables across user schemas and resets generated identity counters. It preserves Prisma's migration records. Each case creates its own records; there is no shared seed data. 4. After the run, teardown drops only the databases it created, even if tests left connections open. diff --git a/hub/package.json b/hub/package.json index f24effed..f6abbd26 100644 --- a/hub/package.json +++ b/hub/package.json @@ -3,6 +3,20 @@ "version": "0.1.0", "private": true, "type": "module", + "imports": { + "#server/*/constants": { + "development": "./server/*/constants.ts", + "default": "./dist/server/*/constants.js" + }, + "#server/*/types": { + "development": "./server/*/types.ts", + "default": "./dist/server/*/types.js" + }, + "#server/*": { + "development": "./server/*/index.ts", + "default": "./dist/server/*/index.js" + } + }, "engines": { "node": "^24.15.0 || ^26.0.0" }, @@ -22,7 +36,7 @@ "build": "npm run typecheck && vite build && tsc --project tsconfig.server.json", "dev": "npm run dev:client", "dev:client": "vite --host 127.0.0.1", - "dev:api": "node --watch server/index.ts", + "dev:api": "node --conditions=development --watch server/index.ts", "start": "node dist/server/index.js", "preview": "vite preview --host 127.0.0.1", "extension:assets:check": "node ../.github/extensions/replica-prototype/check-assets/index.mjs", diff --git a/hub/schema/authentication-provider.prisma b/hub/schema/authentication-provider.prisma new file mode 100644 index 00000000..4e9b4a32 --- /dev/null +++ b/hub/schema/authentication-provider.prisma @@ -0,0 +1,42 @@ +model AuthenticationProvider { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Relations + revisions AuthenticationProviderRevision[] + + // Attribute columns + key String @unique + type String + enabled Boolean @default(true) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("authentication_providers") +} + +model AuthenticationProviderRevision { + // ID columns + id String @id @default(uuid()) @db.Uuid + authenticationProviderId String @map("authentication_provider_id") @db.Uuid + + // Relations + authenticationProvider AuthenticationProvider @relation(fields: [authenticationProviderId], references: [id], onDelete: Cascade) + authorizationAttempts AuthorizationAttempt[] + sessions Session[] + + // Attribute columns + revision Int + issuer String + configuration Json + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([authenticationProviderId, revision]) + @@index([issuer]) + @@map("authentication_provider_revisions") +} diff --git a/hub/schema/authorization-attempt.prisma b/hub/schema/authorization-attempt.prisma new file mode 100644 index 00000000..251c6fde --- /dev/null +++ b/hub/schema/authorization-attempt.prisma @@ -0,0 +1,28 @@ +model AuthorizationAttempt { + // ID columns + id String @id @default(uuid()) @db.Uuid + authenticationProviderRevisionId String @map("authentication_provider_revision_id") @db.Uuid + originatingSessionId String? @map("originating_session_id") @db.Uuid + + // Relations + authenticationProviderRevision AuthenticationProviderRevision @relation(fields: [authenticationProviderRevisionId], references: [id], onDelete: Cascade) + originatingSession Session? @relation("OriginatingSession", fields: [originatingSessionId], references: [id], onDelete: Cascade) + + // Attribute columns + stateDigest String @unique @map("state_digest") + browserDigest String @map("browser_digest") + 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 + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([expiresAt]) + @@index([authenticationProviderRevisionId]) + @@index([originatingSessionId]) + @@map("authorization_attempts") +} diff --git a/hub/schema/external-identity.prisma b/hub/schema/external-identity.prisma new file mode 100644 index 00000000..8c7b25df --- /dev/null +++ b/hub/schema/external-identity.prisma @@ -0,0 +1,26 @@ +model ExternalIdentity { + // ID columns + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sessions Session[] + activatedInvitations MembershipInvitation[] @relation("ActivatedIdentity") + + // Attribute columns + provider String + issuer String + subject String + username String? + displayName String? @map("display_name") + avatarUrl String? @map("avatar_url") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([provider, issuer, subject]) + @@index([userId]) + @@map("external_identities") +} diff --git a/hub/schema/github-integration-state.prisma b/hub/schema/github-integration-state.prisma new file mode 100644 index 00000000..7349ad5a --- /dev/null +++ b/hub/schema/github-integration-state.prisma @@ -0,0 +1,14 @@ +model GitHubIntegrationState { + // ID columns + id String @id @db.Uuid + + // Attribute columns + generation Int @default(0) + encryptedCredentials String? @map("encrypted_credentials") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("github_integration_states") +} diff --git a/hub/schema/identity-store-state.prisma b/hub/schema/identity-store-state.prisma new file mode 100644 index 00000000..badf58f7 --- /dev/null +++ b/hub/schema/identity-store-state.prisma @@ -0,0 +1,14 @@ +model IdentityStoreState { + // ID columns + id String @id @db.Uuid + + // Attribute columns + sessionGeneration Int @default(0) @map("session_generation") + bootstrapCompletedAt DateTime? @map("bootstrap_completed_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("identity_store_states") +} diff --git a/hub/schema/installation-membership.prisma b/hub/schema/installation-membership.prisma new file mode 100644 index 00000000..623d965f --- /dev/null +++ b/hub/schema/installation-membership.prisma @@ -0,0 +1,24 @@ +model InstallationMembership { + // ID columns + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + invitations MembershipInvitation[] @relation("ActivatedMembership") + auditTargets SecurityAudit[] @relation("TargetMembership") + + // Attribute columns + status String + role String + source String + suspendedAt DateTime? @map("suspended_at") @db.Timestamptz(3) + removedAt DateTime? @map("removed_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([userId]) + @@map("installation_memberships") +} diff --git a/hub/schema/membership-invitation.prisma b/hub/schema/membership-invitation.prisma new file mode 100644 index 00000000..3e8e61e2 --- /dev/null +++ b/hub/schema/membership-invitation.prisma @@ -0,0 +1,31 @@ +model MembershipInvitation { + // ID columns + id String @id @default(uuid()) @db.Uuid + createdByUserId String? @map("created_by_user_id") @db.Uuid + activatedIdentityId String? @map("activated_identity_id") @db.Uuid + activatedMembershipId String? @map("activated_membership_id") @db.Uuid + + // Relations + createdByUser User? @relation("InvitationCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + activatedIdentity ExternalIdentity? @relation("ActivatedIdentity", fields: [activatedIdentityId], references: [id], onDelete: SetNull) + activatedMembership InstallationMembership? @relation("ActivatedMembership", fields: [activatedMembershipId], references: [id], onDelete: SetNull) + audits SecurityAudit[] + + // Attribute columns + provider String + issuer String + subject String + role String + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([provider, issuer, subject]) + @@index([expiresAt]) + @@index([createdByUserId]) + @@map("membership_invitations") +} diff --git a/hub/schema/migrations/20261026195151_identity/migration.sql b/hub/schema/migrations/20261026195151_identity/migration.sql new file mode 100644 index 00000000..602477f6 --- /dev/null +++ b/hub/schema/migrations/20261026195151_identity/migration.sql @@ -0,0 +1,203 @@ +-- CreateTable +CREATE TABLE "authentication_providers" ( + "id" UUID NOT NULL, + "key" TEXT NOT NULL, + "type" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "authentication_providers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "authentication_provider_revisions" ( + "id" UUID NOT NULL, + "authentication_provider_id" UUID NOT NULL, + "revision" INTEGER NOT NULL, + "issuer" TEXT NOT NULL, + "configuration" JSONB NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "authentication_provider_revisions_pkey" PRIMARY KEY ("id") +); + +-- 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, + + CONSTRAINT "identity_store_states_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "github_integration_states" ( + "id" UUID NOT NULL, + "generation" INTEGER NOT NULL DEFAULT 0, + "encrypted_credentials" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_integration_states_pkey" PRIMARY KEY ("id") +); + +-- SeedData +INSERT INTO "github_integration_states" ("id") VALUES ('00000000-0000-4000-8000-000000000001'); + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "display_name" TEXT, + "avatar_url" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "external_identities" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "provider" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "username" TEXT, + "display_name" TEXT, + "avatar_url" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "external_identities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "installation_memberships" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "status" TEXT NOT NULL, + "role" TEXT NOT NULL, + "source" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "installation_memberships_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "external_identity_id" UUID NOT NULL, + "authentication_provider_revision_id" UUID NOT NULL, + "token_digest" TEXT NOT NULL, + "signed_in_at" TIMESTAMPTZ(3) NOT NULL, + "fresh_authorized_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "authorization_attempts" ( + "id" UUID NOT NULL, + "authentication_provider_revision_id" UUID NOT NULL, + "originating_session_id" UUID, + "state_digest" TEXT NOT NULL, + "browser_digest" TEXT NOT NULL, + "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, + + CONSTRAINT "authorization_attempts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "security_audits" ( + "id" UUID NOT NULL, + "actor_user_id" UUID, + "session_id" UUID, + "authentication_provider_revision_id" UUID, + "event" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "security_audits_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "authentication_providers_key_key" ON "authentication_providers"("key"); + +-- CreateIndex +CREATE INDEX "authentication_provider_revisions_issuer_idx" ON "authentication_provider_revisions"("issuer"); + +-- CreateIndex +CREATE UNIQUE INDEX "authentication_provider_revisions_authentication_provider_i_key" ON "authentication_provider_revisions"("authentication_provider_id", "revision"); + +-- CreateIndex +CREATE INDEX "external_identities_user_id_idx" ON "external_identities"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "external_identities_provider_issuer_subject_key" ON "external_identities"("provider", "issuer", "subject"); + +-- CreateIndex +CREATE UNIQUE INDEX "installation_memberships_user_id_key" ON "installation_memberships"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_token_digest_key" ON "sessions"("token_digest"); + +-- CreateIndex +CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); + +-- CreateIndex +CREATE INDEX "sessions_external_identity_id_idx" ON "sessions"("external_identity_id"); + +-- CreateIndex +CREATE INDEX "sessions_authentication_provider_revision_id_idx" ON "sessions"("authentication_provider_revision_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "authorization_attempts_state_digest_key" ON "authorization_attempts"("state_digest"); + +-- CreateIndex +CREATE INDEX "authorization_attempts_expires_at_idx" ON "authorization_attempts"("expires_at"); + +-- CreateIndex +CREATE INDEX "authorization_attempts_authentication_provider_revision_id_idx" ON "authorization_attempts"("authentication_provider_revision_id"); + +-- CreateIndex +CREATE INDEX "authorization_attempts_originating_session_id_idx" ON "authorization_attempts"("originating_session_id"); + +-- CreateIndex +CREATE INDEX "security_audits_created_at_idx" ON "security_audits"("created_at"); + +-- AddForeignKey +ALTER TABLE "authentication_provider_revisions" ADD CONSTRAINT "authentication_provider_revisions_authentication_provider__fkey" FOREIGN KEY ("authentication_provider_id") REFERENCES "authentication_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "external_identities" ADD CONSTRAINT "external_identities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_external_identity_id_fkey" FOREIGN KEY ("external_identity_id") REFERENCES "external_identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_authentication_provider_revision_id_fkey" FOREIGN KEY ("authentication_provider_revision_id") REFERENCES "authentication_provider_revisions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "authorization_attempts" ADD CONSTRAINT "authorization_attempts_authentication_provider_revision_id_fkey" FOREIGN KEY ("authentication_provider_revision_id") REFERENCES "authentication_provider_revisions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "authorization_attempts" ADD CONSTRAINT "authorization_attempts_originating_session_id_fkey" FOREIGN KEY ("originating_session_id") REFERENCES "sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/hub/schema/migrations/20261027210000_membership_administration/migration.sql b/hub/schema/migrations/20261027210000_membership_administration/migration.sql new file mode 100644 index 00000000..8445d0f7 --- /dev/null +++ b/hub/schema/migrations/20261027210000_membership_administration/migration.sql @@ -0,0 +1,106 @@ +-- AlterTable +ALTER TABLE "identity_store_states" ADD COLUMN "bootstrap_completed_at" TIMESTAMPTZ(3); + +-- AlterTable +ALTER TABLE "installation_memberships" +ADD COLUMN "suspended_at" TIMESTAMPTZ(3), +ADD COLUMN "removed_at" TIMESTAMPTZ(3); + +-- Existing foundation records are active and must satisfy the lifecycle constraint. +UPDATE "installation_memberships" +SET "suspended_at" = CASE WHEN "status" = 'suspended' THEN CURRENT_TIMESTAMP ELSE NULL END; + +-- Replace the cascading user relation so retained membership and audit linkage cannot disappear. +ALTER TABLE "installation_memberships" DROP CONSTRAINT "installation_memberships_user_id_fkey"; +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_status" CHECK ("status" IN ('active', 'suspended', 'removed')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_role" CHECK ("role" IN ('member', 'administrator')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_source" CHECK ("source" IN ('bootstrap', 'invitation', 'manual', 'recovery', 'synchronized')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_lifecycle" CHECK ( + ("status" = 'active' AND "suspended_at" IS NULL AND "removed_at" IS NULL) OR + ("status" = 'suspended' AND "suspended_at" IS NOT NULL AND "removed_at" IS NULL) OR + ("status" = 'removed' AND "removed_at" IS NOT NULL) +); + +-- CreateTable +CREATE TABLE "membership_invitations" ( + "id" UUID NOT NULL, + "created_by_user_id" UUID, + "activated_identity_id" UUID, + "activated_membership_id" UUID, + "provider" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "role" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "consumed_at" TIMESTAMPTZ(3), + "revoked_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "membership_invitations_pkey" PRIMARY KEY ("id"), + CONSTRAINT "membership_invitations_role" CHECK ("role" IN ('member', 'administrator')), + CONSTRAINT "membership_invitations_lifecycle" CHECK (NOT ("consumed_at" IS NOT NULL AND "revoked_at" IS NOT NULL)) +); + +-- CreateIndex +CREATE INDEX "membership_invitations_provider_issuer_subject_idx" ON "membership_invitations"("provider", "issuer", "subject"); + +-- CreateIndex +CREATE INDEX "membership_invitations_expires_at_idx" ON "membership_invitations"("expires_at"); + +-- CreateIndex +CREATE INDEX "membership_invitations_created_by_user_id_idx" ON "membership_invitations"("created_by_user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "membership_invitations_usable_assignment_key" ON "membership_invitations"("provider", "issuer", "subject") WHERE "consumed_at" IS NULL AND "revoked_at" IS NULL; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_activated_identity_id_fkey" FOREIGN KEY ("activated_identity_id") REFERENCES "external_identities"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_activated_membership_id_fkey" FOREIGN KEY ("activated_membership_id") REFERENCES "installation_memberships"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "security_audits" +ADD COLUMN "target_membership_id" UUID, +ADD COLUMN "invitation_id" UUID, +ADD COLUMN "outcome" TEXT NOT NULL DEFAULT 'succeeded', +ADD COLUMN "context" JSONB NOT NULL DEFAULT '{}'; + +UPDATE "security_audits" +SET "outcome" = 'denied' +WHERE "event" = 'authentication.admission_denied'; + +UPDATE "security_audits" +SET "outcome" = 'failed' +WHERE "event" = 'authentication.provider_failed'; + +-- AddCheckConstraint +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_outcome" CHECK ("outcome" IN ('succeeded', 'denied', 'failed')); + +-- AddCheckConstraint +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_context" CHECK (jsonb_typeof("context") = 'object'); + +-- CreateIndex +CREATE INDEX "security_audits_target_membership_id_idx" ON "security_audits"("target_membership_id"); + +-- CreateIndex +CREATE INDEX "security_audits_invitation_id_idx" ON "security_audits"("invitation_id"); + +-- AddForeignKey +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_target_membership_id_fkey" FOREIGN KEY ("target_membership_id") REFERENCES "installation_memberships"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_invitation_id_fkey" FOREIGN KEY ("invitation_id") REFERENCES "membership_invitations"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/hub/schema/migrations/migration_lock.toml b/hub/schema/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/hub/schema/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma new file mode 100644 index 00000000..e3bcd5ab --- /dev/null +++ b/hub/schema/security-audit.prisma @@ -0,0 +1,27 @@ +model SecurityAudit { + // ID columns + id String @id @default(uuid()) @db.Uuid + actorUserId String? @map("actor_user_id") @db.Uuid + sessionId String? @map("session_id") @db.Uuid + authenticationProviderRevisionId String? @map("authentication_provider_revision_id") @db.Uuid + targetMembershipId String? @map("target_membership_id") @db.Uuid + invitationId String? @map("invitation_id") @db.Uuid + + // Relations + targetMembership InstallationMembership? @relation("TargetMembership", fields: [targetMembershipId], references: [id], onDelete: SetNull) + invitation MembershipInvitation? @relation(fields: [invitationId], references: [id], onDelete: SetNull) + + // Attribute columns + event String + outcome String @default("succeeded") + context Json @default("{}") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([createdAt]) + @@index([targetMembershipId]) + @@index([invitationId]) + @@map("security_audits") +} diff --git a/hub/schema/session.prisma b/hub/schema/session.prisma new file mode 100644 index 00000000..bd8a5d29 --- /dev/null +++ b/hub/schema/session.prisma @@ -0,0 +1,27 @@ +model Session { + // ID columns + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + externalIdentityId String @map("external_identity_id") @db.Uuid + authenticationProviderRevisionId String @map("authentication_provider_revision_id") @db.Uuid + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + externalIdentity ExternalIdentity @relation(fields: [externalIdentityId], references: [id], onDelete: Restrict) + authenticationProviderRevision AuthenticationProviderRevision @relation(fields: [authenticationProviderRevisionId], references: [id], onDelete: Restrict) + originatingAuthorizationAttempts AuthorizationAttempt[] @relation("OriginatingSession") + + // Attribute columns + tokenDigest String @unique @map("token_digest") + signedInAt DateTime @map("signed_in_at") @db.Timestamptz(3) + freshAuthorizedAt DateTime? @map("fresh_authorized_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([userId]) + @@index([externalIdentityId]) + @@index([authenticationProviderRevisionId]) + @@map("sessions") +} diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma new file mode 100644 index 00000000..8ff2cd32 --- /dev/null +++ b/hub/schema/user.prisma @@ -0,0 +1,20 @@ +model User { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Relations + externalIdentities ExternalIdentity[] + memberships InstallationMembership[] + sessions Session[] + createdInvitations MembershipInvitation[] @relation("InvitationCreator") + + // Attribute columns + displayName String? @map("display_name") + avatarUrl String? @map("avatar_url") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("users") +} diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index a3954deb..a78512b8 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -4,16 +4,23 @@ 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" /** 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.set("trust proxy", authentication?.settings.trustedProxyHops ?? 0) app.use(createRequestLogger(logger)) + app.use(["/api/auth", "/api/session", "/api/administration"], omitRequestLogPayload) app.use(express.json()) - routes(app) + routes(app, authentication) app.use(respondNotFound) app.use(handleError) diff --git a/hub/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts new file mode 100644 index 00000000..9eea76ce --- /dev/null +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createAdministrationService } from "#server/domains/administration/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, + subject: string, + token: string, + options: { + role?: "member" | "administrator" + freshAuthorizedAt?: Date | null + } = {}, +) { + const authorizedAt = new Date("2026-10-27T18:00:00.000Z") + const role = options.role ?? "administrator" + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: `${role === "administrator" ? "Administrator" : "Member"} ${subject}`, + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject, + username: `${role}-${subject}`, + displayName: user.displayName, + avatarUrl: null, + }) + const provider = + (await records.currentAuthenticationProviderRevision("github")) ?? + (await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + })) + const membership = await records.saveMembership({ + userId: user.id, + status: "active", + role, + source: "manual", + suspendedAt: null, + removedAt: null, + }) + const session = await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: + options.freshAuthorizedAt === undefined ? authorizedAt : options.freshAuthorizedAt, + }) + return { membership, session, user } + }) +} + +describe("createAdministrationService() database behavior", () => { + it("audits a member's denied invitation without creating it", async () => { + const { store } = await database() + await createAdministrator(store, "40", "member-token", { role: "member" }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.invite("member-token", { + provider: "github", + issuer: "https://github.com", + subject: "99", + role: "member", + }), + ).rejects.toMatchObject({ code: "administrator_required" }) + + expect(await store.read.invitations()).toEqual({ + invitations: [], + nextCursor: null, + }) + const [audit] = await store.read.audits() + expect(audit).toMatchObject({ outcome: "denied" }) + expect(audit?.context).toEqual({ reason: "administrator_required" }) + }) + + it("audits stale authorization without applying the requested membership change", async () => { + const { store } = await database() + const administrator = await createAdministrator(store, "41", "stale-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:05:00.000Z"), + }) + + await expect( + service.changeMembership("stale-token", administrator.membership.id, { + role: "member", + }), + ).rejects.toMatchObject({ code: "fresh_authorization_required" }) + + expect(await store.read.membership(administrator.user.id)).toMatchObject({ + status: "active", + role: "administrator", + }) + const [audit] = await store.read.audits() + expect(audit).toMatchObject({ outcome: "denied" }) + expect(audit?.context).toEqual({ reason: "fresh_authorization_required" }) + }) + + it("audits the final-administrator denial without changing membership or sessions", async () => { + const { store } = await database() + const administrator = await createAdministrator(store, "42", "final-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("final-token", administrator.membership.id, { + status: "suspended", + }), + ).rejects.toMatchObject({ code: "last_administrator" }) + + expect(await store.read.membership(administrator.user.id)).toMatchObject({ + status: "active", + role: "administrator", + suspendedAt: null, + }) + expect(await store.read.session(digestSecret("final-token"))).toMatchObject({ + id: administrator.session.id, + }) + const [audit] = await store.read.audits() + expect(audit).toMatchObject({ outcome: "denied" }) + expect(audit?.context).toEqual({ reason: "last_administrator" }) + }) + + it("audits malformed and missing administration targets without partial mutations", async () => { + const { store } = await database() + await createAdministrator(store, "43", "administrator-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.revokeInvitation("administrator-token", "not-a-uuid"), + ).rejects.toMatchObject({ code: "invitation_changed" }) + await expect( + service.changeMembership("administrator-token", "00000000-0000-4000-8000-000000000099", { + role: "member", + }), + ).rejects.toMatchObject({ code: "membership_not_found" }) + + expect(await store.read.invitations()).toEqual({ + invitations: [], + nextCursor: null, + }) + expect(await store.read.activeAdministratorCount()).toBe(1) + const audits = await store.read.audits() + expect(audits.every(({ outcome }) => outcome === "denied")).toBe(true) + expect(audits.map(({ context }) => context)).toEqual( + expect.arrayContaining([ + { reason: "invitation_changed" }, + { reason: "membership_not_found" }, + ]), + ) + expect(audits).toHaveLength(2) + }) + + it("preserves the suspension timestamp when only the suspended member's role changes", async () => { + const { store } = await database() + await createAdministrator(store, "41", "administrator-token") + const suspendedAt = new Date("2026-10-20T12:00:00.000Z") + const target = await store.lock(async (records) => { + const user = await records.createUser({ + displayName: "Suspended member", + avatarUrl: null, + }) + return records.saveMembership({ + userId: user.id, + status: "suspended", + role: "member", + source: "manual", + suspendedAt, + removedAt: null, + }) + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await service.changeMembership("administrator-token", target.id, { + role: "administrator", + }) + + expect(await store.read.membership(target.userId)).toMatchObject({ + status: "suspended", + role: "administrator", + suspendedAt, + removedAt: null, + }) + }) + + it("audits both changes when restoring a suspended member as an administrator", async () => { + const { store } = await database() + await createAdministrator(store, "44", "administrator-token") + const target = await store.lock(async (records) => { + const user = await records.createUser({ + displayName: "Suspended member", + avatarUrl: null, + }) + return records.saveMembership({ + userId: user.id, + status: "suspended", + role: "member", + source: "manual", + suspendedAt: new Date("2026-10-20T12:00:00.000Z"), + removedAt: null, + }) + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await service.changeMembership("administrator-token", target.id, { + role: "administrator", + status: "active", + }) + + expect(await store.read.membership(target.userId)).toMatchObject({ + status: "active", + role: "administrator", + suspendedAt: null, + removedAt: null, + }) + const audits = (await store.read.audits()).filter( + ({ event, targetMembershipId }) => + event === "membership.change" && targetMembershipId === target.id, + ) + expect(audits.map(({ context }) => context)).toEqual( + expect.arrayContaining([{ reason: "status_active" }, { reason: "role_administrator" }]), + ) + expect(audits).toHaveLength(2) + }) + + it("serializes overlapping suspensions so one active administrator and its session remain", async () => { + const { store } = await database() + const first = await createAdministrator(store, "41", "first-token") + const second = await createAdministrator(store, "42", "second-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + const results = await Promise.allSettled([ + service.changeMembership("first-token", second.membership.id, { + status: "suspended", + }), + service.changeMembership("second-token", first.membership.id, { + status: "suspended", + }), + ]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1) + expect(await store.read.activeAdministratorCount()).toBe(1) + + const { memberships } = await store.read.memberships() + const active = memberships.find(({ status }) => status === "active") + const suspended = memberships.find(({ status }) => status === "suspended") + expect(active).toBeDefined() + expect(suspended).toBeDefined() + const activeToken = active?.id === first.membership.id ? "first-token" : "second-token" + const suspendedToken = suspended?.id === first.membership.id ? "first-token" : "second-token" + expect(await store.read.session(digestSecret(activeToken))).toBeTruthy() + expect(await store.read.session(digestSecret(suspendedToken))).toBeNull() + const audits = await store.read.audits() + expect( + audits.find( + ({ event, outcome, targetMembershipId }) => + event === "membership.change" && + outcome === "succeeded" && + targetMembershipId === suspended?.id, + ), + ).toMatchObject({ context: { reason: "status_suspended" } }) + expect(audits.every(({ outcome }) => outcome === "succeeded" || outcome === "denied")).toBe( + true, + ) + }) +}) diff --git a/hub/server/domains/administration/create-service/index.test.ts b/hub/server/domains/administration/create-service/index.test.ts new file mode 100644 index 00000000..be319663 --- /dev/null +++ b/hub/server/domains/administration/create-service/index.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest" +import type { Mocked } from "vitest" + +import { createAdministrationService } from "#server/domains/administration/create-service" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import { digestSecret } from "#server/utils/digest-secret" + +const now = new Date("2026-10-27T18:00:00.000Z") +const administratorMembership = { + id: "00000000-0000-4000-8000-000000000001", + userId: "administrator", + status: "active" as const, + role: "administrator" as const, + source: "manual", + suspendedAt: null, + removedAt: null, +} +const administratorSession = { + id: "session", + userId: "administrator", + externalIdentityId: "identity", + authenticationProviderRevisionId: "revision", + tokenDigest: digestSecret("session-token"), + signedInAt: now, + freshAuthorizedAt: now, + user: { id: "administrator", displayName: "Administrator", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "administrator", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "administrator", + displayName: "Administrator", + avatarUrl: null, + }, + authenticationProviderRevision: { + id: "revision", + authenticationProviderId: "provider", + revision: 1, + issuer: "https://github.com", + configuration: {}, + authenticationProvider: { + id: "provider", + key: "github", + type: "github", + enabled: true, + }, + }, +} + +function records(overrides: Partial> = {}) { + return Object.assign( + { + session: vi.fn().mockResolvedValue(administratorSession), + sessionById: vi.fn().mockResolvedValue(administratorSession), + membership: vi.fn().mockResolvedValue(administratorMembership), + membershipById: vi.fn().mockResolvedValue(null), + activeAdministratorCount: vi.fn().mockResolvedValue(2), + revokeExpiredInvitations: vi.fn(), + externalIdentity: vi.fn().mockResolvedValue(null), + usableInvitation: vi.fn().mockResolvedValue(null), + createInvitation: vi.fn(), + revokeInvitation: vi.fn().mockResolvedValue(false), + saveMembership: vi.fn(), + deleteUserSessions: vi.fn(), + audit: vi.fn(), + }, + overrides, + ) +} + +function service(identityRecords: ReturnType) { + const typedRecords = identityRecords as unknown as IdentityRecords + const store: IdentityStore = { + read: typedRecords, + lock: (operation) => operation(typedRecords), + } + return createAdministrationService({ store, now: () => now }) +} + +describe("createAdministrationService()", () => { + it("rejects a future fresh authorization timestamp", async () => { + const identityRecords = records({ + session: vi.fn().mockResolvedValue({ + ...administratorSession, + freshAuthorizedAt: new Date(now.getTime() + 1), + }), + }) + + await expect( + service(identityRecords).invite("session-token", { + provider: "github", + issuer: "https://github.com", + subject: "99", + role: "member", + }), + ).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + message: "Verify your identity again.", + }) + expect(identityRecords.createInvitation).not.toHaveBeenCalled() + }) + + it("rejects a malformed invitation ID with the stable unavailable response", async () => { + const identityRecords = records() + + await expect( + service(identityRecords).revokeInvitation("session-token", "not-a-uuid"), + ).rejects.toMatchObject({ + status: 409, + code: "invitation_changed", + message: "The invitation is no longer available.", + }) + expect(identityRecords.revokeInvitation).not.toHaveBeenCalled() + }) + + it("rejects malformed and missing membership IDs with the stable not-found response", async () => { + const identityRecords = records() + const administration = service(identityRecords) + + await expect( + administration.changeMembership("session-token", "not-a-uuid", { + role: "member", + }), + ).rejects.toMatchObject({ + status: 404, + code: "membership_not_found", + message: "The membership was not found.", + }) + expect(identityRecords.membershipById).not.toHaveBeenCalled() + + await expect( + administration.changeMembership("session-token", "00000000-0000-4000-8000-000000000099", { + role: "member", + }), + ).rejects.toMatchObject({ + status: 404, + code: "membership_not_found", + message: "The membership was not found.", + }) + expect(identityRecords.membershipById).toHaveBeenCalledWith( + "00000000-0000-4000-8000-000000000099", + ) + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + }) +}) diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts new file mode 100644 index 00000000..a24d18fb --- /dev/null +++ b/hub/server/domains/administration/create-service/index.ts @@ -0,0 +1,362 @@ +import { z } from "zod" + +import type { + AdministrationDependencies, + AdministrationService, +} from "#server/domains/administration/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { AuthenticationError } from "#server/utils/authentication-error" + +const invitationSchema = z.object({ + provider: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/), + issuer: z.url().max(500), + subject: z.string().trim().min(1).max(500), + role: z.enum(["member", "administrator"]), +}) + +const membershipChangeSchema = z + .object({ + role: z.enum(["member", "administrator"]).optional(), + status: z.enum(["active", "suspended", "removed"]).optional(), + }) + .refine(({ role, status }) => role !== undefined || status !== undefined) + +const idSchema = z.uuid() +const cursorSchema = z.object({ + memberships: z.uuid().nullable().optional(), + invitations: z.uuid().nullable().optional(), +}) + +type CurrentSession = Awaited> + +interface DenialAudit { + event: string +} + +async function getAdministrationSession( + dependencies: AdministrationDependencies, + sessionToken: string, +) { + return dependencies.store.lock(async (records) => { + return getCurrentSession( + { + store: dependencies.store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(false) }, + now: dependencies.now, + }, + sessionToken, + records, + ) + }) +} + +function requireAdministrator( + dependencies: AdministrationDependencies, + current: CurrentSession, + fresh = true, +) { + if (current.membership.role !== "administrator") { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + const freshnessAge = current.freshAuthorizedAt + ? dependencies.now().getTime() - current.freshAuthorizedAt.getTime() + : null + if (fresh && (freshnessAge === null || freshnessAge < 0 || freshnessAge >= 5 * 60_000)) { + throw new AuthenticationError( + 403, + "fresh_authorization_required", + "Verify your identity again.", + ) + } +} + +async function runAuditedMutation( + dependencies: AdministrationDependencies, + sessionToken: string, + audit: DenialAudit, + operation: (current: CurrentSession) => Promise, +): Promise { + let current: CurrentSession | null = null + try { + current = await getAdministrationSession(dependencies, sessionToken) + requireAdministrator(dependencies, current) + return await operation(current) + } catch (error) { + if (current && error instanceof AuthenticationError) { + const deniedBy = current + await dependencies.store.lock((records) => + records.audit({ + event: audit.event, + outcome: "denied", + actorUserId: deniedBy.userId, + sessionId: deniedBy.id, + authenticationProviderRevisionId: deniedBy.authenticationProviderRevisionId, + context: { reason: error.code }, + }), + ) + } + throw error + } +} + +export function createAdministrationService( + options: Omit & { now?: () => Date }, +): AdministrationService { + const dependencies: AdministrationDependencies = { + ...options, + now: options.now ?? (() => new Date()), + } + + return { + async list(sessionToken, cursors = {}) { + const parsedCursors = cursorSchema.safeParse(cursors) + if (!parsedCursors.success) { + throw new AuthenticationError( + 422, + "invalid_administration_cursor", + "The administration cursor is invalid.", + ) + } + const current = await getAdministrationSession(dependencies, sessionToken) + requireAdministrator(dependencies, current, false) + const [memberships, invitations] = await Promise.all([ + dependencies.store.read.memberships(parsedCursors.data.memberships), + dependencies.store.read.invitations(parsedCursors.data.invitations), + ]) + return { memberships, invitations } + }, + + async invite(sessionToken, input) { + return runAuditedMutation( + dependencies, + sessionToken, + { event: "membership.invitation.create" }, + async (current) => { + const parsed = invitationSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError(422, "invalid_invitation", "The invitation is invalid.") + } + return dependencies.store.lock(async (records) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + await records.revokeExpiredInvitations(dependencies.now()) + const identity = await records.externalIdentity( + parsed.data.provider, + parsed.data.issuer, + parsed.data.subject, + ) + const membership = identity ? await records.membership(identity.userId) : null + if (membership && membership.status !== "removed") { + throw new AuthenticationError( + 409, + "identity_already_assigned", + "This identity already has a membership.", + ) + } + const existing = await records.usableInvitation( + parsed.data.provider, + parsed.data.issuer, + parsed.data.subject, + dependencies.now(), + ) + if (existing) { + throw new AuthenticationError( + 409, + "invitation_exists", + "An invitation already exists.", + ) + } + const invitation = await records.createInvitation({ + ...parsed.data, + createdByUserId: actor.userId, + expiresAt: new Date(dependencies.now().getTime() + 7 * 24 * 60 * 60_000), + }) + await records.audit({ + event: "membership.invitation.create", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + invitationId: invitation.id, + }) + return invitation + }) + }, + ) + }, + + async revokeInvitation(sessionToken, invitationId) { + const parsedId = idSchema.safeParse(invitationId) + await runAuditedMutation( + dependencies, + sessionToken, + { event: "membership.invitation.revoke" }, + async (current) => { + if (!parsedId.success) { + throw new AuthenticationError( + 409, + "invitation_changed", + "The invitation is no longer available.", + ) + } + await dependencies.store.lock(async (records) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + if (!(await records.revokeInvitation(parsedId.data, dependencies.now()))) { + throw new AuthenticationError( + 409, + "invitation_changed", + "The invitation is no longer available.", + ) + } + await records.audit({ + event: "membership.invitation.revoke", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + invitationId: parsedId.data, + }) + }) + }, + ) + }, + + async changeMembership(sessionToken, membershipId, input) { + const parsedId = idSchema.safeParse(membershipId) + return runAuditedMutation( + dependencies, + sessionToken, + { event: "membership.change" }, + async (current) => { + const parsed = membershipChangeSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_membership_change", + "The membership change is invalid.", + ) + } + if (!parsedId.success) { + throw new AuthenticationError( + 404, + "membership_not_found", + "The membership was not found.", + ) + } + return dependencies.store.lock(async (records) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + const target = await records.membershipById(parsedId.data) + if (!target) { + throw new AuthenticationError( + 404, + "membership_not_found", + "The membership was not found.", + ) + } + if (target.status === "removed") { + throw new AuthenticationError( + 409, + "membership_removed", + "Removed memberships require a new invitation.", + ) + } + const nextRole = parsed.data.role ?? target.role + const nextStatus = parsed.data.status ?? target.status + if (nextRole === target.role && nextStatus === target.status) { + throw new AuthenticationError( + 409, + "membership_unchanged", + "The membership already has this role and status.", + ) + } + const removesAdministrator = + target.status === "active" && + target.role === "administrator" && + (nextStatus !== "active" || nextRole !== "administrator") + if (removesAdministrator && (await records.activeAdministratorCount()) <= 1) { + throw new AuthenticationError( + 409, + "last_administrator", + "The final active administrator cannot be changed.", + ) + } + const changed = await records.saveMembership({ + userId: target.userId, + role: nextRole, + status: nextStatus, + source: target.source, + suspendedAt: + nextStatus === "suspended" + ? target.status === "suspended" + ? target.suspendedAt + : dependencies.now() + : null, + removedAt: nextStatus === "removed" ? dependencies.now() : null, + }) + if (nextStatus !== "active") { + await records.deleteUserSessions(target.userId) + } + const reasons = [ + nextRole !== target.role ? `role_${nextRole}` : null, + nextStatus !== target.status ? `status_${nextStatus}` : null, + ].filter((reason): reason is string => reason !== null) + for (const reason of reasons) { + await records.audit({ + event: "membership.change", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + targetMembershipId: target.id, + context: { reason }, + }) + } + return changed + }) + }, + ) + }, + } +} diff --git a/hub/server/domains/administration/create-service/types.ts b/hub/server/domains/administration/create-service/types.ts new file mode 100644 index 00000000..4d55b0bc --- /dev/null +++ b/hub/server/domains/administration/create-service/types.ts @@ -0,0 +1,31 @@ +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { + InstallationMembershipPage, + InstallationMembershipRecord, +} from "#server/repositories/installation-memberships/types" +import type { + MembershipInvitationPage, + MembershipInvitationRecord, +} from "#server/repositories/membership-invitations/types" + +export interface AdministrationService { + list( + sessionToken: string, + cursors?: { memberships?: string | null; invitations?: string | null }, + ): Promise<{ + memberships: InstallationMembershipPage + invitations: MembershipInvitationPage + }> + invite(sessionToken: string, input: unknown): Promise + revokeInvitation(sessionToken: string, invitationId: string): Promise + changeMembership( + sessionToken: string, + membershipId: string, + input: unknown, + ): Promise +} + +export interface AdministrationDependencies { + store: IdentityStore + now: () => Date +} 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..b6f7ea77 --- /dev/null +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -0,0 +1,307 @@ +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.", + ) + } + if (attempt.purpose === "link-identity") { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid.", + ) + } + const authorizationAttempt = attempt + const purpose: AuthorizationPurpose = attempt.purpose + let policyAdmitted = false + async function reportProviderFailure(error: unknown): Promise { + await store.lock((records) => + records.audit({ + event: "authentication.provider_failed", + outcome: "failed", + actorUserId: null, + sessionId: authorizationAttempt.originatingSessionId, + authenticationProviderRevisionId: providerRevisionId, + }), + ) + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "authentication_provider_failed", + "The authentication provider failed.", + ) + } + const profile: GitHubUserProfile = await github + .identify(code, authorizationAttempt.pkceVerifier) + .catch(reportProviderFailure) + if (authorizationAttempt.purpose === "sign-in") { + const policyNeeded = await store.lock(async (records) => { + const identity = await records.externalIdentity( + "github", + configuration.issuer, + profile.subject, + ) + const membership = identity ? await records.membership(identity.userId) : null + const invitation = + !membership || membership.status === "removed" + ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) + : null + return !membership && !invitation + }) + if (policyNeeded) { + try { + policyAdmitted = await admissionPolicy.admit(profile) + } catch (error) { + await reportProviderFailure(error) + } + } + } + const result = await 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 (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", + outcome: "succeeded", + actorUserId: session.userId, + sessionId: session.id, + authenticationProviderRevisionId: providerRevisionId, + }) + return { purpose: "verify" as const } + } + let identity = await records.externalIdentity("github", configuration.issuer, profile.subject) + let membership = identity ? await records.membership(identity.userId) : null + const invitation = + purpose === "sign-in" && membership?.status !== "active" && membership?.status !== "suspended" + ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) + : null + if ( + purpose === "sign-in" && + membership?.status !== "active" && + !invitation && + !(policyAdmitted && !membership) + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership?.id ?? null, + context: { reason: membership?.status ?? "unassigned" }, + }) + return { denied: true as const } + } + if (purpose === "bootstrap") { + const activeAdministrators = await records.activeAdministratorCount() + const completed = activeAdministrators === 0 && (await records.completeBootstrap(now())) + if (!completed) { + await records.audit({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason: "unavailable" }, + }) + return { denied: true as const } + } + } else if (purpose === "recovery" && (await records.activeAdministratorCount()) > 0) { + await records.audit({ + event: "membership.recovery", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason: "active_administrator_exists" }, + }) + return { denied: true as const } + } + 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, + }) + } + membership = await records.membership(identity.userId) + if (purpose === "bootstrap") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "administrator", + source: "bootstrap", + suspendedAt: null, + removedAt: null, + }) + await records.audit({ + event: "membership.bootstrap", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + }) + } else if (purpose === "recovery") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "administrator", + source: "recovery", + suspendedAt: null, + removedAt: null, + }) + await records.audit({ + event: "membership.recovery", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + }) + } else if (!membership || membership.status !== "active") { + if (invitation && membership?.status !== "suspended") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: invitation.role, + source: "invitation", + suspendedAt: null, + removedAt: null, + }) + const consumed = await records.consumeInvitation( + invitation.id, + identity.id, + membership.id, + now(), + ) + if (!consumed) { + throw new AuthenticationError( + 409, + "invitation_changed", + "The invitation changed. Start again.", + ) + } + await records.audit({ + event: "membership.invitation.activate", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + invitationId: invitation.id, + }) + } else if (policyAdmitted && !membership) { + await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "member", + source: "synchronized", + suspendedAt: null, + removedAt: null, + }) + } + } + 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", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: session.id, + authenticationProviderRevisionId: providerRevisionId, + }) + return { sessionToken, purpose, denied: false as const } + }) + if ("denied" in result && result.denied) { + throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") + } + return result +} 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..309e3acb --- /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(false) } + } + 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..cd6a7d1b --- /dev/null +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -0,0 +1,97 @@ +import { createAdmissionPolicy } from "#server/domains/authentication/create-admission-policy" +import { createAdministrationService } from "#server/domains/administration/create-service" +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 { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" +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", + } +} + +function readHostSecret(environment: NodeJS.ProcessEnv, key: string): string | null { + const value = environment[key] + if (!value) return null + if (value.length < 32 || value.length > 1_024) { + throw new Error(`${key} must contain between 32 and 1024 characters.`) + } + return value +} + +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 bootstrapSecret = readHostSecret(environment, "HUB_BOOTSTRAP_SECRET") + const recoverySecret = readHostSecret(environment, "HUB_ADMIN_RECOVERY_SECRET") + if (bootstrapSecret && recoverySecret && bootstrapSecret === recoverySecret) { + throw new Error("HUB_BOOTSTRAP_SECRET and HUB_ADMIN_RECOVERY_SECRET must be different.") + } + const settings: AuthenticationSettings = { + origin: origin.origin, + secureCookies: origin.protocol === "https:", + configured: configuration !== null, + admissionPolicy: admissionPolicy.name, + trustedProxyHops: readTrustedProxyHops(environment), + } + return { + administration: createAdministrationService({ store }), + service: createAuthenticationService({ + store, + github: configuration ? createGitHubUserAuthClient(configuration) : null, + configuration, + providerRevisionId: providerRevision?.id ?? null, + admissionPolicy, + bootstrapSecret, + recoverySecret, + }), + 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..fe6eb96a --- /dev/null +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -0,0 +1,193 @@ +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 type { GitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client/types" +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", + suspendedAt: null, + removedAt: null, + }) + 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.each(["bootstrap", "recovery"] as const)( + "consumes a real %s attempt and completes administrator enrollment", + async (purpose) => { + const { store } = await database() + const provider = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + }), + ) + const github: GitHubUserAuthClient = { + authorizationUrl: (state) => `https://github.example/authorize?state=${state}`, + identify: vi.fn().mockResolvedValue({ + subject: `enrolled-${purpose}`, + username: `enrolled-${purpose}`, + displayName: `Enrolled ${purpose}`, + avatarUrl: null, + }), + } + const secret = `${purpose}-secret` + const now = new Date() + const service = createAuthenticationService({ + store, + github, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider.id, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(false) }, + bootstrapSecret: purpose === "bootstrap" ? secret : null, + recoverySecret: purpose === "recovery" ? secret : null, + now: () => now, + }) + + const started = await service.start(purpose, undefined, secret) + const state = new URL(started.authorizationUrl).searchParams.get("state") + expect(state).toBeTruthy() + + const completed = await service.complete("one-use-code", state!, started.browserToken) + + expect(completed.purpose).toBe(purpose) + expect(typeof completed.sessionToken).toBe("string") + const identity = await store.read.externalIdentity( + "github", + "https://github.com", + `enrolled-${purpose}`, + ) + expect(identity).not.toBeNull() + expect(await store.read.membership(identity!.userId)).toMatchObject({ + status: "active", + role: "administrator", + source: purpose, + }) + expect(await store.read.session(digestSecret(completed.sessionToken!))).toMatchObject({ + userId: identity!.userId, + }) + }, + ) + + 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..4c0681bd --- /dev/null +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -0,0 +1,790 @@ +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), + usableInvitation: vi.fn().mockResolvedValue(null), + consumeInvitation: vi.fn().mockResolvedValue(true), + bootstrapCompletedAt: vi.fn().mockResolvedValue(null), + activeAdministratorCount: vi.fn().mockResolvedValue(0), + completeBootstrap: vi.fn().mockResolvedValue(true), + saveMembership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "synchronized", + suspendedAt: null, + removedAt: 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, { + admissionPolicy: { + name: "hub-managed", + admit: vi.fn().mockResolvedValue(false), + }, + }) + 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("activates only an exact provider identity invitation before issuing a session", async () => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const invitation = { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "github", + issuer: configuration.issuer, + subject: "42", + role: "administrator" as const, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + usableInvitation: vi.fn().mockResolvedValue(invitation), + updateUser: vi.fn().mockResolvedValue(existingIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(existingIdentity), + saveMembership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "administrator", + source: "invitation", + suspendedAt: null, + removedAt: null, + }), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "hub-managed", + admit: vi.fn().mockResolvedValue(false), + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + const result = await createAuthenticationService(options).complete( + "code", + "state", + "browser-token", + ) + expect(result.purpose).toBe("sign-in") + expect(typeof result.sessionToken).toBe("string") + + expect(identityRecords.usableInvitation).toHaveBeenCalledWith( + "github", + configuration.issuer, + "42", + now, + ) + expect(identityRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + }) + + it("admits an active Hub member without consulting legacy repository policy", async () => { + const identity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(identity), + membership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "invitation", + suspendedAt: null, + removedAt: null, + }), + updateUser: vi.fn().mockResolvedValue(identity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(identity), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + 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: "Octocat", + avatarUrl: null, + }) + + const completed = await createAuthenticationService(options).complete( + "code", + "state", + "browser-token", + ) + expect(completed.purpose).toBe("sign-in") + expect(typeof completed.sessionToken).toBe("string") + expect(admissionPolicy.admit).not.toHaveBeenCalled() + }) + + it("uses an administrator invitation before legacy repository admission", async () => { + const invitation = { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "github", + issuer: configuration.issuer, + subject: "42", + role: "administrator" as const, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + usableInvitation: vi.fn().mockResolvedValue(invitation), + createUser: vi.fn().mockResolvedValue({ + id: "user", + displayName: "Octocat", + avatarUrl: null, + }), + saveExternalIdentity: vi.fn().mockResolvedValue({ + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }), + saveMembership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "administrator", + source: "invitation", + suspendedAt: null, + removedAt: null, + }), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const admissionPolicy = { + name: "legacy-repository" as const, + admit: vi.fn().mockResolvedValue(true), + } + const options = dependencies(identityRecords, { admissionPolicy }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await createAuthenticationService(options).complete("code", "state", "browser-token") + + expect(admissionPolicy.admit).not.toHaveBeenCalled() + expect(identityRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ role: "administrator", source: "invitation" }), + ) + expect(identityRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + }) + + it("uses a new invitation to readmit a removed member before legacy repository admission", async () => { + const identity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const invitation = { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "github", + issuer: configuration.issuer, + subject: "42", + role: "member" as const, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } + const removedMembership = { + id: "membership", + userId: "user", + status: "removed" as const, + role: "member" as const, + source: "manual", + suspendedAt: null, + removedAt: new Date(now.getTime() - 60_000), + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(identity), + membership: vi.fn().mockResolvedValue(removedMembership), + usableInvitation: vi.fn().mockResolvedValue(invitation), + updateUser: vi.fn().mockResolvedValue(identity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(identity), + saveMembership: vi.fn().mockResolvedValue({ + ...removedMembership, + status: "active", + source: "invitation", + removedAt: null, + }), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const admissionPolicy = { + name: "legacy-repository" as const, + admit: vi.fn().mockResolvedValue(true), + } + const options = dependencies(identityRecords, { admissionPolicy }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + const completed = await createAuthenticationService(options).complete( + "code", + "state", + "browser-token", + ) + expect(typeof completed.sessionToken).toBe("string") + expect(admissionPolicy.admit).not.toHaveBeenCalled() + expect(identityRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + status: "active", + role: "member", + source: "invitation", + removedAt: null, + }), + ) + expect(identityRecords.consumeInvitation).toHaveBeenCalledOnce() + }) + + it("binds bootstrap enrollment to the configured one-use secret", async () => { + const identityRecords = records() + const service = createAuthenticationService({ + ...dependencies(identityRecords), + bootstrapSecret: "bootstrap-secret", + }) + + await expect(service.start("bootstrap", undefined, "wrong-secret")).rejects.toMatchObject({ + status: 404, + code: "bootstrap_unavailable", + }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { reason: "secret_mismatch" }, + }) + const result = await service.start("bootstrap", undefined, "bootstrap-secret") + expect(result.authorizationUrl).toContain("state=") + expect(typeof result.browserToken).toBe("string") + const [createdAttempt] = identityRecords.createAuthorizationAttempt.mock.calls[0]! + expect(createdAttempt.purpose).toBe("bootstrap") + }) + + it("keeps recovery separate and unavailable while an active administrator remains", async () => { + const identityRecords = records({ + activeAdministratorCount: vi.fn().mockResolvedValue(1), + }) + const service = createAuthenticationService({ + ...dependencies(identityRecords), + recoverySecret: "recovery-secret", + }) + + await expect(service.start("recovery", undefined, "recovery-secret")).rejects.toMatchObject({ + status: 404, + code: "recovery_unavailable", + }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.recovery", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { reason: "active_administrator_exists" }, + }) + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalled() + }) + + it("audits missing recovery proof without recording the submitted request", async () => { + const identityRecords = records() + const service = createAuthenticationService({ + ...dependencies(identityRecords), + recoverySecret: "recovery-secret", + }) + + await expect(service.start("recovery")).rejects.toMatchObject({ + status: 404, + code: "recovery_unavailable", + }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.recovery", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { reason: "secret_missing" }, + }) + expect(JSON.stringify(identityRecords.audit.mock.calls)).not.toContain("recovery-secret") + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalled() + }) + + it("does not turn a failed denied-audit write into a normal enrollment rejection", async () => { + const identityRecords = records({ + audit: vi.fn().mockRejectedValue(new Error("audit failed")), + }) + const service = createAuthenticationService({ + ...dependencies(identityRecords), + bootstrapSecret: "bootstrap-secret", + }) + + await expect(service.start("bootstrap", undefined, "wrong-secret")).rejects.toThrow( + "audit failed", + ) + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalled() + }) + + 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 administrator proof strictly younger than five minutes 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() - 5 * 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 = 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/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/membership.test.ts b/hub/server/domains/authentication/create-service/membership.test.ts new file mode 100644 index 00000000..7f0f942e --- /dev/null +++ b/hub/server/domains/authentication/create-service/membership.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest" + +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import { digestSecret } from "#server/utils/digest-secret" + +const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: "revision", + tokenDigest: digestSecret("session-token"), + signedInAt: new Date("2026-10-27T18:00:00.000Z"), + freshAuthorizedAt: new Date("2026-10-27T18:00:00.000Z"), + user: { id: "user", displayName: "Member", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "member", + displayName: "Member", + avatarUrl: null, + }, + authenticationProviderRevision: { + id: "revision", + authenticationProviderId: "provider", + revision: 1, + issuer: "https://github.com", + configuration: {}, + authenticationProvider: { + id: "provider", + key: "github", + type: "github", + enabled: true, + }, + }, +} + +describe("createAuthenticationService() membership enforcement", () => { + it.each(["suspended", "removed"] as const)( + "fails closed when a valid session belongs to a %s membership", + async (status) => { + const records = { + session: vi.fn().mockResolvedValue(session), + membership: vi.fn().mockResolvedValue({ + id: "membership", + userId: session.userId, + status, + role: "member", + source: "invitation", + suspendedAt: status === "suspended" ? new Date() : null, + removedAt: status === "removed" ? new Date() : null, + }), + } as unknown as IdentityRecords + const store: IdentityStore = { + read: records, + lock: (operation) => operation(records), + } + const service = createAuthenticationService({ + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { + name: "hub-managed", + admit: () => Promise.resolve(false), + }, + }) + + await expect(service.current("session-token")).rejects.toMatchObject({ + status: 401, + code: "membership_inactive", + }) + }, + ) +}) 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..cfd6b3c6 --- /dev/null +++ b/hub/server/domains/authentication/create-service/types.ts @@ -0,0 +1,53 @@ +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" | "bootstrap" | "recovery" + +export interface AdmissionPolicy { + name: "hub-managed" | "legacy-repository" + admit(profile: GitHubUserProfile): Promise +} + +export interface AuthenticationSettings { + origin: string + secureCookies: boolean + configured: boolean + admissionPolicy: AdmissionPolicy["name"] + trustedProxyHops: number +} + +export interface AuthenticationContext extends SessionContext { + membership: InstallationMembershipRecord +} + +export interface AuthenticationService { + start( + purpose: AuthorizationPurpose, + sessionToken?: string, + enrollmentSecret?: 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 + bootstrapSecret?: string | null + recoverySecret?: string | null + 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/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/get-current-session/index.ts b/hub/server/domains/authentication/get-current-session/index.ts new file mode 100644 index 00000000..e8a071d6 --- /dev/null +++ b/hub/server/domains/authentication/get-current-session/index.ts @@ -0,0 +1,20 @@ +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) + if (!membership || membership.status !== "active") { + throw new AuthenticationError(401, "membership_inactive", "Sign in to continue.") + } + 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..fd6e8b2e --- /dev/null +++ b/hub/server/domains/authentication/revoke-sessions/index.ts @@ -0,0 +1,40 @@ +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" + +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.") + } + const freshAuthorizationAge = current.freshAuthorizedAt + ? now().getTime() - current.freshAuthorizedAt.getTime() + : null + if ( + freshAuthorizationAge === null || + freshAuthorizationAge < 0 || + freshAuthorizationAge >= administratorFreshAuthorizationLifetimeMilliseconds + ) { + throw new AuthenticationError( + 403, + "fresh_authorization_required", + "Fresh authorization required.", + ) + } + await records.advanceSessionGeneration() + await records.deleteSessions() + await records.audit({ + event: "session.revoke_all", + outcome: "succeeded", + 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..0f8160fa --- /dev/null +++ b/hub/server/domains/authentication/sign-out/index.ts @@ -0,0 +1,19 @@ +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", + outcome: "succeeded", + 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..92886b82 --- /dev/null +++ b/hub/server/domains/authentication/start-github-authorization/index.ts @@ -0,0 +1,106 @@ +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" +import { secretsMatch } from "#server/utils/secrets-match" + +export async function startGitHubAuthorization( + dependencies: AuthenticationDependencies, + purpose: AuthorizationPurpose, + sessionToken?: string, + enrollmentSecret?: string, +) { + const { bootstrapSecret, configuration, github, now, providerRevisionId, recoverySecret, 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") + const denial = 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() + if (purpose === "bootstrap") { + const reason = (await records.bootstrapCompletedAt()) + ? "already_completed" + : !bootstrapSecret + ? "secret_unconfigured" + : !enrollmentSecret + ? "secret_missing" + : !secretsMatch(digestSecret(enrollmentSecret), digestSecret(bootstrapSecret)) + ? "secret_mismatch" + : null + if (reason) { + await records.audit({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason }, + }) + return new AuthenticationError(404, "bootstrap_unavailable", "Enrollment is unavailable.") + } + } + if (purpose === "recovery") { + const reason = !recoverySecret + ? "secret_unconfigured" + : !enrollmentSecret + ? "secret_missing" + : !secretsMatch(digestSecret(enrollmentSecret), digestSecret(recoverySecret)) + ? "secret_mismatch" + : (await records.activeAdministratorCount()) > 0 + ? "active_administrator_exists" + : null + if (reason) { + await records.audit({ + event: "membership.recovery", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason }, + }) + return new AuthenticationError(404, "recovery_unavailable", "Recovery is unavailable.") + } + } + 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 null + }) + if (denial) throw denial + return { + authorizationUrl: github.authorizationUrl(state, pkceVerifier), + browserToken, + } +} diff --git a/hub/server/domains/github-integration/create-credential-provider/index.test.ts b/hub/server/domains/github-integration/create-credential-provider/index.test.ts new file mode 100644 index 00000000..c3a7c6ee --- /dev/null +++ b/hub/server/domains/github-integration/create-credential-provider/index.test.ts @@ -0,0 +1,107 @@ +import { generateKeyPairSync } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider" + +function credentials() { + return { + appId: "12345", + installationId: "7", + clientId: "test-client", + clientSecret: "synthetic-client-secret", + privateKey: generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey, + } +} + +describe("createGitHubIntegrationCredentialProvider()", () => { + it("starts unconfigured and encrypts database credentials with fresh authenticated ciphertext", () => { + const provider = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + }) + const input = credentials() + + expect(provider.name).toBe("database") + expect(provider.read({ generation: 0, encryptedCredentials: null })).toBeNull() + const encrypted = provider.encrypt!(input) + expect(provider.encrypt!(input)).not.toBe(encrypted) + expect(provider.read({ generation: 0, encryptedCredentials: encrypted })).toEqual(input) + expect(encrypted).not.toContain(input.clientSecret) + expect(Buffer.from(encrypted, "base64").toString()).not.toContain(input.privateKey) + + const tampered = Buffer.from(encrypted, "base64") + tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 1 + expect(() => + provider.read({ generation: 0, encryptedCredentials: tampered.toString("base64") }), + ).toThrow() + const otherKey = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_CREDENTIAL_KEY: "cd".repeat(32), + }) + expect(() => otherKey.read({ generation: 0, encryptedCredentials: encrypted })).toThrow() + }) + + it("requires a valid encryption key outside the database", () => { + for (const key of [undefined, "ab".repeat(31), "zz".repeat(32)]) { + expect(() => + createGitHubIntegrationCredentialProvider({ HUB_GITHUB_CREDENTIAL_KEY: key }), + ).toThrow() + } + }) + + it("uses complete environment credentials read-only without consulting database ciphertext", () => { + const input = credentials() + const provider = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_CREDENTIAL_PROVIDER: "environment", + GITHUB_APP_ID: input.appId, + GITHUB_INSTALLATION_ID: input.installationId, + GITHUB_CLIENT_ID: input.clientId, + GITHUB_CLIENT_SECRET: input.clientSecret, + GITHUB_PRIVATE_KEY: input.privateKey, + }) + + expect(provider.name).toBe("environment") + expect(provider.encrypt).toBeUndefined() + expect( + provider.read({ generation: 9, encryptedCredentials: "not-database-credentials" }), + ).toEqual(input) + }) + + it("rejects incomplete environment configuration and unknown providers instead of falling back", () => { + const input = credentials() + const environment = { + HUB_GITHUB_CREDENTIAL_PROVIDER: "environment", + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + GITHUB_APP_ID: input.appId, + GITHUB_INSTALLATION_ID: input.installationId, + GITHUB_CLIENT_ID: input.clientId, + GITHUB_CLIENT_SECRET: input.clientSecret, + GITHUB_PRIVATE_KEY: input.privateKey, + } + for (const field of [ + "GITHUB_APP_ID", + "GITHUB_INSTALLATION_ID", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "GITHUB_PRIVATE_KEY", + ] as const) { + expect(() => + createGitHubIntegrationCredentialProvider({ ...environment, [field]: undefined }), + ).toThrow() + } + expect(() => + createGitHubIntegrationCredentialProvider({ + ...environment, + GITHUB_PRIVATE_KEY: "invalid", + }), + ).toThrow() + expect(() => + createGitHubIntegrationCredentialProvider({ + ...environment, + HUB_GITHUB_CREDENTIAL_PROVIDER: "other", + }), + ).toThrow() + }) +}) diff --git a/hub/server/domains/github-integration/create-credential-provider/index.ts b/hub/server/domains/github-integration/create-credential-provider/index.ts new file mode 100644 index 00000000..c63c01dd --- /dev/null +++ b/hub/server/domains/github-integration/create-credential-provider/index.ts @@ -0,0 +1,36 @@ +import { decryptGitHubCredentials } from "#server/integrations/github/decrypt-github-credentials" +import { encryptGitHubCredentials } from "#server/integrations/github/encrypt-github-credentials" +import { validateGitHubCredentials } from "#server/integrations/github/validate-github-credentials" +import type { GitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider/types" + +export function createGitHubIntegrationCredentialProvider( + environment: NodeJS.ProcessEnv, +): GitHubIntegrationCredentialProvider { + const selected = environment.HUB_GITHUB_CREDENTIAL_PROVIDER ?? "database" + if (selected === "environment") { + const credentials = validateGitHubCredentials({ + appId: environment.GITHUB_APP_ID, + installationId: environment.GITHUB_INSTALLATION_ID, + clientId: environment.GITHUB_CLIENT_ID, + clientSecret: environment.GITHUB_CLIENT_SECRET, + privateKey: environment.GITHUB_PRIVATE_KEY, + }) + return { name: "environment", read: () => credentials } + } + if (selected !== "database") { + throw new Error("HUB_GITHUB_CREDENTIAL_PROVIDER must be database or environment.") + } + + const encodedKey = environment.HUB_GITHUB_CREDENTIAL_KEY + if (!encodedKey || !/^[a-fA-F0-9]{64}$/.test(encodedKey)) { + throw new Error( + "Set HUB_GITHUB_CREDENTIAL_KEY to a 32-byte hexadecimal encryption key outside the database.", + ) + } + const key = Buffer.from(encodedKey, "hex") + return { + name: "database", + read: (state) => decryptGitHubCredentials(key, state.encryptedCredentials), + encrypt: encryptGitHubCredentials.bind(null, key), + } +} diff --git a/hub/server/domains/github-integration/create-credential-provider/types.ts b/hub/server/domains/github-integration/create-credential-provider/types.ts new file mode 100644 index 00000000..ee46be51 --- /dev/null +++ b/hub/server/domains/github-integration/create-credential-provider/types.ts @@ -0,0 +1,8 @@ +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import type { GitHubIntegrationState } from "#server/repositories/github-integration-state/types" + +export interface GitHubIntegrationCredentialProvider { + name: "database" | "environment" + read(state: GitHubIntegrationState): GitHubCredentials | null + encrypt?: (credentials: GitHubCredentials) => string +} diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts new file mode 100644 index 00000000..1c3676b5 --- /dev/null +++ b/hub/server/domains/identity/create-records/index.ts @@ -0,0 +1,89 @@ +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" +import { saveExternalIdentity } from "#server/repositories/external-identities/save-external-identity" +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 { completeBootstrap } from "#server/repositories/identity-store-state/complete-bootstrap" +import { getBootstrapCompletedAt } from "#server/repositories/identity-store-state/get-bootstrap-completed-at" +import { getIdentityStoreState } from "#server/repositories/identity-store-state/get-state" +import { countActiveAdministrators } from "#server/repositories/installation-memberships/count-active-administrators" +import { getInstallationMembership } from "#server/repositories/installation-memberships/get-membership" +import { getInstallationMembershipById } from "#server/repositories/installation-memberships/get-membership-by-id" +import { getInstallationMemberships } from "#server/repositories/installation-memberships/get-memberships" +import { saveInstallationMembership } from "#server/repositories/installation-memberships/save-membership" +import { consumeMembershipInvitation } from "#server/repositories/membership-invitations/consume-invitation" +import { createMembershipInvitation } from "#server/repositories/membership-invitations/create-invitation" +import { getMembershipInvitation } from "#server/repositories/membership-invitations/get-invitation" +import { getMembershipInvitations } from "#server/repositories/membership-invitations/get-invitations" +import { getUsableMembershipInvitation } from "#server/repositories/membership-invitations/get-usable-invitation" +import { revokeMembershipInvitation } from "#server/repositories/membership-invitations/revoke-invitation" +import { revokeExpiredMembershipInvitations } from "#server/repositories/membership-invitations/revoke-expired-invitations" +import { getSecurityAudits } from "#server/repositories/security-audits/get-audits" +import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" +import { createSession } from "#server/repositories/sessions/create-session" +import { deleteSession } from "#server/repositories/sessions/delete-session" +import { deleteSessions } from "#server/repositories/sessions/delete-sessions" +import { deleteUserSessions } from "#server/repositories/sessions/delete-user-sessions" +import { getSession } from "#server/repositories/sessions/get-session" +import { getSessionById } from "#server/repositories/sessions/get-session-by-id" +import { invalidateFreshness } from "#server/repositories/sessions/invalidate-freshness" +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), + bootstrapCompletedAt: getBootstrapCompletedAt.bind(null, database), + advanceSessionGeneration: advanceSessionGeneration.bind(null, database), + completeBootstrap: completeBootstrap.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), + membershipById: getInstallationMembershipById.bind(null, database), + memberships: getInstallationMemberships.bind(null, database), + activeAdministratorCount: countActiveAdministrators.bind(null, database), + saveMembership: saveInstallationMembership.bind(null, database), + createInvitation: createMembershipInvitation.bind(null, database), + invitation: getMembershipInvitation.bind(null, database), + invitations: getMembershipInvitations.bind(null, database), + usableInvitation: getUsableMembershipInvitation.bind(null, database), + consumeInvitation: consumeMembershipInvitation.bind(null, database), + revokeInvitation: revokeMembershipInvitation.bind(null, database), + revokeExpiredInvitations: revokeExpiredMembershipInvitations.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), + verifySession: verifySession.bind(null, database), + invalidateFreshness: invalidateFreshness.bind(null, database), + deleteSession: deleteSession.bind(null, database), + deleteSessions: deleteSessions.bind(null, database), + deleteUserSessions: deleteUserSessions.bind(null, database), + createAuthorizationAttempt: createAuthorizationAttempt.bind(null, database), + consumeAuthorizationAttempt: consumeAuthorizationAttempt.bind(null, database), + audit: saveSecurityAudit.bind(null, database), + audits: getSecurityAudits.bind(null, database), + } +} diff --git a/hub/server/domains/identity/create-store/index.db.test.ts b/hub/server/domains/identity/create-store/index.db.test.ts new file mode 100644 index 00000000..1644ee38 --- /dev/null +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -0,0 +1,711 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createIdentityStore } from "#server/domains/identity/create-store" +import { consumeAuthorizationAttempt } from "#server/repositories/authorization-attempts/consume-attempt" + +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 createIdentityGraph(store: IdentityStore, suffix: string) { + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: `Synthetic User ${suffix}`, + avatarUrl: null, + }) + const externalIdentity = await records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: `https://issuer-${suffix}.example`, + subject: `subject-${suffix}`, + username: `synthetic-${suffix}`, + displayName: `Synthetic User ${suffix}`, + avatarUrl: null, + }) + const authenticationProviderRevision = await records.createAuthenticationProviderRevision({ + key: `oidc-${suffix}`, + type: "oidc", + enabled: true, + revision: 1, + issuer: `https://issuer-${suffix}.example`, + configuration: { + authorizationEndpoint: `https://issuer-${suffix}.example/authorize`, + clientId: `client-${suffix}`, + }, + }) + return { authenticationProviderRevision, externalIdentity, user } + }) +} + +async function createSession(store: IdentityStore, suffix: string) { + const graph = await createIdentityGraph(store, suffix) + const session = await store.lock((records) => + records.createSession({ + userId: graph.user.id, + externalIdentityId: graph.externalIdentity.id, + authenticationProviderRevisionId: graph.authenticationProviderRevision.id, + tokenDigest: `token-digest-${suffix}`, + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }), + ) + return { ...graph, session } +} + +describe("createIdentityStore()", () => { + it("pages every active membership and pending invitation without truncation", async () => { + const { prisma, store } = await database() + const users = Array.from({ length: 101 }, (_, index) => ({ + id: randomUUID(), + displayName: `Paged member ${index}`, + avatarUrl: null, + })) + await prisma.user.createMany({ data: users }) + await prisma.installationMembership.createMany({ + data: users.map((user) => ({ + id: randomUUID(), + userId: user.id, + status: "active", + role: "member", + source: "manual", + suspendedAt: null, + removedAt: null, + })), + }) + await prisma.membershipInvitation.createMany({ + data: users.map((_, index) => ({ + id: randomUUID(), + createdByUserId: null, + provider: "github", + issuer: "https://github.com", + subject: `paged-subject-${index}`, + role: "member", + expiresAt: new Date("2026-11-05T18:00:00.000Z"), + })), + }) + + const membershipFirstPage = await store.read.memberships() + const membershipSecondPage = await store.read.memberships(membershipFirstPage.nextCursor) + expect(membershipFirstPage.memberships).toHaveLength(100) + expect(membershipFirstPage.nextCursor).not.toBeNull() + expect(membershipSecondPage).toMatchObject({ + memberships: [expect.objectContaining({ status: "active" })], + nextCursor: null, + }) + expect( + new Set([ + ...membershipFirstPage.memberships.map(({ id }) => id), + ...membershipSecondPage.memberships.map(({ id }) => id), + ]).size, + ).toBe(101) + + const invitationFirstPage = await store.read.invitations() + const invitationSecondPage = await store.read.invitations(invitationFirstPage.nextCursor) + expect(invitationFirstPage.invitations).toHaveLength(100) + expect(invitationFirstPage.nextCursor).not.toBeNull() + expect(invitationSecondPage).toMatchObject({ + invitations: [ + expect.objectContaining({ + consumedAt: null, + revokedAt: null, + }), + ], + nextCursor: null, + }) + expect( + new Set([ + ...invitationFirstPage.invitations.map(({ id }) => id), + ...invitationSecondPage.invitations.map(({ id }) => id), + ]).size, + ).toBe(101) + }) + + it("rolls back GitHub integration, session, and audit changes together", async () => { + const { store } = await database() + const { session } = await createSession(store, "rollback") + await store.lock((records) => + records.saveGitHubIntegrationCredentials("initial-encrypted-fixture"), + ) + + await expect( + store.lock(async (records) => { + await records.saveGitHubIntegrationCredentials("replacement-encrypted-fixture") + await records.advanceGitHubIntegrationGeneration() + await records.deleteSessions() + await records.audit({ + event: "session.revoke_all", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + }) + throw new Error("Transaction failed") + }), + ).rejects.toThrow("Transaction failed") + + expect(await store.read.githubIntegrationState()).toMatchObject({ + generation: 0, + encryptedCredentials: "initial-encrypted-fixture", + }) + expect(await store.read.sessionById(session.id)).not.toBeNull() + expect(await store.read.audits()).toEqual([]) + }) + + it("serializes locks so an overlapping mutation reads the committed generation", async () => { + const { store } = await database() + await store.lock(() => Promise.resolve()) + let entered!: () => void + let release!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + const first = store.lock(async (records) => { + await records.advanceGitHubIntegrationGeneration() + entered() + await gate + }) + await started + const second = store.lock(async (records) => { + const state = await records.githubIntegrationState() + await records.advanceGitHubIntegrationGeneration() + return state.generation + }) + release() + + await first + expect(await second).toBe(1) + expect((await store.read.githubIntegrationState()).generation).toBe(2) + }) + + it("links multiple issuer-scoped external identities without allowing a relink", async () => { + const { store } = await database() + const firstUser = await store.lock((records) => + records.createUser({ displayName: "First user", avatarUrl: null }), + ) + const secondUser = await store.lock((records) => + records.createUser({ displayName: "Second user", avatarUrl: null }), + ) + const firstIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: firstUser.id, + provider: "oidc", + issuer: "https://first.example", + subject: "shared-subject", + username: "first-name", + displayName: "First identity", + avatarUrl: null, + }), + ) + const secondIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: firstUser.id, + provider: "oidc", + issuer: "https://second.example", + subject: "shared-subject", + username: "second-name", + displayName: "Second identity", + avatarUrl: null, + }), + ) + const relinkAttempt = await store.lock((records) => + records.saveExternalIdentity({ + userId: secondUser.id, + provider: "oidc", + issuer: "https://first.example", + subject: "shared-subject", + username: "updated-name", + displayName: "Updated identity", + avatarUrl: null, + }), + ) + + expect(secondIdentity.id).not.toBe(firstIdentity.id) + expect(secondIdentity.userId).toBe(firstUser.id) + expect(relinkAttempt).toMatchObject({ + id: firstIdentity.id, + userId: firstUser.id, + username: "updated-name", + }) + expect( + await store.read.externalIdentity("oidc", "https://first.example", "shared-subject"), + ).toMatchObject({ id: firstIdentity.id, user: { id: firstUser.id } }) + expect(await store.read.user(firstUser.id)).not.toHaveProperty("administrator") + }) + + it("persists installation status, role, and source independently from the user", async () => { + const { store } = await database() + const user = await store.lock((records) => + records.createUser({ displayName: "Administrator", avatarUrl: null }), + ) + + await store.lock((records) => + records.saveMembership({ + userId: user.id, + status: "active", + role: "administrator", + source: "bootstrap", + suspendedAt: null, + removedAt: null, + }), + ) + const suspendedAt = new Date("2026-10-27T18:00:00.000Z") + await store.lock((records) => + records.saveMembership({ + userId: user.id, + status: "suspended", + role: "member", + source: "manual", + suspendedAt, + removedAt: null, + }), + ) + + expect(await store.read.membership(user.id)).toMatchObject({ + userId: user.id, + status: "suspended", + role: "member", + source: "manual", + suspendedAt, + removedAt: null, + }) + expect(await store.read.user(user.id)).toEqual(user) + expect(user).not.toHaveProperty("administrator") + }) + + it("stores OIDC provider revisions separately from GitHub integration credentials", async () => { + const { prisma, store } = await database() + const revision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "workforce-oidc", + type: "oidc", + enabled: true, + revision: 7, + issuer: "https://login.example", + configuration: { + authorizationEndpoint: "https://login.example/authorize", + clientId: "replica-hub", + }, + }), + ) + + expect(revision).toMatchObject({ + revision: 7, + issuer: "https://login.example", + configuration: { + authorizationEndpoint: "https://login.example/authorize", + clientId: "replica-hub", + }, + authenticationProvider: { + key: "workforce-oidc", + type: "oidc", + enabled: true, + }, + }) + expect(await store.read.githubIntegrationState()).toMatchObject({ + generation: 0, + encryptedCredentials: null, + }) + + await expect( + store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "workforce-oidc", + type: "oidc", + enabled: true, + revision: 7, + issuer: "https://replacement.example", + configuration: { clientId: "replacement-client" }, + }), + ), + ).rejects.toThrow() + await store.lock((records) => + records.saveGitHubIntegrationCredentials("encrypted-github-credentials"), + ) + + expect( + await prisma.authenticationProviderRevision.findUnique({ where: { id: revision.id } }), + ).toMatchObject({ + issuer: "https://login.example", + configuration: revision.configuration, + }) + }) + + it("keeps sessions tied to identity revisions when GitHub credentials rotate", async () => { + const { store } = await database() + const { authenticationProviderRevision, externalIdentity, session, user } = await createSession( + store, + "credential-rotation", + ) + + await store.lock(async (records) => { + await records.saveGitHubIntegrationCredentials("rotated-encrypted-credentials") + await records.advanceGitHubIntegrationGeneration() + }) + + expect(await store.read.session(session.tokenDigest)).toMatchObject({ + id: session.id, + user: { id: user.id }, + externalIdentity: { id: externalIdentity.id }, + authenticationProviderRevision: { + id: authenticationProviderRevision.id, + authenticationProvider: { key: "oidc-credential-rotation", type: "oidc" }, + }, + }) + }) + + it("rejects a session when the external identity provider type differs", async () => { + const { store } = await database() + const user = await store.lock((records) => + records.createUser({ displayName: "Provider mismatch", avatarUrl: null }), + ) + const externalIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://login.example", + subject: "provider-mismatch", + username: "provider-mismatch", + displayName: "Provider mismatch", + avatarUrl: null, + }), + ) + const authenticationProviderRevision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "provider-mismatch", + type: "oidc", + enabled: true, + revision: 1, + issuer: "https://login.example", + configuration: { clientId: "provider-mismatch" }, + }), + ) + + await expect( + store.lock((records) => + records.createSession({ + userId: user.id, + externalIdentityId: externalIdentity.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + tokenDigest: "provider-mismatch-token", + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }), + ), + ).rejects.toThrow("Session external identity provider must match") + expect(await store.read.session("provider-mismatch-token")).toBeNull() + }) + + it("rejects a session when the external identity issuer differs", async () => { + const { store } = await database() + const user = await store.lock((records) => + records.createUser({ displayName: "Issuer mismatch", avatarUrl: null }), + ) + const externalIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: "https://identity.example", + subject: "issuer-mismatch", + username: "issuer-mismatch", + displayName: "Issuer mismatch", + avatarUrl: null, + }), + ) + const authenticationProviderRevision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "issuer-mismatch", + type: "oidc", + enabled: true, + revision: 1, + issuer: "https://provider.example", + configuration: { clientId: "issuer-mismatch" }, + }), + ) + + await expect( + store.lock((records) => + records.createSession({ + userId: user.id, + externalIdentityId: externalIdentity.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + tokenDigest: "issuer-mismatch-token", + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }), + ), + ).rejects.toThrow("Session external identity issuer must match") + expect(await store.read.session("issuer-mismatch-token")).toBeNull() + }) + + it("keeps a provider type and historical session context after rejecting a type change", async () => { + const { store } = await database() + const { authenticationProviderRevision, session } = await createSession( + store, + "immutable-provider-type", + ) + + await expect( + store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "oidc-immutable-provider-type", + type: "github", + enabled: false, + revision: 2, + issuer: "https://github.com", + configuration: { clientId: "replacement-client" }, + }), + ), + ).rejects.toThrow("Authentication provider type cannot change") + + expect(await store.read.session(session.tokenDigest)).toMatchObject({ + authenticationProviderRevision: { + id: authenticationProviderRevision.id, + revision: 1, + authenticationProvider: { + key: "oidc-immutable-provider-type", + type: "oidc", + enabled: true, + }, + }, + }) + }) + + it("deletes an attempt expiring at the database transaction time without consuming it", async () => { + const { prisma, store } = await database() + const { authenticationProviderRevision } = await createIdentityGraph(store, "expiry-boundary") + const stateDigest = "expiry-boundary-state" + + const consumed = await prisma.$transaction(async (database) => { + const [clock] = await database.$queryRaw<{ now: Date }[]>` + SELECT CURRENT_TIMESTAMP AS now + ` + if (!clock) throw new Error("Expected the database transaction time") + await database.authorizationAttempt.create({ + data: { + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: null, + stateDigest, + browserDigest: "expiry-boundary-browser", + pkceVerifier: "expiry-boundary-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: clock.now, + }, + }) + return consumeAuthorizationAttempt(database, stateDigest) + }) + + expect(consumed).toBeNull() + expect(await prisma.authorizationAttempt.findUnique({ where: { stateDigest } })).toBeNull() + }) + + it("rejects an attempt that expires while its consumer waits for the store lock", async () => { + const { prisma, store } = await database() + const { authenticationProviderRevision } = await createIdentityGraph(store, "waiting-expiry") + const stateDigest = "waiting-expiry-state" + await store.lock(() => Promise.resolve()) + let announceAttempt!: (expiresAt: Date) => void + let releaseLock!: () => void + const attemptCreated = new Promise((resolve) => { + announceAttempt = resolve + }) + const lockGate = new Promise((resolve) => { + releaseLock = resolve + }) + const holder = store.lock(async (records) => { + const expiresAt = new Date(Date.now() + 1_500) + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: null, + stateDigest, + browserDigest: "waiting-expiry-browser", + pkceVerifier: "waiting-expiry-verifier", + nonce: null, + purpose: "sign-in", + expiresAt, + }) + announceAttempt(expiresAt) + await lockGate + }) + const expiresAt = await attemptCreated + let consumerEntered = false + const consumer = store.lock(async (records) => { + consumerEntered = true + return records.consumeAuthorizationAttempt(stateDigest) + }) + onTestFinished(async () => { + releaseLock() + await Promise.allSettled([holder, consumer]) + }) + + await vi.waitFor( + async () => { + const [waiting] = await prisma.$queryRaw< + { transactionStartedAt: Date; waitEventType: string | null }[] + >` + SELECT + xact_start AS "transactionStartedAt", + wait_event_type AS "waitEventType" + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND state = 'active' + AND query LIKE '%identity_store_states%' + AND wait_event_type = 'Lock' + ORDER BY xact_start DESC + LIMIT 1 + ` + expect(waiting?.waitEventType).toBe("Lock") + expect(waiting?.transactionStartedAt.getTime()).toBeLessThan(expiresAt.getTime()) + }, + { interval: 20, timeout: 5_000 }, + ) + await vi.waitFor( + async () => { + const [clock] = await prisma.$queryRaw<{ expired: boolean }[]>` + SELECT clock_timestamp() >= ${expiresAt} AS expired + ` + expect(clock?.expired).toBe(true) + }, + { interval: 20, timeout: 5_000 }, + ) + expect(consumerEntered).toBe(false) + releaseLock() + + await holder + expect(await consumer).toBeNull() + expect(await prisma.authorizationAttempt.findUnique({ where: { stateDigest } })).toBeNull() + }) + + it("prunes expired provider-aware attempts and atomically consumes a live attempt once", async () => { + const { prisma, store } = await database() + const { authenticationProviderRevision, session } = await createSession(store, "attempt") + const otherProviderRevision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "other-oidc", + type: "oidc", + enabled: true, + revision: 2, + issuer: "https://other.example", + configuration: { clientId: "other-client" }, + }), + ) + const expired = { + authenticationProviderRevisionId: otherProviderRevision.id, + originatingSessionId: null, + stateDigest: "expired-state-digest", + browserDigest: "expired-browser-digest", + pkceVerifier: "expired-pkce-verifier", + nonce: null, + purpose: "sign-in" as const, + expiresAt: new Date(Date.now() - 1), + } + const live = { + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: session.id, + stateDigest: "live-state-digest", + browserDigest: "live-browser-digest", + pkceVerifier: "live-pkce-verifier", + nonce: "live-oidc-nonce", + purpose: "link-identity" as const, + expiresAt: new Date(Date.now() + 15 * 60_000), + } + await store.lock((records) => records.createAuthorizationAttempt(expired)) + await store.lock((records) => records.createAuthorizationAttempt(live)) + + expect( + await prisma.authorizationAttempt.findUnique({ + where: { stateDigest: expired.stateDigest }, + }), + ).toBeNull() + const results = await Promise.all([ + prisma.$transaction((database) => consumeAuthorizationAttempt(database, live.stateDigest)), + prisma.$transaction((database) => consumeAuthorizationAttempt(database, live.stateDigest)), + ]) + + expect(results.filter(Boolean)).toHaveLength(1) + expect(results.find(Boolean)).toMatchObject(live) + expect( + await prisma.authorizationAttempt.findUnique({ + where: { stateDigest: live.stateDigest }, + }), + ).toBeNull() + }) + + it("removes pending attempts with their originating session", async () => { + const { store } = await database() + const { authenticationProviderRevision, session } = await createSession(store, "sign-out") + const attempt = { + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: session.id, + stateDigest: "originating-session-state", + browserDigest: "originating-session-browser", + pkceVerifier: "originating-session-verifier", + nonce: null, + purpose: "verify" as const, + expiresAt: new Date(Date.now() + 15 * 60_000), + } + await store.lock((records) => records.createAuthorizationAttempt(attempt)) + + await store.lock((records) => records.invalidateFreshness()) + expect(await store.read.sessionById(session.id)).toMatchObject({ freshAuthorizedAt: null }) + await store.lock((records) => records.deleteSession(session.id)) + + expect(await store.read.session(session.tokenDigest)).toBeNull() + expect( + await store.lock((records) => records.consumeAuthorizationAttempt(attempt.stateDigest)), + ).toBeNull() + }) + + it("retains minimal structured audits for ninety days and prunes older records on writes", async () => { + const { prisma, store } = await database() + const { authenticationProviderRevision, session, user } = await createSession(store, "audit") + const now = Date.now() + vi.spyOn(Date, "now").mockReturnValue(now) + const cutoff = now - 90 * 24 * 60 * 60_000 + const old = await prisma.securityAudit.create({ + data: { + event: "identity.denied.request_failed", + createdAt: new Date(cutoff - 1), + }, + }) + const retained = await prisma.securityAudit.create({ + data: { + event: "session.sign_in", + createdAt: new Date(cutoff), + }, + }) + + expect((await store.read.audits()).map(({ id }) => id)).toEqual([retained.id]) + expect(await prisma.securityAudit.findUnique({ where: { id: old.id } })).not.toBeNull() + await store.lock((records) => + records.audit({ + event: "identity.verified", + actorUserId: user.id, + sessionId: session.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + }), + ) + + expect(await prisma.securityAudit.findUnique({ where: { id: old.id } })).toBeNull() + expect(await prisma.securityAudit.findUnique({ where: { id: retained.id } })).not.toBeNull() + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "identity.verified", + actorUserId: user.id, + sessionId: session.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + }), + expect.objectContaining({ id: retained.id, event: "session.sign_in" }), + ]) + expect(await prisma.securityAudit.count()).toBe(2) + }) +}) diff --git a/hub/server/domains/identity/create-store/index.ts b/hub/server/domains/identity/create-store/index.ts new file mode 100644 index 00000000..73da355b --- /dev/null +++ b/hub/server/domains/identity/create-store/index.ts @@ -0,0 +1,12 @@ +import { createIdentityRecords } from "#server/domains/identity/create-records" +import { withIdentityStoreLock } from "#server/domains/identity/with-store-lock" +import type { IdentityStore } from "#server/domains/identity/create-store/types" + +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +export function createIdentityStore(client: PrismaClient): IdentityStore { + return { + read: createIdentityRecords(client), + lock: (operation) => withIdentityStoreLock(client, operation), + } +} diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts new file mode 100644 index 00000000..95627e64 --- /dev/null +++ b/hub/server/domains/identity/create-store/types.ts @@ -0,0 +1,113 @@ +import type { AuthenticationProviderInput } from "#server/repositories/authentication-providers/types" +import type { AuthorizationAttempt } from "#server/repositories/authorization-attempts/types" +import type { + ExternalIdentityInput, + HubExternalIdentity, +} from "#server/repositories/external-identities/types" +import type { GitHubIntegrationState } from "#server/repositories/github-integration-state/types" +import type { + InstallationMembershipDetails, + InstallationMembershipInput, + InstallationMembershipPage, + InstallationMembershipRecord, +} from "#server/repositories/installation-memberships/types" +import type { + MembershipInvitationInput, + MembershipInvitationPage, + MembershipInvitationRecord, +} from "#server/repositories/membership-invitations/types" +import type { AuditInput, SecurityAuditRecord } 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 + authenticationProviderId: string + revision: number + issuer: string + configuration: unknown + authenticationProvider: { + id: string + key: string + type: string + enabled: boolean + } +} + +export interface SessionContext extends HubSession { + user: HubUser + externalIdentity: HubExternalIdentity + authenticationProviderRevision: AuthenticationProviderRevisionRecord +} + +export interface IdentityRecords { + identityStoreState(): Promise + bootstrapCompletedAt(): Promise + advanceSessionGeneration(): Promise + completeBootstrap(completedAt: Date): 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, + subject: string, + ): Promise<(HubExternalIdentity & { user: HubUser }) | null> + saveExternalIdentity( + input: ExternalIdentityInput, + ): Promise + membership(userId: string): Promise + membershipById(id: string): Promise + memberships(cursor?: string | null): Promise + activeAdministratorCount(): Promise + saveMembership(input: InstallationMembershipInput): Promise + createInvitation(input: MembershipInvitationInput): Promise + invitation(id: string): Promise + invitations(cursor?: string | null): Promise + usableInvitation( + provider: string, + issuer: string, + subject: string, + now: Date, + ): Promise + consumeInvitation( + id: string, + activatedIdentityId: string, + activatedMembershipId: string, + consumedAt: Date, + ): Promise + revokeInvitation(id: string, revokedAt: Date): Promise + revokeExpiredInvitations(revokedAt: Date): Promise + createAuthenticationProviderRevision( + input: AuthenticationProviderInput, + ): Promise + currentAuthenticationProviderRevision( + key: string, + ): Promise + authenticationProviderRevision(id: string): Promise + session(tokenDigest: string): Promise + sessionById(id: string): Promise + createSession(input: Omit): Promise + verifySession(id: string, at: Date): Promise + invalidateFreshness(): Promise + deleteSession(id: string): Promise + deleteSessions(): Promise + deleteUserSessions(userId: string): Promise + createAuthorizationAttempt( + input: Omit & { + sessionGeneration?: number + }, + ): Promise + consumeAuthorizationAttempt(stateDigest: string): Promise + audit(input: AuditInput): Promise + audits(): Promise +} + +export interface IdentityStore { + read: IdentityRecords + lock(operation: (records: IdentityRecords) => Promise): Promise +} diff --git a/hub/server/domains/identity/with-store-lock/index.ts b/hub/server/domains/identity/with-store-lock/index.ts new file mode 100644 index 00000000..bcdabd78 --- /dev/null +++ b/hub/server/domains/identity/with-store-lock/index.ts @@ -0,0 +1,16 @@ +import { createIdentityRecords } from "#server/domains/identity/create-records" +import { ensureGitHubIntegrationState } from "#server/repositories/github-integration-state/ensure-state" +import { withIdentityStoreLock as withRepositoryLock } from "#server/repositories/identity-store-state/with-store-lock" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" + +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +export function withIdentityStoreLock( + client: PrismaClient, + operation: (records: IdentityRecords) => Promise, +): Promise { + return withRepositoryLock(client, async (database) => { + await ensureGitHubIntegrationState(database) + return operation(createIdentityRecords(database)) + }) +} 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/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/integrations/express/respond-administration-error/index.ts b/hub/server/integrations/express/respond-administration-error/index.ts new file mode 100644 index 00000000..f3ddf3ff --- /dev/null +++ b/hub/server/integrations/express/respond-administration-error/index.ts @@ -0,0 +1,20 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +export function respondAdministrationError( + error: unknown, + _request: Request, + response: Response, + next: NextFunction, +): void { + if (!(error instanceof AuthenticationError)) { + next(error) + return + } + if (error.code === "csrf_failed" || error.code === "origin_failed") { + response.status(403).json({ error: "Request not allowed" }) + return + } + response.status(error.status).json({ error: error.code, message: error.message }) +} 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/decrypt-github-credentials/index.ts b/hub/server/integrations/github/decrypt-github-credentials/index.ts new file mode 100644 index 00000000..d25b1c18 --- /dev/null +++ b/hub/server/integrations/github/decrypt-github-credentials/index.ts @@ -0,0 +1,22 @@ +import { createDecipheriv } from "node:crypto" + +import { credentialContext } from "#server/integrations/github/encrypt-github-credentials/constants" +import { validateGitHubCredentials } from "#server/integrations/github/validate-github-credentials" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function decryptGitHubCredentials( + key: Buffer, + encryptedCredentials: string | null, +): GitHubCredentials | null { + if (encryptedCredentials === null) return null + try { + const data = Buffer.from(encryptedCredentials, "base64") + const decipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12)) + decipher.setAAD(credentialContext) + decipher.setAuthTag(data.subarray(12, 28)) + const plaintext = Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]) + return validateGitHubCredentials(JSON.parse(plaintext.toString("utf8"))) + } catch { + throw new Error("Cannot decrypt GitHub App credentials. Restore the configured encryption key.") + } +} diff --git a/hub/server/integrations/github/encrypt-github-credentials/constants.ts b/hub/server/integrations/github/encrypt-github-credentials/constants.ts new file mode 100644 index 00000000..45c9388c --- /dev/null +++ b/hub/server/integrations/github/encrypt-github-credentials/constants.ts @@ -0,0 +1 @@ +export const credentialContext = Buffer.from("replica:github-app:v1") diff --git a/hub/server/integrations/github/encrypt-github-credentials/index.ts b/hub/server/integrations/github/encrypt-github-credentials/index.ts new file mode 100644 index 00000000..56876cdc --- /dev/null +++ b/hub/server/integrations/github/encrypt-github-credentials/index.ts @@ -0,0 +1,12 @@ +import { createCipheriv, randomBytes } from "node:crypto" + +import { credentialContext } from "#server/integrations/github/encrypt-github-credentials/constants" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function encryptGitHubCredentials(key: Buffer, credentials: GitHubCredentials): string { + const nonce = randomBytes(12) + const cipher = createCipheriv("aes-256-gcm", key, nonce) + cipher.setAAD(credentialContext) + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(credentials)), cipher.final()]) + return Buffer.concat([nonce, cipher.getAuthTag(), ciphertext]).toString("base64") +} 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/github-error/index.ts b/hub/server/integrations/github/github-error/index.ts new file mode 100644 index 00000000..9be9cfe6 --- /dev/null +++ b/hub/server/integrations/github/github-error/index.ts @@ -0,0 +1,10 @@ +import type { GitHubErrorCode } from "#server/integrations/github/github-error/types" + +export class GitHubError extends Error { + code: GitHubErrorCode + + constructor(code: GitHubErrorCode, message: string) { + super(message) + this.code = code + } +} diff --git a/hub/server/integrations/github/github-error/types.ts b/hub/server/integrations/github/github-error/types.ts new file mode 100644 index 00000000..a5fdfb96 --- /dev/null +++ b/hub/server/integrations/github/github-error/types.ts @@ -0,0 +1 @@ +export type GitHubErrorCode = "invalid_credentials" 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/integrations/github/validate-github-credentials/constants.ts b/hub/server/integrations/github/validate-github-credentials/constants.ts new file mode 100644 index 00000000..3a497c7e --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/constants.ts @@ -0,0 +1,16 @@ +import { z } from "zod" + +const githubIdSchema = z + .string() + .regex(/^[1-9]\d*$/) + .max(30) + +export const credentialsSchema = z + .object({ + appId: githubIdSchema, + installationId: githubIdSchema, + clientId: z.string().min(1).max(200), + clientSecret: z.string().min(1).max(1000), + privateKey: z.string().min(1).max(16384), + }) + .strict() diff --git a/hub/server/integrations/github/validate-github-credentials/index.ts b/hub/server/integrations/github/validate-github-credentials/index.ts new file mode 100644 index 00000000..744553d5 --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/index.ts @@ -0,0 +1,30 @@ +import { createPrivateKey } from "node:crypto" + +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import { GitHubError } from "#server/integrations/github/github-error" + +import { credentialsSchema } from "#server/integrations/github/validate-github-credentials/constants" + +export function validateGitHubCredentials(input: unknown): GitHubCredentials { + const parsed = credentialsSchema.safeParse(input) + if (!parsed.success) { + throw new GitHubError( + "invalid_credentials", + "Supply the GitHub App ID, installation ID, client ID, client secret, and RSA private key.", + ) + } + + try { + const key = createPrivateKey(parsed.data.privateKey) + if (key.asymmetricKeyType !== "rsa" || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) { + throw new Error("Invalid key") + } + } catch { + throw new GitHubError( + "invalid_credentials", + "The GitHub App private key must be an RSA key of at least 2048 bits.", + ) + } + + return parsed.data +} diff --git a/hub/server/integrations/github/validate-github-credentials/types.ts b/hub/server/integrations/github/validate-github-credentials/types.ts new file mode 100644 index 00000000..d45f9941 --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/types.ts @@ -0,0 +1,7 @@ +export interface GitHubCredentials { + appId: string + installationId: string + clientId: string + clientSecret: string + privateKey: string +} diff --git a/hub/server/integrations/pino/create-request-logger/index.test.ts b/hub/server/integrations/pino/create-request-logger/index.test.ts index 9c78c3a4..78f967e7 100644 --- a/hub/server/integrations/pino/create-request-logger/index.test.ts +++ b/hub/server/integrations/pino/create-request-logger/index.test.ts @@ -5,6 +5,7 @@ import express from "express" import { describe, expect, it, onTestFinished, vi } from "vitest" import { createLogger } from "../create-logger" +import { omitRequestLogPayload } from "../omit-request-log-payload" import { createRequestLogger } from "." @@ -111,7 +112,7 @@ describe("createRequestLogger()", () => { expect(records[0]).toMatchObject({ level: 50, statusCode: 500, outcome: "completed" }) }) - it("lets an endpoint omit all payload fields without suppressing completion metadata", async () => { + it("redacts normalized OAuth callback fields without changing the request", async () => { const records: Record[] = [] const logger = createLogger({ destination: { write: (line) => records.push(parseRecord(line)) }, @@ -119,23 +120,94 @@ describe("createRequestLogger()", () => { const app = express() app.use(createRequestLogger(logger)) app.use(express.json()) - app.post("/private/:id", (_req, res) => { - res.locals.omitLogPayload = true - res.json({ private: "response-secret" }) + app.post("/callback", (req, res) => { + const body: unknown = req.body + res.json({ query: req.query, body }) }) const baseUrl = await listen(app) + const query = new URLSearchParams({ + code: "query-code", + STATE: "query-state", + "no-nce": "query-nonce", + code_verifier: "query-verifier", + safe: "query-safe", + }) + const body = { + Code: "body-code", + state: "body-state", + nonce: "body-nonce", + "code.verifier": "body-verifier", + safe: "body-safe", + } - const response = await fetch(`${baseUrl}/private/param-secret?search=query-secret`, { + const response = await fetch(`${baseUrl}/callback?${query}`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ text: "body-secret" }), + body: JSON.stringify(body), + }) + + expect(await response.json()).toEqual({ + query: { + code: "query-code", + STATE: "query-state", + "no-nce": "query-nonce", + code_verifier: "query-verifier", + safe: "query-safe", + }, + body, + }) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).toMatchObject({ + query: { + code: "[REDACTED]", + STATE: "[REDACTED]", + "no-nce": "[REDACTED]", + code_verifier: "[REDACTED]", + safe: "query-safe", + }, + body: { + Code: "[REDACTED]", + state: "[REDACTED]", + nonce: "[REDACTED]", + "code.verifier": "[REDACTED]", + safe: "body-safe", + }, + }) + expect(JSON.stringify(records)).not.toMatch( + /query-code|query-state|query-nonce|query-verifier|body-code|body-state|body-nonce|body-verifier/, + ) + }) + + it("lets an authentication router omit payload fields without suppressing completion metadata", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { write: (line) => records.push(parseRecord(line)) }, }) + const app = express() + app.use(createRequestLogger(logger)) + app.use(express.json()) + const authentication = express.Router() + authentication.use(omitRequestLogPayload) + authentication.post("/:provider/callback", (_req, res) => { + res.json({ private: "response-secret" }) + }) + app.use("/authentication", authentication) + const baseUrl = await listen(app) + + const response = await fetch( + `${baseUrl}/authentication/provider-secret/callback?search=query-secret`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "body-secret" }), + }, + ) await response.arrayBuffer() await vi.waitFor(() => expect(records).toHaveLength(1)) expect(records[0]).toMatchObject({ method: "POST", - route: "/private/:id", + route: "/:provider/callback", statusCode: 200, outcome: "completed", }) @@ -143,7 +215,7 @@ describe("createRequestLogger()", () => { expect(records[0]).not.toHaveProperty(field) } expect(JSON.stringify(records)).not.toMatch( - /param-secret|query-secret|body-secret|response-secret/, + /provider-secret|query-secret|body-secret|response-secret/, ) }) diff --git a/hub/server/integrations/pino/omit-request-log-payload/index.ts b/hub/server/integrations/pino/omit-request-log-payload/index.ts new file mode 100644 index 00000000..b9393eaa --- /dev/null +++ b/hub/server/integrations/pino/omit-request-log-payload/index.ts @@ -0,0 +1,7 @@ +import type { NextFunction, Request, Response } from "express" + +/** Exclude authentication request payloads while retaining completion metadata. */ +export function omitRequestLogPayload(_request: Request, response: Response, next: NextFunction) { + response.locals.omitLogPayload = true + next() +} diff --git a/hub/server/integrations/pino/snapshot-request/index.ts b/hub/server/integrations/pino/snapshot-request/index.ts index 8a1b9d15..6f2daf2c 100644 --- a/hub/server/integrations/pino/snapshot-request/index.ts +++ b/hub/server/integrations/pino/snapshot-request/index.ts @@ -1,5 +1,6 @@ import type { Request } from "express" +import { sensitiveRequestLogKeyPattern } from "../../../utils/logging/sanitize-log-value/constants.ts" import { sanitizeLogValue } from "../../../utils/logging/sanitize-log-value/index.ts" import type { RequestSnapshot } from "./types.ts" @@ -11,10 +12,10 @@ export function snapshotRequest(request: Request): RequestSnapshot { route !== null && typeof route === "object" && "path" in route ? route.path : undefined return { route: typeof path === "string" ? path : null, - params: sanitizeLogValue(request.params), - query: sanitizeLogValue(request.query), + params: sanitizeLogValue(request.params, sensitiveRequestLogKeyPattern), + query: sanitizeLogValue(request.query, sensitiveRequestLogKeyPattern), ...(request.is("application/json") && request.body !== undefined - ? { body: sanitizeLogValue(request.body) } + ? { body: sanitizeLogValue(request.body, sensitiveRequestLogKeyPattern) } : {}), } } diff --git a/hub/server/integrations/prisma/index.ts b/hub/server/integrations/prisma/index.ts index f9c0d9b0..78f43a6d 100644 --- a/hub/server/integrations/prisma/index.ts +++ b/hub/server/integrations/prisma/index.ts @@ -1,5 +1,5 @@ -import "dotenv/config" import { PrismaPg } from "@prisma/adapter-pg" +import "dotenv/config" import { PrismaClient } from "../../generated/prisma/client.ts" diff --git a/hub/server/repositories/authentication-providers/create-provider-revision/index.ts b/hub/server/repositories/authentication-providers/create-provider-revision/index.ts new file mode 100644 index 00000000..23cc1a54 --- /dev/null +++ b/hub/server/repositories/authentication-providers/create-provider-revision/index.ts @@ -0,0 +1,33 @@ +import type { AuthenticationProviderInput } from "#server/repositories/authentication-providers/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createAuthenticationProviderRevision( + database: Prisma.TransactionClient, + input: AuthenticationProviderInput, +) { + const existing = await database.authenticationProvider.findUnique({ + where: { key: input.key }, + select: { type: true }, + }) + if (existing && existing.type !== input.type) { + throw new Error("Authentication provider type cannot change.") + } + const provider = await database.authenticationProvider.upsert({ + where: { key: input.key }, + create: { key: input.key, type: input.type, enabled: input.enabled }, + update: { enabled: input.enabled }, + }) + if (provider.type !== input.type) { + throw new Error("Authentication provider type cannot change.") + } + return database.authenticationProviderRevision.create({ + data: { + authenticationProviderId: provider.id, + revision: input.revision, + issuer: input.issuer, + configuration: input.configuration, + }, + include: { authenticationProvider: true }, + }) +} 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/authentication-providers/types.ts b/hub/server/repositories/authentication-providers/types.ts new file mode 100644 index 00000000..8c320138 --- /dev/null +++ b/hub/server/repositories/authentication-providers/types.ts @@ -0,0 +1,10 @@ +import type { Prisma } from "../../generated/prisma/client.ts" + +export interface AuthenticationProviderInput { + key: string + type: "github" | "oidc" + enabled: boolean + revision: number + issuer: string + configuration: Prisma.InputJsonValue +} diff --git a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts new file mode 100644 index 00000000..95123735 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts @@ -0,0 +1,52 @@ +import type { AuthorizationAttempt } from "#server/repositories/authorization-attempts/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +interface ConsumedAuthorizationAttempt extends AuthorizationAttempt { + valid: boolean +} + +export async function consumeAuthorizationAttempt( + database: Prisma.TransactionClient, + stateDigest: string, +): Promise { + const [attempt] = await database.$queryRaw` + DELETE FROM authorization_attempts + WHERE state_digest = ${stateDigest} + RETURNING + id, + authentication_provider_revision_id AS "authenticationProviderRevisionId", + originating_session_id AS "originatingSessionId", + state_digest AS "stateDigest", + browser_digest AS "browserDigest", + pkce_verifier AS "pkceVerifier", + nonce, + purpose, + session_generation AS "sessionGeneration", + expires_at AS "expiresAt", + expires_at > clock_timestamp() AS valid + ` + if (!attempt) return null + if (!attempt.valid) return null + if ( + attempt.purpose !== "sign-in" && + attempt.purpose !== "verify" && + attempt.purpose !== "link-identity" && + attempt.purpose !== "bootstrap" && + attempt.purpose !== "recovery" + ) { + throw new Error("Stored authorization purpose is invalid.") + } + return { + id: attempt.id, + authenticationProviderRevisionId: attempt.authenticationProviderRevisionId, + originatingSessionId: attempt.originatingSessionId, + stateDigest: attempt.stateDigest, + browserDigest: attempt.browserDigest, + 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 new file mode 100644 index 00000000..cab6fc09 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/create-attempt/index.ts @@ -0,0 +1,13 @@ +import type { AuthorizationAttempt } from "#server/repositories/authorization-attempts/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createAuthorizationAttempt( + database: Prisma.TransactionClient, + 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 new file mode 100644 index 00000000..9bad2393 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/types.ts @@ -0,0 +1,12 @@ +export interface AuthorizationAttempt { + id: string + authenticationProviderRevisionId: string + originatingSessionId: string | null + stateDigest: string + browserDigest: string + pkceVerifier: string + nonce: string | null + purpose: "sign-in" | "verify" | "link-identity" | "bootstrap" | "recovery" + sessionGeneration: number + expiresAt: Date +} diff --git a/hub/server/repositories/external-identities/get-external-identity/index.ts b/hub/server/repositories/external-identities/get-external-identity/index.ts new file mode 100644 index 00000000..126c6da0 --- /dev/null +++ b/hub/server/repositories/external-identities/get-external-identity/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getExternalIdentity( + database: Prisma.TransactionClient, + provider: string, + issuer: string, + subject: string, +) { + return database.externalIdentity.findUnique({ + where: { provider_issuer_subject: { provider, issuer, subject } }, + include: { user: true }, + }) +} diff --git a/hub/server/repositories/external-identities/save-external-identity/index.ts b/hub/server/repositories/external-identities/save-external-identity/index.ts new file mode 100644 index 00000000..9a386b2e --- /dev/null +++ b/hub/server/repositories/external-identities/save-external-identity/index.ts @@ -0,0 +1,20 @@ +import type { ExternalIdentityInput } from "#server/repositories/external-identities/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function saveExternalIdentity( + database: Prisma.TransactionClient, + identity: ExternalIdentityInput, +) { + const { provider, issuer, subject } = identity + return database.externalIdentity.upsert({ + where: { provider_issuer_subject: { provider, issuer, subject } }, + create: identity, + update: { + username: identity.username, + displayName: identity.displayName, + avatarUrl: identity.avatarUrl, + }, + include: { user: true }, + }) +} diff --git a/hub/server/repositories/external-identities/types.ts b/hub/server/repositories/external-identities/types.ts new file mode 100644 index 00000000..f35f00f4 --- /dev/null +++ b/hub/server/repositories/external-identities/types.ts @@ -0,0 +1,13 @@ +export interface ExternalIdentityInput { + userId: string + provider: string + issuer: string + subject: string + username: string | null + displayName: string | null + avatarUrl: string | null +} + +export interface HubExternalIdentity extends ExternalIdentityInput { + id: string +} diff --git a/hub/server/repositories/github-integration-state/advance-generation/index.ts b/hub/server/repositories/github-integration-state/advance-generation/index.ts new file mode 100644 index 00000000..92420a95 --- /dev/null +++ b/hub/server/repositories/github-integration-state/advance-generation/index.ts @@ -0,0 +1,11 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function advanceGitHubIntegrationGeneration(database: Prisma.TransactionClient) { + await database.gitHubIntegrationState.upsert({ + where: { id: staticGitHubIntegrationRecordId }, + create: { id: staticGitHubIntegrationRecordId, generation: 1 }, + update: { generation: { increment: 1 } }, + }) +} diff --git a/hub/server/repositories/github-integration-state/constants.ts b/hub/server/repositories/github-integration-state/constants.ts new file mode 100644 index 00000000..b86d5af4 --- /dev/null +++ b/hub/server/repositories/github-integration-state/constants.ts @@ -0,0 +1,2 @@ +// Every Hub process must lock the same row before changing GitHub integration credentials. +export const staticGitHubIntegrationRecordId = "00000000-0000-4000-8000-000000000001" diff --git a/hub/server/repositories/github-integration-state/ensure-state/index.ts b/hub/server/repositories/github-integration-state/ensure-state/index.ts new file mode 100644 index 00000000..c3a1116f --- /dev/null +++ b/hub/server/repositories/github-integration-state/ensure-state/index.ts @@ -0,0 +1,11 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function ensureGitHubIntegrationState(database: Prisma.TransactionClient) { + await database.gitHubIntegrationState.upsert({ + where: { id: staticGitHubIntegrationRecordId }, + create: { id: staticGitHubIntegrationRecordId }, + update: {}, + }) +} diff --git a/hub/server/repositories/github-integration-state/get-state/index.ts b/hub/server/repositories/github-integration-state/get-state/index.ts new file mode 100644 index 00000000..1107a6cf --- /dev/null +++ b/hub/server/repositories/github-integration-state/get-state/index.ts @@ -0,0 +1,9 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getGitHubIntegrationState(database: Prisma.TransactionClient) { + return database.gitHubIntegrationState.findUniqueOrThrow({ + where: { id: staticGitHubIntegrationRecordId }, + }) +} diff --git a/hub/server/repositories/github-integration-state/save-credentials/index.ts b/hub/server/repositories/github-integration-state/save-credentials/index.ts new file mode 100644 index 00000000..e053f527 --- /dev/null +++ b/hub/server/repositories/github-integration-state/save-credentials/index.ts @@ -0,0 +1,14 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveGitHubIntegrationCredentials( + database: Prisma.TransactionClient, + encryptedCredentials: string, +) { + await database.gitHubIntegrationState.upsert({ + where: { id: staticGitHubIntegrationRecordId }, + create: { id: staticGitHubIntegrationRecordId, encryptedCredentials }, + update: { encryptedCredentials }, + }) +} diff --git a/hub/server/repositories/github-integration-state/types.ts b/hub/server/repositories/github-integration-state/types.ts new file mode 100644 index 00000000..b26f045a --- /dev/null +++ b/hub/server/repositories/github-integration-state/types.ts @@ -0,0 +1,4 @@ +export interface GitHubIntegrationState { + generation: number + encryptedCredentials: string | null +} 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/complete-bootstrap/index.ts b/hub/server/repositories/identity-store-state/complete-bootstrap/index.ts new file mode 100644 index 00000000..c8a1a35e --- /dev/null +++ b/hub/server/repositories/identity-store-state/complete-bootstrap/index.ts @@ -0,0 +1,14 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +import { staticIdentityStoreRecordId } from "../constants.ts" + +export async function completeBootstrap( + database: Prisma.TransactionClient, + completedAt: Date, +): Promise { + const updated = await database.identityStoreState.updateMany({ + where: { id: staticIdentityStoreRecordId, bootstrapCompletedAt: null }, + data: { bootstrapCompletedAt: completedAt }, + }) + return updated.count === 1 +} diff --git a/hub/server/repositories/identity-store-state/constants.ts b/hub/server/repositories/identity-store-state/constants.ts new file mode 100644 index 00000000..3d42c96e --- /dev/null +++ b/hub/server/repositories/identity-store-state/constants.ts @@ -0,0 +1,2 @@ +// Every Hub process must lock the same row before changing identity persistence. +export const staticIdentityStoreRecordId = "00000000-0000-4000-8000-000000000002" diff --git a/hub/server/repositories/identity-store-state/get-bootstrap-completed-at/index.ts b/hub/server/repositories/identity-store-state/get-bootstrap-completed-at/index.ts new file mode 100644 index 00000000..eae2e392 --- /dev/null +++ b/hub/server/repositories/identity-store-state/get-bootstrap-completed-at/index.ts @@ -0,0 +1,11 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function getBootstrapCompletedAt(database: Prisma.TransactionClient) { + const state = await database.identityStoreState.findUniqueOrThrow({ + where: { id: staticIdentityStoreRecordId }, + select: { bootstrapCompletedAt: true }, + }) + return state.bootstrapCompletedAt +} 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/identity-store-state/with-store-lock/index.ts b/hub/server/repositories/identity-store-state/with-store-lock/index.ts new file mode 100644 index 00000000..f8b8b247 --- /dev/null +++ b/hub/server/repositories/identity-store-state/with-store-lock/index.ts @@ -0,0 +1,18 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +export function withIdentityStoreLock( + client: PrismaClient, + operation: (database: Prisma.TransactionClient) => Promise, +): Promise { + return client.$transaction(async (transaction) => { + await transaction.identityStoreState.upsert({ + where: { id: staticIdentityStoreRecordId }, + create: { id: staticIdentityStoreRecordId }, + update: {}, + }) + await transaction.$queryRaw`SELECT id FROM identity_store_states WHERE id = ${staticIdentityStoreRecordId}::uuid FOR UPDATE` + return operation(transaction) + }) +} diff --git a/hub/server/repositories/installation-memberships/count-active-administrators/index.ts b/hub/server/repositories/installation-memberships/count-active-administrators/index.ts new file mode 100644 index 00000000..877e61a5 --- /dev/null +++ b/hub/server/repositories/installation-memberships/count-active-administrators/index.ts @@ -0,0 +1,7 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function countActiveAdministrators(database: Prisma.TransactionClient) { + return database.installationMembership.count({ + where: { status: "active", role: "administrator" }, + }) +} diff --git a/hub/server/repositories/installation-memberships/get-membership-by-id/index.ts b/hub/server/repositories/installation-memberships/get-membership-by-id/index.ts new file mode 100644 index 00000000..82885058 --- /dev/null +++ b/hub/server/repositories/installation-memberships/get-membership-by-id/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipDetails } from "../types.ts" + +export async function getInstallationMembershipById( + database: Prisma.TransactionClient, + id: string, +): Promise { + return (await database.installationMembership.findUnique({ + where: { id }, + include: { user: { include: { externalIdentities: true } } }, + })) as InstallationMembershipDetails | null +} diff --git a/hub/server/repositories/installation-memberships/get-membership/index.ts b/hub/server/repositories/installation-memberships/get-membership/index.ts new file mode 100644 index 00000000..f79ddec6 --- /dev/null +++ b/hub/server/repositories/installation-memberships/get-membership/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipRecord } from "../types.ts" + +export async function getInstallationMembership( + database: Prisma.TransactionClient, + userId: string, +): Promise { + return (await database.installationMembership.findUnique({ + where: { userId }, + })) as InstallationMembershipRecord | null +} diff --git a/hub/server/repositories/installation-memberships/get-memberships/index.ts b/hub/server/repositories/installation-memberships/get-memberships/index.ts new file mode 100644 index 00000000..5c1e734c --- /dev/null +++ b/hub/server/repositories/installation-memberships/get-memberships/index.ts @@ -0,0 +1,21 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipDetails, InstallationMembershipPage } from "../types.ts" + +export async function getInstallationMemberships( + database: Prisma.TransactionClient, + cursor: string | null = null, +): Promise { + const records = (await database.installationMembership.findMany({ + include: { user: { include: { externalIdentities: true } } }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + cursor: cursor ? { id: cursor } : undefined, + skip: cursor ? 1 : undefined, + take: 101, + })) as InstallationMembershipDetails[] + const hasMore = records.length > 100 + const memberships = hasMore ? records.slice(0, 100) : records + return { + memberships, + nextCursor: hasMore ? (memberships.at(-1)?.id ?? null) : null, + } +} diff --git a/hub/server/repositories/installation-memberships/save-membership/index.ts b/hub/server/repositories/installation-memberships/save-membership/index.ts new file mode 100644 index 00000000..930b75ae --- /dev/null +++ b/hub/server/repositories/installation-memberships/save-membership/index.ts @@ -0,0 +1,18 @@ +import type { + InstallationMembershipInput, + InstallationMembershipRecord, +} from "#server/repositories/installation-memberships/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveInstallationMembership( + database: Prisma.TransactionClient, + input: InstallationMembershipInput, +): Promise { + const { userId, ...membership } = input + return (await database.installationMembership.upsert({ + where: { userId }, + create: input, + update: membership, + })) as InstallationMembershipRecord +} diff --git a/hub/server/repositories/installation-memberships/types.ts b/hub/server/repositories/installation-memberships/types.ts new file mode 100644 index 00000000..4d09271e --- /dev/null +++ b/hub/server/repositories/installation-memberships/types.ts @@ -0,0 +1,29 @@ +export interface InstallationMembershipInput { + userId: string + status: "active" | "suspended" | "removed" + role: "member" | "administrator" + source: "bootstrap" | "invitation" | "manual" | "recovery" | "synchronized" + suspendedAt?: Date | null + removedAt?: Date | null +} + +export interface InstallationMembershipRecord { + id: string + userId: string + status: InstallationMembershipInput["status"] + role: InstallationMembershipInput["role"] + source: InstallationMembershipInput["source"] + suspendedAt: Date | null + removedAt: Date | null +} + +export interface InstallationMembershipDetails extends InstallationMembershipRecord { + user: HubUser & { externalIdentities: HubExternalIdentity[] } +} + +export interface InstallationMembershipPage { + memberships: InstallationMembershipDetails[] + nextCursor: string | null +} +import type { HubExternalIdentity } from "#server/repositories/external-identities/types" +import type { HubUser } from "#server/repositories/users/types" diff --git a/hub/server/repositories/membership-invitations/consume-invitation/index.ts b/hub/server/repositories/membership-invitations/consume-invitation/index.ts new file mode 100644 index 00000000..ba1b2194 --- /dev/null +++ b/hub/server/repositories/membership-invitations/consume-invitation/index.ts @@ -0,0 +1,15 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function consumeMembershipInvitation( + database: Prisma.TransactionClient, + id: string, + activatedIdentityId: string, + activatedMembershipId: string, + consumedAt: Date, +): Promise { + const updated = await database.membershipInvitation.updateMany({ + where: { id, consumedAt: null, revokedAt: null, expiresAt: { gt: consumedAt } }, + data: { activatedIdentityId, activatedMembershipId, consumedAt }, + }) + return updated.count === 1 +} diff --git a/hub/server/repositories/membership-invitations/create-invitation/index.ts b/hub/server/repositories/membership-invitations/create-invitation/index.ts new file mode 100644 index 00000000..dd721b6b --- /dev/null +++ b/hub/server/repositories/membership-invitations/create-invitation/index.ts @@ -0,0 +1,15 @@ +import type { + MembershipInvitationInput, + MembershipInvitationRecord, +} from "#server/repositories/membership-invitations/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createMembershipInvitation( + database: Prisma.TransactionClient, + input: MembershipInvitationInput, +): Promise { + return (await database.membershipInvitation.create({ + data: input, + })) as MembershipInvitationRecord +} diff --git a/hub/server/repositories/membership-invitations/get-invitation/index.ts b/hub/server/repositories/membership-invitations/get-invitation/index.ts new file mode 100644 index 00000000..d4d3d130 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-invitation/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationRecord } from "../types.ts" + +export async function getMembershipInvitation( + database: Prisma.TransactionClient, + id: string, +): Promise { + return (await database.membershipInvitation.findUnique({ + where: { id }, + })) as MembershipInvitationRecord | null +} diff --git a/hub/server/repositories/membership-invitations/get-invitations/index.ts b/hub/server/repositories/membership-invitations/get-invitations/index.ts new file mode 100644 index 00000000..cac665c6 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-invitations/index.ts @@ -0,0 +1,20 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationPage, MembershipInvitationRecord } from "../types.ts" + +export async function getMembershipInvitations( + database: Prisma.TransactionClient, + cursor: string | null = null, +): Promise { + const records = (await database.membershipInvitation.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + cursor: cursor ? { id: cursor } : undefined, + skip: cursor ? 1 : undefined, + take: 101, + })) as MembershipInvitationRecord[] + const hasMore = records.length > 100 + const invitations = hasMore ? records.slice(0, 100) : records + return { + invitations, + nextCursor: hasMore ? (invitations.at(-1)?.id ?? null) : null, + } +} diff --git a/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts b/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts new file mode 100644 index 00000000..9c7dfed6 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts @@ -0,0 +1,22 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationRecord } from "../types.ts" + +export async function getUsableMembershipInvitation( + database: Prisma.TransactionClient, + provider: string, + issuer: string, + subject: string, + now: Date, +): Promise { + return (await database.membershipInvitation.findFirst({ + where: { + provider, + issuer, + subject, + consumedAt: null, + revokedAt: null, + expiresAt: { gt: now }, + }, + orderBy: { createdAt: "asc" }, + })) as MembershipInvitationRecord | null +} diff --git a/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts b/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts new file mode 100644 index 00000000..ba501bfc --- /dev/null +++ b/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function revokeExpiredMembershipInvitations( + database: Prisma.TransactionClient, + revokedAt: Date, +) { + await database.membershipInvitation.updateMany({ + where: { consumedAt: null, revokedAt: null, expiresAt: { lte: revokedAt } }, + data: { revokedAt }, + }) +} diff --git a/hub/server/repositories/membership-invitations/revoke-invitation/index.ts b/hub/server/repositories/membership-invitations/revoke-invitation/index.ts new file mode 100644 index 00000000..563e8665 --- /dev/null +++ b/hub/server/repositories/membership-invitations/revoke-invitation/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function revokeMembershipInvitation( + database: Prisma.TransactionClient, + id: string, + revokedAt: Date, +): Promise { + const updated = await database.membershipInvitation.updateMany({ + where: { id, consumedAt: null, revokedAt: null }, + data: { revokedAt }, + }) + return updated.count === 1 +} diff --git a/hub/server/repositories/membership-invitations/types.ts b/hub/server/repositories/membership-invitations/types.ts new file mode 100644 index 00000000..af3facb4 --- /dev/null +++ b/hub/server/repositories/membership-invitations/types.ts @@ -0,0 +1,22 @@ +export interface MembershipInvitationInput { + createdByUserId: string | null + provider: string + issuer: string + subject: string + role: "member" | "administrator" + expiresAt: Date +} + +export interface MembershipInvitationRecord extends MembershipInvitationInput { + id: string + activatedIdentityId: string | null + activatedMembershipId: string | null + consumedAt: Date | null + revokedAt: Date | null + createdAt: Date +} + +export interface MembershipInvitationPage { + invitations: MembershipInvitationRecord[] + nextCursor: string | null +} diff --git a/hub/server/repositories/security-audits/constants.ts b/hub/server/repositories/security-audits/constants.ts new file mode 100644 index 00000000..2f80cb2e --- /dev/null +++ b/hub/server/repositories/security-audits/constants.ts @@ -0,0 +1,2 @@ +export const auditRetentionMs = 90 * 24 * 60 * 60_000 +export const auditResultLimit = 100 diff --git a/hub/server/repositories/security-audits/get-audits/index.ts b/hub/server/repositories/security-audits/get-audits/index.ts new file mode 100644 index 00000000..6783bc47 --- /dev/null +++ b/hub/server/repositories/security-audits/get-audits/index.ts @@ -0,0 +1,14 @@ +import { auditResultLimit, auditRetentionMs } from "#server/repositories/security-audits/constants" +import type { SecurityAuditRecord } from "#server/repositories/security-audits/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function getSecurityAudits( + database: Prisma.TransactionClient, +): Promise { + return database.securityAudit.findMany({ + where: { createdAt: { gte: new Date(Date.now() - auditRetentionMs) } }, + orderBy: { createdAt: "desc" }, + take: auditResultLimit, + }) +} diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts new file mode 100644 index 00000000..b67c6235 --- /dev/null +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -0,0 +1,30 @@ +import { auditRetentionMs } from "#server/repositories/security-audits/constants" +import type { AuditInput } from "#server/repositories/security-audits/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveSecurityAudit(database: Prisma.TransactionClient, data: AuditInput) { + const context = data.context ?? {} + const validContext = + Object.keys(context).every((key) => key === "reason" || key === "source") && + Object.values(context).every((value) => /^[a-z][a-z0-9_]{0,63}$/.test(value)) + if ( + !/^[a-z][a-z0-9_.]{0,79}$/.test(data.event) || + !["succeeded", "denied", "failed"].includes(data.outcome ?? "succeeded") || + !validContext + ) { + throw new Error("Security audit input is invalid.") + } + await database.securityAudit.deleteMany({ + where: { createdAt: { lt: new Date(Date.now() - auditRetentionMs) } }, + }) + await database.securityAudit.create({ + data: { + ...data, + outcome: data.outcome ?? "succeeded", + targetMembershipId: data.targetMembershipId ?? null, + invitationId: data.invitationId ?? null, + context, + }, + }) +} diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts new file mode 100644 index 00000000..7283d69c --- /dev/null +++ b/hub/server/repositories/security-audits/types.ts @@ -0,0 +1,23 @@ +export interface AuditInput { + event: string + outcome?: "succeeded" | "denied" | "failed" + actorUserId: string | null + sessionId: string | null + authenticationProviderRevisionId: string | null + targetMembershipId?: string | null + invitationId?: string | null + context?: Record +} + +export interface SecurityAuditRecord { + id: string + event: string + outcome: string + actorUserId: string | null + sessionId: string | null + authenticationProviderRevisionId: string | null + targetMembershipId: string | null + invitationId: string | null + context: unknown + createdAt: Date +} diff --git a/hub/server/repositories/sessions/create-session/index.ts b/hub/server/repositories/sessions/create-session/index.ts new file mode 100644 index 00000000..8a8e8a5d --- /dev/null +++ b/hub/server/repositories/sessions/create-session/index.ts @@ -0,0 +1,32 @@ +import type { HubSession } from "#server/repositories/sessions/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createSession( + database: Prisma.TransactionClient, + data: Omit, +) { + const [identity, providerRevision] = await Promise.all([ + database.externalIdentity.findUnique({ + where: { id: data.externalIdentityId }, + select: { userId: true, provider: true, issuer: true }, + }), + database.authenticationProviderRevision.findUnique({ + where: { id: data.authenticationProviderRevisionId }, + select: { issuer: true, authenticationProvider: { select: { type: true } } }, + }), + ]) + if (identity?.userId !== data.userId) { + throw new Error("Session external identity must belong to the Hub user.") + } + if (!providerRevision) { + throw new Error("Session authentication provider revision does not exist.") + } + if (identity.provider !== providerRevision.authenticationProvider.type) { + throw new Error("Session external identity provider must match the authentication provider.") + } + if (identity.issuer !== providerRevision.issuer) { + throw new Error("Session external identity issuer must match the provider revision.") + } + return database.session.create({ data }) +} diff --git a/hub/server/repositories/sessions/delete-session/index.ts b/hub/server/repositories/sessions/delete-session/index.ts new file mode 100644 index 00000000..439d061e --- /dev/null +++ b/hub/server/repositories/sessions/delete-session/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteSession(database: Prisma.TransactionClient, id: string) { + await database.session.deleteMany({ where: { id } }) +} diff --git a/hub/server/repositories/sessions/delete-sessions/index.ts b/hub/server/repositories/sessions/delete-sessions/index.ts new file mode 100644 index 00000000..2d8f5fd1 --- /dev/null +++ b/hub/server/repositories/sessions/delete-sessions/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteSessions(database: Prisma.TransactionClient) { + await database.session.deleteMany() +} diff --git a/hub/server/repositories/sessions/delete-user-sessions/index.ts b/hub/server/repositories/sessions/delete-user-sessions/index.ts new file mode 100644 index 00000000..81870fda --- /dev/null +++ b/hub/server/repositories/sessions/delete-user-sessions/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteUserSessions(database: Prisma.TransactionClient, userId: string) { + await database.session.deleteMany({ where: { userId } }) +} diff --git a/hub/server/repositories/sessions/get-session-by-id/index.ts b/hub/server/repositories/sessions/get-session-by-id/index.ts new file mode 100644 index 00000000..4d7e4df6 --- /dev/null +++ b/hub/server/repositories/sessions/get-session-by-id/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getSessionById(database: Prisma.TransactionClient, id: string) { + return database.session.findUnique({ + where: { id }, + include: { + user: true, + externalIdentity: true, + authenticationProviderRevision: { include: { authenticationProvider: true } }, + }, + }) +} diff --git a/hub/server/repositories/sessions/get-session/index.ts b/hub/server/repositories/sessions/get-session/index.ts new file mode 100644 index 00000000..746b6750 --- /dev/null +++ b/hub/server/repositories/sessions/get-session/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getSession(database: Prisma.TransactionClient, tokenDigest: string) { + return database.session.findUnique({ + where: { tokenDigest }, + include: { + user: true, + externalIdentity: true, + authenticationProviderRevision: { include: { authenticationProvider: true } }, + }, + }) +} diff --git a/hub/server/repositories/sessions/invalidate-freshness/index.ts b/hub/server/repositories/sessions/invalidate-freshness/index.ts new file mode 100644 index 00000000..9b106204 --- /dev/null +++ b/hub/server/repositories/sessions/invalidate-freshness/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function invalidateFreshness(database: Prisma.TransactionClient) { + await database.session.updateMany({ data: { freshAuthorizedAt: null } }) +} diff --git a/hub/server/repositories/sessions/types.ts b/hub/server/repositories/sessions/types.ts new file mode 100644 index 00000000..f49defe5 --- /dev/null +++ b/hub/server/repositories/sessions/types.ts @@ -0,0 +1,9 @@ +export interface HubSession { + id: string + userId: string + externalIdentityId: string + authenticationProviderRevisionId: string + tokenDigest: string + signedInAt: Date + freshAuthorizedAt: Date | null +} diff --git a/hub/server/repositories/sessions/verify-session/index.ts b/hub/server/repositories/sessions/verify-session/index.ts new file mode 100644 index 00000000..2b278df8 --- /dev/null +++ b/hub/server/repositories/sessions/verify-session/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function verifySession( + database: Prisma.TransactionClient, + id: string, + freshAuthorizedAt: Date, +) { + await database.session.update({ + where: { id }, + data: { freshAuthorizedAt }, + }) +} diff --git a/hub/server/repositories/users/create-user/index.ts b/hub/server/repositories/users/create-user/index.ts new file mode 100644 index 00000000..3282981a --- /dev/null +++ b/hub/server/repositories/users/create-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 createUser(database: Prisma.TransactionClient, input: UserInput) { + return database.user.create({ data: input }) +} diff --git a/hub/server/repositories/users/get-user/index.ts b/hub/server/repositories/users/get-user/index.ts new file mode 100644 index 00000000..956bc0e6 --- /dev/null +++ b/hub/server/repositories/users/get-user/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getUser(database: Prisma.TransactionClient, id: string) { + return database.user.findUnique({ where: { id } }) +} diff --git a/hub/server/repositories/users/types.ts b/hub/server/repositories/users/types.ts new file mode 100644 index 00000000..50eb5c67 --- /dev/null +++ b/hub/server/repositories/users/types.ts @@ -0,0 +1,7 @@ +export interface HubUser { + id: string + displayName: string | null + avatarUrl: string | null +} + +export type UserInput = Omit 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/administration.test.ts b/hub/server/routes/administration.test.ts new file mode 100644 index 00000000..dc8db20e --- /dev/null +++ b/hub/server/routes/administration.test.ts @@ -0,0 +1,213 @@ +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 { AdministrationService } from "#server/domains/administration/create-service/types" +import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { AuthenticationRoutes } from "#server/routes/types" +import { AuthenticationError } from "#server/utils/authentication-error" + +import { createApp } from "../create-app/index.ts" + +const csrfToken = "c".repeat(43) +const sessionToken = "s".repeat(43) +const protectedHeaders = { + origin: "https://hub.example", + cookie: `__Host-replica-csrf=${csrfToken}; __Host-replica-session=${sessionToken}`, + "content-type": "application/json", + "x-csrf-token": csrfToken, +} + +function runtime( + overrides: Partial> = {}, +): AuthenticationRoutes & { administration: Mocked } { + const service = { + start: vi.fn(), + complete: vi.fn(), + current: vi.fn(), + signOut: vi.fn(), + revokeAll: vi.fn(), + } as unknown as Mocked + const administration = Object.assign( + { + list: vi.fn().mockResolvedValue({ + memberships: { memberships: [], nextCursor: null }, + invitations: { invitations: [], nextCursor: null }, + }), + invite: vi.fn(), + revokeInvitation: vi.fn(), + changeMembership: vi.fn(), + }, + overrides, + ) + return { + service, + administration, + settings: { + origin: "https://hub.example", + secureCookies: true, + configured: true, + admissionPolicy: "hub-managed", + trustedProxyHops: 0, + }, + } +} + +async function serve(authentication: AuthenticationRoutes) { + const server = createServer(createApp({ authentication })) + 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}` +} + +describe("administration routes", () => { + it("returns an explicit authorization failure without leaking the session token", async () => { + const authentication = runtime({ + list: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ), + ), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration`, { + headers: { cookie: `__Host-replica-session=${sessionToken}` }, + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "administrator_required", + message: "Administrator access is required.", + }) + expect(authentication.administration.list.mock.calls).toEqual([[sessionToken, {}]]) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") + }) + + it("passes independent collection cursors and returns the paged administration response", async () => { + const page = { + memberships: { memberships: [], nextCursor: "next-membership" }, + invitations: { invitations: [], nextCursor: "next-invitation" }, + } + const authentication = runtime({ + list: vi.fn().mockResolvedValue(page), + }) + const baseUrl = await serve(authentication) + + const response = await fetch( + `${baseUrl}/api/administration?membershipsCursor=membership-cursor&invitationsCursor=invitation-cursor`, + { + headers: { cookie: `__Host-replica-session=${sessionToken}` }, + }, + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual(page) + expect(authentication.administration.list.mock.calls).toContainEqual([ + sessionToken, + { + memberships: "membership-cursor", + invitations: "invitation-cursor", + }, + ]) + }) + + it.each([ + { name: "missing", origin: undefined }, + { name: "wrong", origin: "https://attacker.example" }, + ])("rejects a $name Origin before an administration mutation runs", async ({ origin }) => { + const authentication = runtime() + const baseUrl = await serve(authentication) + const headers = { ...protectedHeaders } + if (origin) headers.origin = origin + else delete (headers as { origin?: string }).origin + + const response = await fetch(`${baseUrl}/api/administration/invitations`, { + method: "POST", + headers, + body: JSON.stringify({ + provider: "github", + issuer: "https://github.com", + subject: "42", + role: "member", + }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(authentication.administration.invite.mock.calls).toEqual([]) + }) + + it("rejects membership changes without matching CSRF before calling the service", async () => { + const authentication = runtime() + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/memberships/membership`, { + method: "PATCH", + headers: { ...protectedHeaders, "x-csrf-token": "wrong-token" }, + body: JSON.stringify({ status: "suspended" }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(authentication.administration.changeMembership.mock.calls).toEqual([]) + }) + + it("preserves the last-administrator conflict shape", async () => { + const authentication = runtime({ + changeMembership: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 409, + "last_administrator", + "The final active administrator cannot be changed.", + ), + ), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/memberships/membership`, { + method: "PATCH", + headers: protectedHeaders, + body: JSON.stringify({ role: "member" }), + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: "last_administrator", + message: "The final active administrator cannot be changed.", + }) + }) + + it("delegates invitation revocation with the authenticated session", async () => { + const authentication = runtime({ + revokeInvitation: vi.fn().mockResolvedValue(undefined), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/invitations/invitation`, { + method: "DELETE", + headers: protectedHeaders, + }) + + expect(response.status).toBe(204) + expect(authentication.administration.revokeInvitation.mock.calls).toEqual([ + [sessionToken, "invitation"], + ]) + }) +}) diff --git a/hub/server/routes/api/administration/get/index.ts b/hub/server/routes/api/administration/get/index.ts new file mode 100644 index 00000000..df022c4c --- /dev/null +++ b/hub/server/routes/api/administration/get/index.ts @@ -0,0 +1,25 @@ +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" + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + response.json( + await authentication.administration.list(readCookie(request.headers.cookie, cookies.session), { + memberships: + typeof request.query.membershipsCursor === "string" + ? request.query.membershipsCursor + : undefined, + invitations: + typeof request.query.invitationsCursor === "string" + ? request.query.invitationsCursor + : undefined, + }), + ) +} diff --git a/hub/server/routes/api/administration/invitations/delete/index.ts b/hub/server/routes/api/administration/invitations/delete/index.ts new file mode 100644 index 00000000..5fc68ebd --- /dev/null +++ b/hub/server/routes/api/administration/invitations/delete/index.ts @@ -0,0 +1,23 @@ +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 remove( + 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.administration.revokeInvitation( + readCookie(request.headers.cookie, cookies.session), + typeof request.params.id === "string" ? request.params.id : "", + ) + response.status(204).end() +} diff --git a/hub/server/routes/api/administration/invitations/post/index.ts b/hub/server/routes/api/administration/invitations/post/index.ts new file mode 100644 index 00000000..0a9b576c --- /dev/null +++ b/hub/server/routes/api/administration/invitations/post/index.ts @@ -0,0 +1,23 @@ +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 invitation = await authentication.administration.invite( + readCookie(request.headers.cookie, cookies.session), + request.body, + ) + response.status(201).json({ invitation }) +} diff --git a/hub/server/routes/api/administration/memberships/patch/index.ts b/hub/server/routes/api/administration/memberships/patch/index.ts new file mode 100644 index 00000000..aaa04c8d --- /dev/null +++ b/hub/server/routes/api/administration/memberships/patch/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 patch( + 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 membership = await authentication.administration.changeMembership( + readCookie(request.headers.cookie, cookies.session), + typeof request.params.id === "string" ? request.params.id : "", + request.body, + ) + response.json({ membership }) +} diff --git a/hub/server/routes/api/auth/enrollment/post/index.ts b/hub/server/routes/api/auth/enrollment/post/index.ts new file mode 100644 index 00000000..92795e3f --- /dev/null +++ b/hub/server/routes/api/auth/enrollment/post/index.ts @@ -0,0 +1,33 @@ +import type { Request, Response } from "express" + +import type { AuthorizationPurpose } from "#server/domains/authentication/create-service/types" +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { requireCsrfToken } from "#server/utils/require-csrf-token" +import { readCookie } from "#server/utils/read-cookie" + +export async function post( + authentication: AuthenticationRoutes, + purpose: Extract, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const secret = + request.body && + typeof request.body === "object" && + !Array.isArray(request.body) && + typeof (request.body as Record).secret === "string" + ? (request.body as Record).secret + : "" + const attempt = await authentication.service.start(purpose, undefined, secret) + response.cookie(cookies.authorization, attempt.browserToken, { + ...cookies.privateOptions, + maxAge: 10 * 60_000, + }) + response.json({ authorizationUrl: attempt.authorizationUrl }) +} 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..9f297743 --- /dev/null +++ b/hub/server/routes/api/session/get/index.ts @@ -0,0 +1,55 @@ +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, + }, + membership: { + id: current.membership.id, + role: current.membership.role, + status: current.membership.status, + }, + }) + } 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..86dbe0bf --- /dev/null +++ b/hub/server/routes/authentication.test.ts @@ -0,0 +1,388 @@ +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, + trustedProxyHops = 0, +) { + 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, + ) + const administration = { + list: vi.fn().mockResolvedValue({ + memberships: { memberships: [], nextCursor: null }, + invitations: { invitations: [], nextCursor: null }, + }), + invite: vi.fn(), + revokeInvitation: vi.fn(), + changeMembership: vi.fn(), + } + return { + settings: { + origin: "https://hub.example", + secureCookies, + configured: true, + admissionPolicy: "hub-managed" as const, + trustedProxyHops, + }, + service, + administration, + } +} + +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("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.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) + + 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, + "x-forwarded-for": `198.51.100.${request + 1}`, + }, + }), + ) + } + + 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("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({ + 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: { + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "invitation", + suspendedAt: null, + removedAt: 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..e33dda0c 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -1,8 +1,77 @@ 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 { respondAdministrationError } from "#server/integrations/express/respond-administration-error" +import { get as handleApiAuthGithubCallbackGet } from "#server/routes/api/auth/github/callback/get" +import { post as handleApiAuthGithubPost } from "#server/routes/api/auth/github/post" +import { post as handleApiAuthEnrollmentPost } from "#server/routes/api/auth/enrollment/post" +import { get as handleApiAdministrationGet } from "#server/routes/api/administration/get" +import { post as handleApiAdministrationInvitationsPost } from "#server/routes/api/administration/invitations/post" +import { remove as handleApiAdministrationInvitationsDelete } from "#server/routes/api/administration/invitations/delete" +import { patch as handleApiAdministrationMembershipsPatch } from "#server/routes/api/administration/memberships/patch" 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", "/api/administration"], + prepareAuthenticationRequest.bind(null, authentication.settings.origin), + ) + 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.post( + "/api/auth/bootstrap", + initiationLimit, + handleApiAuthEnrollmentPost.bind(null, authentication, "bootstrap"), + ) + app.post( + "/api/auth/recovery", + initiationLimit, + handleApiAuthEnrollmentPost.bind(null, authentication, "recovery"), + ) + app.get("/api/administration", handleApiAdministrationGet.bind(null, authentication)) + app.post( + "/api/administration/invitations", + handleApiAdministrationInvitationsPost.bind(null, authentication), + ) + app.delete( + "/api/administration/invitations/:id", + handleApiAdministrationInvitationsDelete.bind(null, authentication), + ) + app.patch( + "/api/administration/memberships/:id", + handleApiAdministrationMembershipsPatch.bind(null, authentication), + ) + app.use("/api/administration", respondAdministrationError) + app.use(respondAuthenticationError) } diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts new file mode 100644 index 00000000..8a775590 --- /dev/null +++ b/hub/server/routes/types.ts @@ -0,0 +1,11 @@ +import type { + AuthenticationService, + AuthenticationSettings, +} from "#server/domains/authentication/create-service/types" +import type { AdministrationService } from "#server/domains/administration/create-service/types" + +export interface AuthenticationRoutes { + service: AuthenticationService + administration: AdministrationService + 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/logging/sanitize-log-value/constants.ts b/hub/server/utils/logging/sanitize-log-value/constants.ts index d15e3596..ce7fab5a 100644 --- a/hub/server/utils/logging/sanitize-log-value/constants.ts +++ b/hub/server/utils/logging/sanitize-log-value/constants.ts @@ -6,3 +6,5 @@ export const maxLogKeyLength = 128 export const truncationMarker = "[TRUNCATED]" export const sensitiveLogKeyPattern = /(password|passwd|passphrase|secret|token|authorization|cookie|apikey|privatekey|sessionid)/ +export const sensitiveRequestLogKeyPattern = + /(?:password|passwd|passphrase|secret|token|authorization|cookie|apikey|privatekey|sessionid|^(?:code|state|nonce|codeverifier)$)/ diff --git a/hub/server/utils/logging/sanitize-log-value/index.test.ts b/hub/server/utils/logging/sanitize-log-value/index.test.ts index ecb60b54..1e623fbf 100644 --- a/hub/server/utils/logging/sanitize-log-value/index.test.ts +++ b/hub/server/utils/logging/sanitize-log-value/index.test.ts @@ -25,6 +25,24 @@ describe("sanitizeLogValue()", () => { expect(input).toEqual(original) }) + it("keeps callback-shaped fields in general application log data", () => { + const input = { + code: "application-code", + STATE: "application-state", + "no-nce": "application-nonce", + code_verifier: "application-verifier", + access_token: "application-token", + } + + expect(sanitizeLogValue(input)).toEqual({ + code: "application-code", + STATE: "application-state", + "no-nce": "application-nonce", + code_verifier: "application-verifier", + access_token: "[REDACTED]", + }) + }) + it("makes cycles and non-JSON primitives safe to serialize", () => { const input: Record = { title: "Keep this", diff --git a/hub/server/utils/logging/sanitize-log-value/index.ts b/hub/server/utils/logging/sanitize-log-value/index.ts index fd81e426..aa9960cc 100644 --- a/hub/server/utils/logging/sanitize-log-value/index.ts +++ b/hub/server/utils/logging/sanitize-log-value/index.ts @@ -2,11 +2,18 @@ import { Buffer } from "node:buffer" import { visitLogValue } from "../visit-log-value/index.ts" -import { maxLogBytes } from "./constants.ts" +import { maxLogBytes, sensitiveLogKeyPattern } from "./constants.ts" import type { LogValue } from "./types.ts" /** Copy structured data with secret-key redaction and bounded JSON output. */ -export function sanitizeLogValue(value: unknown): LogValue { - const result = visitLogValue(value, 0, { ancestors: new WeakSet(), entries: 0 }) +export function sanitizeLogValue( + value: unknown, + sensitiveKeyPattern: RegExp = sensitiveLogKeyPattern, +): LogValue { + const result = visitLogValue(value, 0, { + ancestors: new WeakSet(), + entries: 0, + sensitiveKeyPattern, + }) return Buffer.byteLength(JSON.stringify(result)) <= maxLogBytes ? result : "[OMITTED: MAX BYTES]" } diff --git a/hub/server/utils/logging/visit-log-value/index.ts b/hub/server/utils/logging/visit-log-value/index.ts index 247eb881..902cccff 100644 --- a/hub/server/utils/logging/visit-log-value/index.ts +++ b/hub/server/utils/logging/visit-log-value/index.ts @@ -3,7 +3,6 @@ import { maxLogEntries, maxLogKeyLength, maxLogStringLength, - sensitiveLogKeyPattern, truncationMarker, } from "../sanitize-log-value/constants.ts" import type { LogValue } from "../sanitize-log-value/types.ts" @@ -45,7 +44,7 @@ export function visitLogValue(input: unknown, depth: number, traversal: LogTrave const descriptor = Object.getOwnPropertyDescriptor(input, key) if (!descriptor) continue let result: LogValue - if (sensitiveLogKeyPattern.test(key.toLowerCase().replace(/[^a-z0-9]/g, ""))) { + if (traversal.sensitiveKeyPattern.test(key.toLowerCase().replace(/[^a-z0-9]/g, ""))) { result = "[REDACTED]" } else if ("value" in descriptor) { result = visitLogValue(descriptor.value, depth + 1, traversal) diff --git a/hub/server/utils/logging/visit-log-value/types.ts b/hub/server/utils/logging/visit-log-value/types.ts index 9446a8f0..bb3c8573 100644 --- a/hub/server/utils/logging/visit-log-value/types.ts +++ b/hub/server/utils/logging/visit-log-value/types.ts @@ -1,4 +1,5 @@ export interface LogTraversal { ancestors: WeakSet entries: number + sensitiveKeyPattern: RegExp } 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) +} diff --git a/hub/test/database/global-setup/index.ts b/hub/test/database/global-setup/index.ts index cf60e93d..38645dfa 100644 --- a/hub/test/database/global-setup/index.ts +++ b/hub/test/database/global-setup/index.ts @@ -54,8 +54,8 @@ export default async function setup(project: TestProject) { process.execPath, [ join(hubRoot, "node_modules/prisma/build/index.js"), - "db", - "push", + "migrate", + "deploy", "--config", join(hubRoot, "prisma7.config.ts"), ], diff --git a/hub/test/database/migrations/index.db.test.ts b/hub/test/database/migrations/index.db.test.ts new file mode 100644 index 00000000..18dd8ea3 --- /dev/null +++ b/hub/test/database/migrations/index.db.test.ts @@ -0,0 +1,132 @@ +import { randomBytes } from "node:crypto" +import { readFile } from "node:fs/promises" +import { fileURLToPath } from "node:url" +import pg from "pg" +import { describe, expect, it, onTestFinished } from "vitest" + +describe("committed database migrations", () => { + it("upgrades retained identity records and enforces administration lifecycle constraints", async () => { + const adminUrl = process.env.TEST_DATABASE_URL + if (!adminUrl) throw new Error("TEST_DATABASE_URL is required.") + + const databaseName = `replica_migration_${randomBytes(8).toString("hex")}` + const admin = new pg.Client({ connectionString: adminUrl }) + await admin.connect() + await admin.query(`CREATE DATABASE ${pg.escapeIdentifier(databaseName)}`) + const databaseUrl = new URL(adminUrl) + databaseUrl.pathname = databaseName + const database = new pg.Client({ connectionString: databaseUrl.toString() }) + await database.connect() + onTestFinished(async () => { + await database.end() + await admin.query(`DROP DATABASE ${pg.escapeIdentifier(databaseName)} WITH (FORCE)`) + await admin.end() + }) + + const initialMigration = await readFile( + fileURLToPath( + new URL( + "../../../schema/migrations/20261026195151_identity/migration.sql", + import.meta.url, + ), + ), + "utf8", + ) + const administrationMigration = await readFile( + fileURLToPath( + new URL( + "../../../schema/migrations/20261027210000_membership_administration/migration.sql", + import.meta.url, + ), + ), + "utf8", + ) + await database.query(initialMigration) + await database.query(` + INSERT INTO "users" ("id", "display_name") + VALUES ('00000000-0000-4000-8000-000000000101', 'Suspended member'); + INSERT INTO "installation_memberships" ("id", "user_id", "status", "role", "source") + VALUES ( + '00000000-0000-4000-8000-000000000102', + '00000000-0000-4000-8000-000000000101', + 'suspended', + 'member', + 'manual' + ); + INSERT INTO "security_audits" ("id", "event") + VALUES + ('00000000-0000-4000-8000-000000000103', 'authentication.admission_denied'), + ('00000000-0000-4000-8000-000000000104', 'authentication.provider_failed'), + ('00000000-0000-4000-8000-000000000105', 'session.sign_in'); + `) + + await database.query(administrationMigration) + + const membership = await database.query<{ + status: string + suspendedAt: Date | null + removedAt: Date | null + }>(` + SELECT + "status", + "suspended_at" AS "suspendedAt", + "removed_at" AS "removedAt" + FROM "installation_memberships" + WHERE "id" = '00000000-0000-4000-8000-000000000102' + `) + expect(membership.rows[0]).toMatchObject({ + status: "suspended", + removedAt: null, + }) + expect(membership.rows[0]?.suspendedAt).toBeInstanceOf(Date) + const audits = await database.query<{ event: string; outcome: string }>(` + SELECT "event", "outcome" + FROM "security_audits" + ORDER BY "event" + `) + expect(audits.rows).toEqual([ + { event: "authentication.admission_denied", outcome: "denied" }, + { event: "authentication.provider_failed", outcome: "failed" }, + { event: "session.sign_in", outcome: "succeeded" }, + ]) + + await expect( + database.query(` + UPDATE "installation_memberships" + SET "status" = 'active' + WHERE "id" = '00000000-0000-4000-8000-000000000102' + `), + ).rejects.toMatchObject({ code: "23514" }) + await expect( + database.query(` + UPDATE "installation_memberships" + SET "status" = 'removed', "suspended_at" = NULL, "removed_at" = NULL + WHERE "id" = '00000000-0000-4000-8000-000000000102' + `), + ).rejects.toMatchObject({ code: "23514" }) + await expect( + database.query(` + INSERT INTO "membership_invitations" ( + "id", + "provider", + "issuer", + "subject", + "role", + "expires_at", + "consumed_at", + "revoked_at" + ) + VALUES ( + '00000000-0000-4000-8000-000000000106', + 'github', + 'https://github.com', + '42', + 'member', + CURRENT_TIMESTAMP + INTERVAL '1 day', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + `), + ).rejects.toMatchObject({ code: "23514" }) + }) +}) diff --git a/hub/tsconfig.json b/hub/tsconfig.json index 516108e7..fe11015c 100644 --- a/hub/tsconfig.json +++ b/hub/tsconfig.json @@ -8,6 +8,7 @@ "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", + "customConditions": ["development"], "noEmit": true, "noUncheckedIndexedAccess": true, "paths": { diff --git a/hub/tsconfig.server.json b/hub/tsconfig.server.json index ebd6babc..6c5d0765 100644 --- a/hub/tsconfig.server.json +++ b/hub/tsconfig.server.json @@ -12,5 +12,5 @@ "lib": ["ES2023"] }, "include": ["server/**/*.ts"], - "exclude": ["server/**/*.test.ts"] + "exclude": ["server/**/*.test.ts", "server/**/test/**"] }