diff --git a/docs/reference/specs/person-directory.md b/docs/reference/specs/person-directory.md index 7fe4160b6..b6fdd51fc 100644 --- a/docs/reference/specs/person-directory.md +++ b/docs/reference/specs/person-directory.md @@ -1,9 +1,9 @@ # Person directory -The directory is a foundation for [verified cross-surface identity](../../decisions/0079-a-person-outlives-the-surfaces-they-use.md), not an ingress cutover. It supplies a relationship, never grants or an authorization decision. Existing email linking, actors, ownership, run events and startup are unchanged. The disabled intent transaction below supplies persistence only; credential verification, live ceremonies and request attribution remain separate implementation stages. +The directory is a foundation for [verified cross-surface identity](../../decisions/0079-a-person-outlives-the-surfaces-they-use.md), not an ingress cutover. It supplies a relationship, never grants or an authorization decision. Existing email linking, actors, ownership, run events and startup are unchanged. The disabled intent transaction below supplies persistence only; the offline proof adapters below verify credentials without activating live ceremonies or request attribution. -- **Code**: `src/core/identity/contract.ts`, `src/core/identity/engine.ts`, `src/core/identity/memory.ts`, `src/core/identity/sqlite.ts`, `src/core/identity/linkContract.ts`, `src/core/identity/linkEngine.ts`. -- **Tests**: `src/core/identity/directory.test.ts`, `src/core/identity/link.test.ts`. +- **Code**: `src/core/identity/contract.ts`, `src/core/identity/engine.ts`, `src/core/identity/memory.ts`, `src/core/identity/sqlite.ts`, `src/core/identity/linkContract.ts`, `src/core/identity/linkEngine.ts`, `src/core/identity/humanProof.ts`, `src/core/identity/linkCeremony.ts`, `src/core/identity/testing/fakeHumanProofs.ts`, `src/channels/linkProofAccess.ts`, `src/channels/linkProofSlack.ts`, `src/channels/linkProofJwt.ts`. +- **Tests**: `src/core/identity/directory.test.ts`, `src/core/identity/link.test.ts`, `src/core/identity/linkCeremony.test.ts`, `src/channels/linkProofs.test.ts`. ## Behavior @@ -36,10 +36,10 @@ Each contract case runs against both implementations; SQLite cases use independe ## Disabled dual-identity intent transaction -This implements only the storage slice of [the proposed linking design](../../decisions/0081-account-linking-proves-both-identities-and-revocation-fences-their-use.md). The proposal remains proposed. No route, OAuth client, authority consumer, frontend or email bridge changes with this seam. +This implements only the storage slice of [the proposed linking design](../../decisions/0081-account-linking-proves-both-identities-and-revocation-fences-their-use.md). The proposal remains proposed. No route, production OAuth configuration, authority consumer, frontend or email bridge changes with this seam. -1. **Trusted metadata, not credential verification.** `link` is an internal command boundary on `PersonDirectory`. The future caller authenticates a human Access session, pins its issuer/audience, supplies SHA-256 browser/state/nonce digests of independent random values, chooses bounded intent/result deadlines, and validates Slack OIDC before staging proof. The store accepts closed metadata only, never a code, JWT, email or target person. It pins Access's exact tuple (null tenant), audience, browser digest and proof-policy version; Slack's issuer, workspace and client audience are pinned at initiation. No grant or ownership mutation exists here. -2. **Durable one-time state.** Server-minted intents transition `pending → exchanging → awaiting-consent → committed`, or terminal `failed`, `cancelled`, `expired`. Callback `claim` checks state digest and the authenticated owner and advances the revision once. Only `claimed` permits a future caller to exchange a code. A duplicate claim cannot exchange again. After an uncertain exchange, the future owner must send `interrupt`, never repeat code exchange; an interrupted operation cannot later stage proof. Restart alone does not reset a claim. All commands reauthenticate the exact initiating actor/audience/browser, reject expired Access proof, and compare expected intent/proof revisions. Authentication failures neither disclose nor consume an intent. Deadlines can only shorten to proof expiry. +1. **Trusted metadata, not credential verification.** `link` is an internal command boundary on `PersonDirectory`. The offline caller described below authenticates a human Access session, pins its issuer/audience, supplies SHA-256 browser/state/nonce digests of independent random values, chooses bounded intent/result deadlines, and validates Slack OIDC before staging proof. The store accepts closed metadata only, never a code, JWT, email or target person. It pins Access's exact tuple (null tenant), audience, browser digest and proof-policy version; Slack's issuer, workspace and client audience are pinned at initiation. No grant or ownership mutation exists here. +2. **Durable one-time state.** Server-minted intents transition `pending → exchanging → awaiting-consent → committed`, or terminal `failed`, `cancelled`, `expired`. Callback `claim` checks state digest and the authenticated owner and advances the revision once. An authenticated internal `inspect` returns detached active metadata for proof validation; it applies the same owner/deadline fence and confers no exchange permission. Only `claimed` permits the offline caller to exchange a code. A duplicate claim cannot exchange again. After an uncertain exchange, the future owner must send `interrupt`, never repeat code exchange; an interrupted operation cannot later stage proof. Restart alone does not reset a claim. All commands reauthenticate the exact initiating actor/audience/browser, reject expired Access proof, and compare expected intent/proof revisions. Authentication failures neither disclose nor consume an intent. Deadlines can only shorten to proof expiry. 3. **One commit, no compensation.** Explicit local consent, intent revision, deadline and both observed binding revisions are checked inside the same storage transaction that chooses/creates the person, writes missing bindings and binding receipts, consumes the intent, and appends a separate operation audit. Two unknown identities create one person; one known active identity selects its person; two active identities already on that person create only an already-linked operation outcome. Existing active bindings are not gratuitously refreshed. Different people, either tombstone, stale revisions or corrupt data refuse; no target adoption, movement, merge or recovery is possible. Terminal refusals retain an attributable audit but no person or binding mutation. Audit/storage failure rolls back the entire attempted transition, permitting a safe retry. 4. **Replay is a receipt, not authority.** Within the pinned result-read deadline, `read` or repeat `commit` returns the original committed receipt only to the original freshly authenticated actor/audience/browser. It does not re-resolve into another target, mutate revisions, issue another receipt or restore a later-revoked relationship. A repeat `commit` must carry the original consent-ready revision and explicit consent; a changed revision returns `stale`, and missing consent returns `consent_required`. Only `read` is revision-free. Replay checks the operation audit against an independent immutable receipt snapshot retained on the terminal intent; contradictory person, identity or other receipt facts return `conflict` without consulting current bindings. Pending/proof staging is discarded on terminal transition; the owner digest remains solely for bounded result authorization, not an access token. Result reads after the deadline fail closed. Deployment lifetime/rate limits, background retention cleanup and protocol/revocation fencing remain later ceremony gates, not claims of this storage slice. 5. **Matching stores.** SQLite runs the shared transition engine inside `transactionSync`, including reads, selection and operation audit writes. The in-memory implementation stages a snapshot and publishes it only on success. Both are synchronous across each transaction; only SQLite promises restart durability. No live caller is wired to the new command. @@ -62,3 +62,29 @@ This implements only the storage slice of [the proposed linking design](../../de | Independent SQLite processes contend on a held write lock and commit one complete pair | `[unit]` `src/core/identity/link.test.ts::durable link intent::serializes independent writers at the database authority` | | Revocation/refresh after proof staging cannot bind the other identity; concurrent retries produce one outcome | `[unit]` `src/core/identity/link.test.ts::atomic link contract::*::fences revocation and refresh after proof staging`, `src/core/identity/link.test.ts::atomic link contract::*::returns one receipt for concurrent retries of the same intent` | | Corrupt persisted state or contradictory operation outcomes cannot be replayed as successful links | `[unit]` `src/core/identity/link.test.ts::atomic link contract::*::refuses inconsistent persisted intent and audit states` | + +## Offline human proof and ceremony adapters + +The proof slice reuses the existing Access application-JWT verifier and supplies a separate confidential-client Slack OIDC adapter. It is internal, not registered at ingress, and does not change the legacy email bridge. The design record remains proposed. Deterministic fakes implement both proof seams; no production scopes, redirects, secrets, link/unlink UI, or person-derived authorization are enabled. + +- Access admits only the `access` strategy's signed human session, not a service token, view-as, email/header assertion, decoded claims, or synthetic token/none identity. Retained metadata is issuer, null tenant, exact subject, configured admitted audience, issuance, expiry and human kind; email is discarded. Strict linking validation is intentionally narrower than the unchanged dashboard gate. +- Slack pins discovery to query/code, RS256, issuer and endpoints, authenticates the client server-side, and uses the exact configured HTTPS redirect. State and nonce are independent unpredictable values, never substitutes for one another. A signed, allowlisted team plus agreeing human user/sub determines identity; a team hint, email or bot token cannot. Multi-audience tokens require matching authorized party. +- Initiation stores digests only and caps the deadline at ten minutes or earlier Access expiry. Callback durably claims state before exchange, reauthenticates the initiating Access subject/audience/browser, and stages only validated Slack metadata. Slack expiry can only shorten the deadline. Result reads end no later than ten minutes after the original intent deadline. Final consent revalidates Access and leaves atomic binding/revision/consent checks to the directory transaction. +- Repeated callbacks never exchange again. An uncertain exchange is interrupted, not retried; after process loss, an authenticated explicit recovery interrupt fences any old completion. A wrong browser/account/state does not consume another intent. No credential is logged, audited, cached, or emitted as a run event. Callback responses always use a clean same-origin redirect with no-store/no-referrer. Edge query-log redaction, secure cookie transport, CSRF/rate limits, and live deployment proof remain release gates, not capabilities of an unwired library. + +| Criterion | Evidence | +| --- | --- | +| Access projects verified human metadata only | `[unit]` `src/channels/linkProofs.test.ts::Access human proof::projects only signed human metadata from the application JWT` | +| Access issuer, audience, finite validity, service ambiguity and synthetic evidence fail closed | `[unit]` `src/channels/linkProofs.test.ts::Access human proof::rejects issuer audience time and human service ambiguity`, `src/channels/linkProofs.test.ts::Access human proof::rejects unsigned email header synthetic and view-as evidence` | +| Trusted Access signing-key rotation preserves identity; unsafe keys do not verify | `[unit]` `src/channels/linkProofs.test.ts::Access human proof::accepts trusted key rotation without changing identity and rejects unsafe keys` | +| Slack pins query discovery and confidential-client exchange, exact redirect and validated identity metadata | `[unit]` `src/channels/linkProofs.test.ts::Slack OIDC human proof::pins query discovery and exchanges once as a confidential client with the exact redirect` | +| Slack rejects signature, key, issuer, audience, time, nonce, workspace, user-subject and bot/email substitutes | `[unit]` `src/channels/linkProofs.test.ts::Slack OIDC human proof::rejects Slack signature key issuer audience time nonce team and user mismatches` | +| Multi-audience authorized party and trusted rotated keys validate; protocol or policy drift never exchanges a code | `[unit]` `src/channels/linkProofs.test.ts::Slack OIDC human proof::accepts authorized multi-audience and rotated trusted keys`, `src/channels/linkProofs.test.ts::Slack OIDC human proof::fails closed on discovery redirect or workspace-policy drift without exchanging a code` | +| Provider errors and response credentials never escape as proof | `[unit]` `src/channels/linkProofs.test.ts::Slack OIDC human proof::suppresses provider errors and drops all response credentials` | +| Both directory implementations receive metadata only and require explicit local consent before atomic binding; replay reads the same receipt | `[unit]` `src/core/identity/linkCeremony.test.ts::offline link ceremony::*::hands only validated metadata to the atomic consent transaction` | +| Replay and concurrent callbacks exchange at most once; wrong state, browser and Access account neither exchange nor commit | `[unit]` `src/core/identity/linkCeremony.test.ts::offline link ceremony::*::rejects replay concurrent callbacks wrong state wrong browser and account switch` | +| Ten-minute intent maximum, earlier Access expiry, Slack shortening and bounded result reads cannot be extended by refresh | `[unit]` `src/core/identity/linkCeremony.test.ts::offline link ceremony::*::bounds Access Slack intent and result lifetimes without refresh extension` | +| Callback failure and durable claim recovery fail closed without code replay or any binding | `[unit]` `src/core/identity/linkCeremony.test.ts::offline link ceremony::*::fails callback crashes and recovers a durable claim without exchanging again` | +| Callback method/redirect/duplicate parameters are refused; all responses strip secrets and use no-store/no-referrer, durable rows contain no credentials or raw state/nonce/browser secret | `[unit]` `src/core/identity/linkCeremony.test.ts::offline link ceremony::*::rejects callback transport drift and keeps secrets out of persistence and responses` | +| A warmed Access key cache accepts a genuinely new trusted RSA key without changing identity, and refuses a foreign signer | `[unit]` `src/channels/linkProofs.test.ts::Access human proof::refreshes a warm key cache for real rotation and rejects a foreign signing key` | +| Real signed Access and Slack proofs reach the transaction through the ceremony, with credentials excluded from every directory command | `[unit]` `src/channels/linkProofs.test.ts::verified proof handoff::carries real signed Access and Slack proofs through consent without persisting credentials` | diff --git a/src/channels/linkProofAccess.ts b/src/channels/linkProofAccess.ts new file mode 100644 index 000000000..42f5832a9 --- /dev/null +++ b/src/channels/linkProofAccess.ts @@ -0,0 +1,51 @@ +import { identitySchema } from "../core/identity/contract.js"; +import type { AccessEvidence, AccessHumanProof, AccessProofProvider } from "../core/identity/humanProof.js"; +import { JwksCache, verifyAccessJwt, type AccessConfig, type VerifyDeps } from "./accessAuth.js"; +import { audienceValid, jwtParts, signingKeys, tokenTimes } from "./linkProofJwt.js"; + +/** Offline linking projection, deliberately not part of dashboard identity resolution. + * Its private key-only cache cannot inherit keys from the less restrictive gate. */ +export class AccessHumanProofAdapter implements AccessProofProvider { + private readonly config: AccessConfig; + private readonly deps: VerifyDeps; + constructor(config: AccessConfig, deps: Pick) { + this.config = { ...config }; + this.deps = { + now: deps.now, + fetchJwks: async (url) => signingKeys(await deps.fetchJwks(url)), + cache: new JwksCache(), + }; + } + async verify(evidence: AccessEvidence): Promise { + try { + if ( + evidence.strategy !== "access" || + evidence.viewAs !== undefined || + typeof evidence.token !== "string" || + !/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(this.config.teamDomain) || + !this.config.aud + ) + return null; + const parts = jwtParts(evidence.token); + if (!parts) return null; + // Reuse the application verifier on this exact immutable JWT. Only AFTER + // it succeeds do decoded claims become the retained proof metadata. + const verified = await verifyAccessJwt(evidence.token, this.config, this.deps); + if (!verified || !verified.sub || verified.commonName !== undefined) return null; + const c = parts.claims; + if ( + c.common_name !== undefined || + (c.type !== undefined && c.type !== "app") || + !audienceValid(c.aud, this.config.aud) || + verified.sub.trim() !== verified.sub + ) + return null; + const times = tokenTimes(c, this.deps.now()); + const identity = identitySchema.safeParse({ issuer: c.iss, tenant: null, subject: verified.sub }); + if (!times || !identity.success) return null; + return { kind: "human", identity: { ...identity.data, tenant: null }, audience: this.config.aud, ...times }; + } catch { + return null; + } + } +} diff --git a/src/channels/linkProofJwt.ts b/src/channels/linkProofJwt.ts new file mode 100644 index 000000000..ca5da46a2 --- /dev/null +++ b/src/channels/linkProofJwt.ts @@ -0,0 +1,71 @@ +import { createPublicKey } from "node:crypto"; +import { secondsToMs } from "../core/budgets.js"; +import type { AccessJwk } from "./accessAuth.js"; + +/** These helpers never confer trust: only a successful signature check does. */ +export function jwtParts( + token: unknown, +): { header: Record; claims: Record; input: string; signature: Buffer } | null { + if (typeof token !== "string" || token.length > 16384) return null; + const parts = token.split("."); + if (parts.length !== 3 || parts.some((p) => !/^[A-Za-z0-9_-]+$/.test(p))) return null; + try { + const [header, claims] = parts.slice(0, 2).map((p) => JSON.parse(Buffer.from(p, "base64url").toString("utf8"))); + if (![header, claims].every((v) => v && typeof v === "object" && !Array.isArray(v))) return null; + if ( + header.alg !== "RS256" || + typeof header.kid !== "string" || + !header.kid || + header.crit !== undefined || + header.b64 !== undefined + ) + return null; + return { header, claims, input: `${parts[0]}.${parts[1]}`, signature: Buffer.from(parts[2], "base64url") }; + } catch { + return null; + } +} +export function signingKeys(keys: AccessJwk[]): AccessJwk[] { + return keys.filter((k) => { + try { + return ( + typeof k.kid === "string" && + keys.filter((v) => v.kid === k.kid).length === 1 && + k.kty === "RSA" && + (k.alg === undefined || k.alg === "RS256") && + (k.use === undefined || k.use === "sig") && + (k.key_ops === undefined || (Array.isArray(k.key_ops) && k.key_ops.includes("verify"))) && + (createPublicKey({ key: k, format: "jwk" }).asymmetricKeyDetails?.modulusLength ?? 0) >= 2048 + ); + } catch { + return false; + } + }); +} +export function audienceValid(value: unknown, audience: string): boolean { + return ( + value === audience || + (Array.isArray(value) && + value.length > 0 && + value.every((v) => typeof v === "string" && v.length > 0) && + new Set(value).size === value.length && + value.includes(audience)) + ); +} +export function tokenTimes( + claims: Record, + now: number, +): { issuedAt: number; expiresAt: number } | null { + const instant = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(secondsToMs(v)) && v >= 0; + if (!instant(claims.exp) || !instant(claims.iat) || (claims.nbf !== undefined && !instant(claims.nbf))) return null; + const issuedAt = secondsToMs(claims.iat), + expiresAt = secondsToMs(claims.exp); + if ( + expiresAt <= now || + issuedAt >= expiresAt || + issuedAt > now + secondsToMs(60) || + (typeof claims.nbf === "number" && (secondsToMs(claims.nbf) > now + secondsToMs(60) || claims.nbf >= claims.exp)) + ) + return null; + return { issuedAt, expiresAt }; +} diff --git a/src/channels/linkProofSlack.ts b/src/channels/linkProofSlack.ts new file mode 100644 index 000000000..ad53c0dd0 --- /dev/null +++ b/src/channels/linkProofSlack.ts @@ -0,0 +1,176 @@ +import { createPublicKey, verify } from "node:crypto"; +import { secondsToMs } from "../core/budgets.js"; +import { + proofDigest, + type SlackHumanProof, + type SlackPolicy, + type SlackProofProvider, + type SlackProofRequest, +} from "../core/identity/humanProof.js"; +import type { AccessJwk } from "./accessAuth.js"; +import { audienceValid, jwtParts, signingKeys, tokenTimes } from "./linkProofJwt.js"; + +export const SLACK_OIDC = Object.freeze({ + issuer: "https://slack.com", + discovery: "https://slack.com/.well-known/openid-configuration", + authorize: "https://slack.com/openid/connect/authorize", + token: "https://slack.com/api/openid.connect.token", + keys: "https://slack.com/openid/connect/keys", +}); +interface SlackClient { + audience: string; + callbackUri: string; + teams: readonly string[]; + clientSecret: () => string; +} +interface SlackDeps { + now: () => number; + fetch: (url: string, init: RequestInit) => Promise; +} +const transport: RequestInit = { + cache: "no-store", + redirect: "error", + referrerPolicy: "no-referrer", + credentials: "omit", +}; +function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid provider response"); + return value as Record; +} + +/** Not installed at startup. No bot token, user-info/email fallback, logger, + * credential cache or token-bearing error escapes this confidential-client seam. */ +export class SlackOidcProofAdapter implements SlackProofProvider { + private readonly client: SlackClient; + constructor( + client: SlackClient, + private readonly deps: SlackDeps, + ) { + this.client = { ...client, teams: [...client.teams] }; + } + private policyMatches(policy: SlackPolicy): boolean { + const url = new URL(this.client.callbackUri); + return ( + url.protocol === "https:" && + !url.username && + !url.password && + !url.search && + !url.hash && + !!this.client.audience && + policy.audience === this.client.audience && + policy.callbackUri === this.client.callbackUri && + /^T[A-Z0-9]+$/.test(policy.tenant) && + this.client.teams.includes(policy.tenant) + ); + } + private async json(url: string, init: RequestInit = {}): Promise> { + const response = await this.deps.fetch(url, { ...transport, ...init }); + if (!response.ok) throw new Error("provider unavailable"); + return object(await response.json()); + } + private async protocol(): Promise { + const d = await this.json(SLACK_OIDC.discovery); + const supports = (field: string, value: string) => Array.isArray(d[field]) && d[field].includes(value); + return ( + d.issuer === SLACK_OIDC.issuer && + d.authorization_endpoint === SLACK_OIDC.authorize && + d.token_endpoint === SLACK_OIDC.token && + d.jwks_uri === SLACK_OIDC.keys && + JSON.stringify(d.response_modes_supported) === JSON.stringify(["query"]) && + JSON.stringify(d.id_token_signing_alg_values_supported) === JSON.stringify(["RS256"]) && + supports("response_types_supported", "code") && + supports("grant_types_supported", "authorization_code") && + supports("scopes_supported", "openid") && + supports("scopes_supported", "profile") && + supports("token_endpoint_auth_methods_supported", "client_secret_basic") + ); + } + async authorization(input: { policy: SlackPolicy; state: string; nonce: string }): Promise { + try { + if ( + !this.policyMatches(input.policy) || + !/^[A-Za-z0-9_-]{43}$/.test(input.state) || + !/^[A-Za-z0-9_-]{43}$/.test(input.nonce) || + input.state === input.nonce || + !(await this.protocol()) + ) + return null; + const params = new URLSearchParams({ + client_id: this.client.audience, + redirect_uri: this.client.callbackUri, + response_type: "code", + response_mode: "query", + scope: "openid profile", + state: input.state, + nonce: input.nonce, + team: input.policy.tenant, + }); + return `${SLACK_OIDC.authorize}?${params}`; + } catch { + return null; + } + } + async exchange(input: SlackProofRequest): Promise { + try { + if (!this.policyMatches(input.policy) || !input.code || input.code.length > 4096 || !(await this.protocol())) + return null; + const secret = this.client.clientSecret(); + if (!secret) return null; + // OAuth Basic encodes each component before joining; credentials never ride + // the URL, follow a redirect, or enter a thrown provider error. + const encode = (s: string) => new URLSearchParams({ v: s }).toString().slice(2); + const response = await this.json(SLACK_OIDC.token, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Basic ${Buffer.from(`${encode(this.client.audience)}:${encode(secret)}`).toString("base64")}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: input.code, + redirect_uri: this.client.callbackUri, + }).toString(), + }); + if (response.ok !== true) return null; + const token = jwtParts(response.id_token); + if (!token) return null; + const jwks = await this.json(SLACK_OIDC.keys); + if (!Array.isArray(jwks.keys)) return null; + const jwk = signingKeys(jwks.keys as AccessJwk[]).find((k) => k.kid === token.header.kid); + if ( + !jwk || + !verify("RSA-SHA256", Buffer.from(token.input), createPublicKey({ key: jwk, format: "jwk" }), token.signature) + ) + return null; + const c = token.claims; + const times = tokenTimes(c, this.deps.now()); + if ( + !times || + times.issuedAt < input.createdAt - secondsToMs(60) || + c.iss !== SLACK_OIDC.issuer || + !audienceValid(c.aud, this.client.audience) || + (c.azp !== undefined && c.azp !== this.client.audience) || + (Array.isArray(c.aud) && c.aud.length > 1 && c.azp !== this.client.audience) || + typeof c.nonce !== "string" || + proofDigest(c.nonce) !== input.nonceHash || + c["https://slack.com/team_id"] !== input.policy.tenant || + typeof c.sub !== "string" || + !/^[UW][A-Z0-9]+$/.test(c.sub) || + c["https://slack.com/user_id"] !== c.sub || + (c.is_bot !== undefined && c.is_bot !== false) || + (c.is_app_user !== undefined && c.is_app_user !== false) || + c.bot_id !== undefined + ) + return null; + return { + identity: { issuer: SLACK_OIDC.issuer, tenant: input.policy.tenant, subject: c.sub }, + audience: this.client.audience, + callbackUri: this.client.callbackUri, + nonceHash: input.nonceHash, + expiresAt: times.expiresAt, + }; + } catch { + return null; + } + } +} diff --git a/src/channels/linkProofs.test.ts b/src/channels/linkProofs.test.ts new file mode 100644 index 000000000..5e179a27c --- /dev/null +++ b/src/channels/linkProofs.test.ts @@ -0,0 +1,318 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { assert, describe, expect, it, vi } from "vitest"; +import { LinkCeremony } from "../core/identity/linkCeremony.js"; +import { InMemoryPersonDirectory } from "../core/identity/memory.js"; +import { AccessHumanProofAdapter } from "./linkProofAccess.js"; +import type { AccessJwk } from "./accessAuth.js"; + +const now = 1_800_000_000_000; +const config = { teamDomain: "proof.cloudflareaccess.com", aud: "application" }; +const pair = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const key: AccessJwk = { ...pair.publicKey.export({ format: "jwk" }), kid: "key-a", alg: "RS256", use: "sig" }; +function jwt(claims: Record, header: Record = {}, signer = pair.privateKey) { + const input = [JSON.stringify({ alg: "RS256", kid: "key-a", ...header }), JSON.stringify(claims)] + .map((v) => Buffer.from(v).toString("base64url")) + .join("."); + return `${input}.${sign("RSA-SHA256", Buffer.from(input), signer).toString("base64url")}`; +} +const accessClaims = (over: Record = {}) => ({ + iss: `https://${config.teamDomain}`, + aud: config.aud, + sub: "human-a", + email: "display@example.test", + iat: now / 1000, + nbf: now / 1000, + exp: now / 1000 + 3600, + ...over, +}); +const evidence = (token: unknown) => ({ strategy: "access", token }); +function access(keys = [key]) { + return new AccessHumanProofAdapter(config, { now: () => now, fetchJwks: async () => keys }); +} + +describe("Access human proof", () => { + it("projects only signed human metadata from the application JWT", async () => { + expect(await access().verify(evidence(jwt(accessClaims())))).toEqual({ + kind: "human", + identity: { issuer: `https://${config.teamDomain}`, tenant: null, subject: "human-a" }, + audience: config.aud, + issuedAt: now, + expiresAt: now + 3600_000, + }); + }); + it("rejects issuer audience time and human service ambiguity", async () => { + for (const over of [ + { iss: "https://foreign.test" }, + { aud: "other" }, + { aud: [config.aud, 3] }, + { exp: now / 1000 }, + { exp: 1e300 }, + { exp: "later" }, + { iat: undefined }, + { iat: now / 1000 + 61 }, + { iat: "now" }, + { nbf: "now" }, + { nbf: now / 1000 + 61 }, + { sub: "" }, + { sub: " " }, + { sub: undefined }, + { common_name: "service" }, + { sub: "", common_name: "service" }, + { type: "service" }, + ]) + expect(await access().verify(evidence(jwt(accessClaims(over))))).toBeNull(); + }); + it("rejects unsigned email header synthetic and view-as evidence", async () => { + for (const input of [ + evidence(accessClaims()), + evidence("human@example.test"), + evidence(undefined), + { strategy: "token", token: jwt(accessClaims()) }, + { strategy: "none", token: jwt(accessClaims()) }, + { ...evidence(jwt(accessClaims())), viewAs: "slack:someone" }, + evidence(jwt(accessClaims(), { alg: "none" })), + evidence(jwt(accessClaims(), { kid: "unknown" })), + evidence(jwt(accessClaims(), { crit: ["unrecognized"] })), + evidence(jwt(accessClaims()).slice(0, -20)), + ]) + expect(await access().verify(input)).toBeNull(); + }); + it("refreshes a warm key cache for real rotation and rejects a foreign signing key", async () => { + const rotated = generateKeyPairSync("rsa", { modulusLength: 2048 }); + let keys = [key], + clock = now; + const adapter = new AccessHumanProofAdapter(config, { now: () => clock, fetchJwks: async () => keys }); + const first = await adapter.verify(evidence(jwt(accessClaims()))); + expect(first).not.toBeNull(); + expect(await adapter.verify(evidence(jwt(accessClaims(), {}, rotated.privateKey)))).toBeNull(); + keys = [{ ...rotated.publicKey.export({ format: "jwk" }), kid: "key-b", alg: "RS256", use: "sig" }]; + clock += 61_000; + expect(await adapter.verify(evidence(jwt(accessClaims(), { kid: "key-b" }, rotated.privateKey)))).toEqual(first); + }); + it("accepts trusted key rotation without changing identity and rejects unsafe keys", async () => { + const adapter = access([{ ...key, kid: "key-b" }]); + expect(await adapter.verify(evidence(jwt(accessClaims(), { kid: "key-b" })))).toMatchObject({ + identity: { subject: "human-a" }, + }); + for (const bad of [ + { ...key, use: "enc" }, + { ...key, alg: "HS256" }, + { ...key, key_ops: ["encrypt"] }, + ]) + expect(await access([bad]).verify(evidence(jwt(accessClaims())))).toBeNull(); + }); +}); + +import { SlackOidcProofAdapter, SLACK_OIDC } from "./linkProofSlack.js"; +import { proofDigest } from "../core/identity/humanProof.js"; +const policy = { tenant: "TDEMO", audience: "client-a", callbackUri: "https://app.test/link/callback" }; +const nonce = "n".repeat(43); +const state = "s".repeat(43); +const slackClaims = (over: Record = {}) => ({ + iss: "https://slack.com", + aud: policy.audience, + sub: "UDEMO", + nonce, + "https://slack.com/team_id": policy.tenant, + "https://slack.com/user_id": "UDEMO", + iat: now / 1000, + exp: now / 1000 + 600, + ...over, +}); +const discovery = { + issuer: "https://slack.com", + authorization_endpoint: SLACK_OIDC.authorize, + token_endpoint: SLACK_OIDC.token, + jwks_uri: SLACK_OIDC.keys, + response_modes_supported: ["query"], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], + id_token_signing_alg_values_supported: ["RS256"], + scopes_supported: ["openid", "profile", "email"], +}; +function slack( + options: { + claims?: Record; + header?: Record; + token?: string; + discovery?: object; + keys?: AccessJwk[]; + fail?: boolean; + } = {}, +) { + const requests: { url: string; init: RequestInit }[] = []; + const adapter = new SlackOidcProofAdapter( + { ...policy, teams: [policy.tenant], clientSecret: () => "client-secret" }, + { + now: () => now, + fetch: async (url, init) => { + requests.push({ url, init }); + if (options.fail) throw new Error("code-secret refresh-secret jwt-secret"); + const body = + url === SLACK_OIDC.discovery + ? (options.discovery ?? discovery) + : url === SLACK_OIDC.keys + ? { keys: options.keys ?? [key] } + : { + ok: true, + id_token: options.token ?? jwt(slackClaims(options.claims), options.header), + access_token: "access-secret", + refresh_token: "refresh-secret", + }; + return new Response(JSON.stringify(body), { status: 200 }); + }, + }, + ); + return { adapter, requests }; +} +const exchange = { code: "code-secret", policy, nonceHash: proofDigest(nonce), createdAt: now }; + +describe("Slack OIDC human proof", () => { + it("pins query discovery and exchanges once as a confidential client with the exact redirect", async () => { + const h = slack(); + const url = new URL((await h.adapter.authorization({ policy, state, nonce }))!); + expect(url.origin + url.pathname).toBe(SLACK_OIDC.authorize); + expect(Object.fromEntries(url.searchParams)).toEqual({ + client_id: policy.audience, + redirect_uri: policy.callbackUri, + response_type: "code", + response_mode: "query", + scope: "openid profile", + state, + nonce, + team: policy.tenant, + }); + expect(await h.adapter.exchange(exchange)).toEqual({ + identity: { issuer: "https://slack.com", tenant: policy.tenant, subject: "UDEMO" }, + audience: policy.audience, + callbackUri: policy.callbackUri, + nonceHash: proofDigest(nonce), + expiresAt: now + 600_000, + }); + const req = h.requests.find((r) => r.url === SLACK_OIDC.token)!; + expect(req.init).toMatchObject({ + method: "POST", + cache: "no-store", + redirect: "error", + referrerPolicy: "no-referrer", + }); + expect(new Headers(req.init.headers).get("authorization")).toBe( + `Basic ${Buffer.from("client-a:client-secret").toString("base64")}`, + ); + expect(Object.fromEntries(new URLSearchParams(String(req.init.body)))).toEqual({ + grant_type: "authorization_code", + code: "code-secret", + redirect_uri: policy.callbackUri, + }); + }); + it("rejects Slack signature key issuer audience time nonce team and user mismatches", async () => { + for (const claims of [ + { iss: "https://evil.test" }, + { aud: "other" }, + { aud: [policy.audience, 7] }, + { aud: [policy.audience, "other"] }, + { azp: "other" }, + { nonce: "other" }, + { nonce: undefined }, + { exp: now / 1000 }, + { exp: "never" }, + { exp: 1e300 }, + { iat: undefined }, + { iat: now / 1000 + 61 }, + { iat: now / 1000 - 61 }, + { nbf: now / 1000 + 61 }, + { "https://slack.com/team_id": "TOTHER" }, + { "https://slack.com/team_id": undefined }, + { "https://slack.com/user_id": "UOTHER" }, + { "https://slack.com/user_id": undefined }, + { sub: "" }, + { sub: "BDEMO", "https://slack.com/user_id": "BDEMO" }, + { is_bot: true }, + { is_app_user: true }, + { bot_id: "BDEMO" }, + { sub: undefined, email: "display@example.test", email_verified: true }, + ]) + expect(await slack({ claims }).adapter.exchange(exchange), JSON.stringify(claims)).toBeNull(); + for (const header of [{ alg: "HS256" }, { alg: "none" }, { kid: "unknown" }, { crit: ["unknown"] }]) + expect(await slack({ header }).adapter.exchange(exchange)).toBeNull(); + for (const keys of [[{ ...key, use: "enc" }], [key, key], []]) + expect(await slack({ keys }).adapter.exchange(exchange)).toBeNull(); + for (const token of ["xoxb-bot-token", jwt(slackClaims()).slice(0, -20), JSON.stringify(slackClaims())]) + expect(await slack({ token }).adapter.exchange(exchange)).toBeNull(); + }); + it("accepts authorized multi-audience and rotated trusted keys", async () => { + expect( + await slack({ claims: { aud: [policy.audience, "other"], azp: policy.audience } }).adapter.exchange(exchange), + ).not.toBeNull(); + expect( + await slack({ keys: [{ ...key, kid: "rotated" }], header: { kid: "rotated" } }).adapter.exchange(exchange), + ).not.toBeNull(); + }); + it("fails closed on discovery redirect or workspace-policy drift without exchanging a code", async () => { + for (const change of [ + { response_modes_supported: ["form_post"] }, + { response_modes_supported: ["query", "form_post"] }, + { issuer: "https://evil.test" }, + { token_endpoint: "https://evil.test/token" }, + { authorization_endpoint: "https://evil.test/auth" }, + { jwks_uri: "https://evil.test/keys" }, + { token_endpoint_auth_methods_supported: ["none"] }, + { id_token_signing_alg_values_supported: ["HS256"] }, + ]) { + const h = slack({ discovery: { ...discovery, ...change } }); + expect(await h.adapter.authorization({ policy, state, nonce })).toBeNull(); + expect(await h.adapter.exchange(exchange)).toBeNull(); + expect(h.requests.every((r) => r.url === SLACK_OIDC.discovery)).toBe(true); + } + for (const change of [{ callbackUri: policy.callbackUri + "/" }, { tenant: "TOTHER" }, { audience: "other" }]) { + const h = slack(); + expect(await h.adapter.exchange({ ...exchange, policy: { ...policy, ...change } })).toBeNull(); + expect(h.requests).toHaveLength(0); + } + }); + it("suppresses provider errors and drops all response credentials", async () => { + expect(await slack({ fail: true }).adapter.exchange(exchange)).toBeNull(); + const result = await slack().adapter.exchange(exchange); + expect(JSON.stringify(result)).not.toMatch(/code-secret|access-secret|refresh-secret|id_token|email/); + }); +}); + +describe("verified proof handoff", () => { + it("carries real signed Access and Slack proofs through consent without persisting credentials", async () => { + const options: { claims?: Record } = {}; + const provider = slack(options); + const directory = new InMemoryPersonDirectory(() => now); + const commands = vi.spyOn(directory, "link"); + const ceremony = new LinkCeremony({ directory, access: access(), slack: provider.adapter, now: () => now }, policy); + const token = jwt(accessClaims()); + const begun = await ceremony.begin(evidence(token)); + assert(begun.status === "started"); + const query = new URL(begun.authorizationUrl).searchParams; + options.claims = { nonce: query.get("nonce") }; + const callback = await ceremony.callback({ + session: begun.session, + evidence: evidence(token), + method: "GET", + callbackUri: policy.callbackUri, + query: new URLSearchParams({ state: query.get("state")!, code: "code-secret" }), + }); + expect(callback.result).toMatchObject({ status: "ok", intent: { state: "awaiting-consent" } }); + expect(await ceremony.commit(begun.session, evidence(token), 3, true)).toMatchObject({ + status: "committed", + receipt: { actor: { subject: "human-a" }, slack: { identity: { tenant: "TDEMO", subject: "UDEMO" } } }, + }); + const serialized = JSON.stringify(commands.mock.calls); + for (const secret of [ + token, + "code-secret", + "access-secret", + "refresh-secret", + begun.session.browser, + query.get("nonce")!, + query.get("state")!, + ]) + expect(serialized).not.toContain(secret); + commands.mockRestore(); + }); +}); diff --git a/src/core/identity/humanProof.ts b/src/core/identity/humanProof.ts new file mode 100644 index 000000000..ccbb1fc2e --- /dev/null +++ b/src/core/identity/humanProof.ts @@ -0,0 +1,34 @@ +import { createHash } from "node:crypto"; +import type { LinkCommand } from "./linkContract.js"; + +/** Internal credentials go only to the selected verifier, never the directory. */ +export interface AccessEvidence { + strategy: string; + token: unknown; + viewAs?: unknown; +} +export interface AccessHumanProof { + kind: "human"; + identity: { issuer: string; tenant: null; subject: string }; + audience: string; + issuedAt: number; + expiresAt: number; +} +export interface AccessProofProvider { + verify(evidence: AccessEvidence): Promise; +} +export type SlackPolicy = Extract["slackPolicy"]; +export type SlackHumanProof = Omit["proof"], "expectedRevision">; +export interface SlackProofRequest { + code: string; + policy: SlackPolicy; + nonceHash: string; + createdAt: number; +} +export interface SlackProofProvider { + authorization(input: { policy: SlackPolicy; state: string; nonce: string }): Promise; + exchange(input: SlackProofRequest): Promise; +} + +/** Only digests cross the durable state/nonce/browser boundary. */ +export const proofDigest = (value: string): string => createHash("sha256").update(value).digest("hex"); diff --git a/src/core/identity/linkCeremony.test.ts b/src/core/identity/linkCeremony.test.ts new file mode 100644 index 000000000..ab87a8f6a --- /dev/null +++ b/src/core/identity/linkCeremony.test.ts @@ -0,0 +1,261 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, assert, describe, expect, it, vi } from "vitest"; +import type { PersonDirectory } from "./contract.js"; +import { InMemoryPersonDirectory } from "./memory.js"; +import { SqlitePersonDirectory, type DirectorySqlStorage } from "./sqlite.js"; +import { LinkCeremony } from "./linkCeremony.js"; +import { FakeAccessProofProvider, FakeSlackProofProvider } from "./testing/fakeHumanProofs.js"; +import { proofDigest } from "./humanProof.js"; + +const start = 1_800_000_000_000; +const accessIdentity = { issuer: "https://proof.cloudflareaccess.com", tenant: null, subject: "human-a" }; +const slackIdentity = { issuer: "https://slack.com" as const, tenant: "TDEMO", subject: "UDEMO" }; +const policy = { tenant: slackIdentity.tenant, audience: "client-a", callbackUri: "https://app.test/link/callback" }; +const evidence = { strategy: "access", token: "fixture-access" }; +const cleanups: (() => void)[] = []; +afterEach(() => { + vi.restoreAllMocks(); + cleanups.splice(0).forEach((close) => close()); +}); +function harness(kind: string, accessExpiry = start + 3600_000, slackExpiry = start + 300_000) { + let time = start; + const now = () => time; + let db: DatabaseSync | undefined; + let directory: PersonDirectory; + const path = join(mkdtempSync(join(tmpdir(), "proof-")), "proof.sqlite"); + const open = () => { + db = new DatabaseSync(path); + const storage: DirectorySqlStorage = { + sql: { exec: (sql, ...params) => db!.prepare(sql).all(...params) as any }, + transactionSync: (body) => { + db!.exec("BEGIN IMMEDIATE"); + try { + const result = body(); + db!.exec("COMMIT"); + return result; + } catch (e) { + db!.exec("ROLLBACK"); + throw e; + } + }, + }; + directory = new SqlitePersonDirectory(storage, now); + }; + if (kind === "sqlite") open(); + else directory = new InMemoryPersonDirectory(now); + cleanups.push(() => db?.close()); + const access = new FakeAccessProofProvider([ + { + credential: "fixture-access", + proof: { + kind: "human", + identity: accessIdentity, + audience: "application", + issuedAt: start, + expiresAt: accessExpiry, + }, + }, + { + credential: "fixture-switched", + proof: { + kind: "human", + identity: { ...accessIdentity, subject: "human-other" }, + audience: "application", + issuedAt: start, + expiresAt: accessExpiry, + }, + }, + { + credential: "fixture-refreshed", + proof: { + kind: "human", + identity: accessIdentity, + audience: "application", + issuedAt: start, + expiresAt: start + 7200_000, + }, + }, + ]); + const slack = new FakeSlackProofProvider({ + identity: slackIdentity, + audience: policy.audience, + callbackUri: policy.callbackUri, + expiresAt: slackExpiry, + }); + const ceremony = () => new LinkCeremony({ directory, access, slack, now }, policy); + return { + access, + slack, + ceremony, + get directory() { + return directory; + }, + at(v: number) { + time = v; + }, + reopen() { + if (db) { + db.close(); + open(); + } + }, + dump() { + return db + ? JSON.stringify( + ["person_link_intents", "person_link_outcomes", "person_bindings", "person_binding_receipts"].map((t) => + db!.prepare(`SELECT * FROM ${t}`).all(), + ), + ) + : JSON.stringify((directory as any).state, (_k, v) => (v instanceof Map ? [...v] : v)); + }, + async begin() { + const result = await ceremony().begin(evidence); + assert(result.status === "started"); + const url = new URL(result.authorizationUrl); + return { + ...result, + request: { + session: result.session, + evidence, + method: "GET", + callbackUri: policy.callbackUri, + query: new URLSearchParams({ state: url.searchParams.get("state")!, code: "fixture-code" }), + }, + }; + }, + }; +} + +describe("offline link ceremony", () => { + for (const kind of ["memory", "sqlite"]) + describe(kind, () => { + it("hands only validated metadata to the atomic consent transaction", async () => { + const h = harness(kind); + const b = await h.begin(); + expect(b.expiresAt).toBe(start + 600_000); + const response = await h.ceremony().callback(b.request); + expect(response.result).toMatchObject({ + status: "ok", + intent: { state: "awaiting-consent", expiresAt: start + 300_000 }, + }); + expect(await h.directory.resolve(accessIdentity)).toEqual({ status: "unknown" }); + expect(await h.ceremony().commit(b.session, evidence, 3, false)).toEqual({ status: "consent_required" }); + const result = await h.ceremony().commit(b.session, evidence, 3, true); + expect(result).toMatchObject({ + status: "committed", + receipt: { consent: true, actor: accessIdentity, slack: { identity: slackIdentity } }, + }); + expect(await h.directory.resolve(accessIdentity)).toMatchObject({ status: "bound", binding: { revision: 1 } }); + expect(await h.directory.resolve(slackIdentity)).toMatchObject({ status: "bound", binding: { revision: 1 } }); + h.reopen(); + expect(await h.ceremony().read(b.session, evidence)).toEqual(result); + expect(h.slack.exchanges).toBe(1); + }); + it("rejects replay concurrent callbacks wrong state wrong browser and account switch", async () => { + const h = harness(kind); + const b = await h.begin(); + for (const request of [ + { ...b.request, query: new URLSearchParams({ state: "wrong", code: "fixture-code" }) }, + { ...b.request, session: { ...b.session, browser: "b".repeat(43) } }, + { ...b.request, evidence: { ...evidence, token: "fixture-switched" } }, + { ...b.request, evidence: { strategy: "none", token: "fixture-access" } }, + ]) + expect((await h.ceremony().callback(request)).result.status).not.toBe("ok"); + expect(h.slack.exchanges).toBe(0); + const results = await Promise.all([h.ceremony().callback(b.request), h.ceremony().callback(b.request)]); + expect(results.filter((r) => r.result.status === "ok")).toHaveLength(1); + expect(h.slack.exchanges).toBe(1); + expect(await h.ceremony().commit(b.session, { ...evidence, token: "fixture-switched" }, 3, true)).toEqual({ + status: "not_found", + }); + await h.ceremony().commit(b.session, evidence, 3, true); + expect((await h.ceremony().callback(b.request)).result.status).toBe("already_claimed"); + expect(h.slack.exchanges).toBe(1); + }); + it("bounds Access Slack intent and result lifetimes without refresh extension", async () => { + const h = harness(kind, start + 120_000, start + 300_000); + const b = await h.begin(); + expect(b.expiresAt).toBe(start + 120_000); + h.at(start + 120_000); + expect( + (await h.ceremony().callback({ ...b.request, evidence: { ...evidence, token: "fixture-refreshed" } })).result + .status, + ).toBe("expired"); + expect(h.slack.exchanges).toBe(0); + const h2 = harness(kind); + const b2 = await h2.begin(); + await h2.ceremony().callback(b2.request); + h2.at(start + 300_000); + expect(await h2.ceremony().commit(b2.session, evidence, 3, true)).toEqual({ status: "expired" }); + const h3 = harness(kind); + const b3 = await h3.begin(); + await h3.ceremony().callback(b3.request); + await h3.ceremony().commit(b3.session, evidence, 3, true); + h3.at(start + 1200_000); + expect(await h3.ceremony().read(b3.session, evidence)).toEqual({ status: "expired" }); + }); + it("fails callback crashes and recovers a durable claim without exchanging again", async () => { + const h = harness(kind); + const b = await h.begin(); + vi.spyOn(h.slack, "exchange").mockRejectedValueOnce(new Error("code-secret jwt-secret")); + expect((await h.ceremony().callback(b.request)).result.status).toBe("failed"); + expect((await h.ceremony().callback(b.request)).result.status).toBe("failed"); + const h2 = harness(kind); + const b2 = await h2.begin(); + const auth = { + identity: accessIdentity, + audience: "application", + browserHash: proofDigest(b2.session.browser), + proofVersion: 1, + expiresAt: start + 3600_000, + }; + expect( + await h2.directory.link({ + action: "claim", + id: b2.session.id, + auth, + expectedRevision: 1, + stateHash: proofDigest(b2.request.query.get("state")!), + }), + ).toMatchObject({ status: "claimed" }); + h2.reopen(); + expect((await h2.ceremony().callback(b2.request)).result.status).toBe("already_claimed"); + expect(await h2.ceremony().interrupt(b2.session, evidence)).toEqual({ status: "failed" }); + expect(h2.slack.exchanges).toBe(0); + expect(await h2.directory.resolve(accessIdentity)).toEqual({ status: "unknown" }); + }); + it("rejects callback transport drift and keeps secrets out of persistence and responses", async () => { + const h = harness(kind); + const b = await h.begin(); + const query = new URLSearchParams(b.request.query); + query.append("code", "second-code"); + for (const request of [ + { ...b.request, method: "POST" }, + { ...b.request, callbackUri: policy.callbackUri + "/" }, + { ...b.request, query }, + ]) { + const result = await h.ceremony().callback(request); + expect(result.result.status).toBe("invalid"); + expect(result.response).toEqual({ + status: 303, + headers: { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", Location: "https://app.test/" }, + }); + } + expect(h.slack.exchanges).toBe(0); + const response = await h.ceremony().callback(b.request); + await h.ceremony().commit(b.session, evidence, 3, true); + const serialized = h.dump() + JSON.stringify(response); + for (const secret of [ + "fixture-code", + "fixture-access", + b.session.browser, + b.request.query.get("state")!, + new URL(b.authorizationUrl).searchParams.get("nonce")!, + ]) + expect(serialized).not.toContain(secret); + }); + }); +}); diff --git a/src/core/identity/linkCeremony.ts b/src/core/identity/linkCeremony.ts new file mode 100644 index 000000000..b7525062d --- /dev/null +++ b/src/core/identity/linkCeremony.ts @@ -0,0 +1,223 @@ +import { randomBytes } from "node:crypto"; +import { minutesToMs } from "../budgets.js"; +import type { ExternalIdentity, PersonDirectory } from "./contract.js"; +import { + proofDigest, + type AccessEvidence, + type AccessProofProvider, + type SlackPolicy, + type SlackProofProvider, +} from "./humanProof.js"; +import type { LinkCommand, LinkResult } from "./linkContract.js"; + +type Auth = LinkCommand["auth"]; +export interface LinkBrowserSession { + id: string; + browser: string; +} +interface CeremonyDeps { + directory: PersonDirectory; + access: AccessProofProvider; + slack: SlackProofProvider; + now: () => number; +} +export interface LinkCallback { + session: LinkBrowserSession; + evidence: AccessEvidence; + method: string; + /** Exact endpoint without query, supplied by the future trusted HTTP adapter. */ + callbackUri: string; + query: URLSearchParams; +} +const secret = () => randomBytes(32).toString("base64url"); + +/** Disabled orchestration only: no HTTP registration, consent UI, authorization + * consumer, event/log sink, or credential persistence. The future ingress must + * protect initiation/consent against CSRF and transport the session in a secure + * HttpOnly cookie; body/query-supplied session secrets must never substitute. */ +export class LinkCeremony { + private readonly policy: SlackPolicy; + private readonly cleanLocation: string; + constructor( + private readonly deps: CeremonyDeps, + policy: SlackPolicy, + ) { + this.policy = { ...policy }; + this.cleanLocation = new URL("/", policy.callbackUri).href; + } + private async auth(evidence: AccessEvidence, browser: string): Promise { + try { + if (!/^[A-Za-z0-9_-]{43}$/.test(browser)) return null; + const proof = await this.deps.access.verify(evidence); + if (!proof || proof.kind !== "human" || proof.identity.tenant !== null || proof.expiresAt <= this.deps.now()) + return null; + return { + identity: { issuer: proof.identity.issuer, tenant: null, subject: proof.identity.subject }, + audience: proof.audience, + browserHash: proofDigest(browser), + expiresAt: proof.expiresAt, + proofVersion: 1, + }; + } catch { + return null; + } + } + private async link(command: LinkCommand): Promise { + try { + return await this.deps.directory.link(command); + } catch { + return { status: "unavailable" }; + } + } + private async revision(identity: ExternalIdentity): Promise { + const result = await this.deps.directory.resolve(identity); + return result.status === "unknown" ? 0 : result.status === "bound" ? result.binding.revision : null; + } + async begin( + evidence: AccessEvidence, + ): Promise< + | { status: "started"; session: LinkBrowserSession; authorizationUrl: string; expiresAt: number } + | { status: "invalid" | "unavailable" } + > { + try { + const browser = secret(), + state = secret(), + nonce = secret(); + const auth = await this.auth(evidence, browser); + if (!auth) return { status: "invalid" }; + const accessRevision = await this.revision(auth.identity); + if (accessRevision === null) return { status: "invalid" }; + const authorizationUrl = await this.deps.slack.authorization({ policy: { ...this.policy }, state, nonce }); + if (!authorizationUrl) return { status: "unavailable" }; + const expiresAt = Math.min(this.deps.now() + minutesToMs(10), auth.expiresAt); + const result = await this.link({ + action: "begin", + auth, + accessRevision, + slackPolicy: this.policy, + stateHash: proofDigest(state), + nonceHash: proofDigest(nonce), + expiresAt, + resultExpiresAt: expiresAt + minutesToMs(10), + }); + if (result.status !== "ok") return { status: "unavailable" }; + return { + status: "started", + session: { id: result.intent.id, browser }, + authorizationUrl, + expiresAt: result.intent.expiresAt, + }; + } catch { + return { status: "unavailable" }; + } + } + async callback(input: LinkCallback) { + // Never reflect a code, state, provider error, or query in the response. + return { + result: await this.exchange(input), + response: { + status: 303, + headers: { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + Location: this.cleanLocation, + }, + }, + }; + } + private async exchange(input: LinkCallback): Promise { + let claimed: { id: string; auth: Auth; expectedRevision: number } | undefined; + try { + const auth = await this.auth(input.evidence, input.session.browser); + if ( + !auth || + input.method !== "GET" || + input.callbackUri !== this.policy.callbackUri || + input.query.getAll("state").length !== 1 || + input.query.getAll("code").length !== 1 || + [...input.query.keys()].some((key) => key !== "code" && key !== "state") + ) + return { status: "invalid" }; + const state = input.query.get("state")!, + code = input.query.get("code")!; + if (!/^[A-Za-z0-9_-]{43}$/.test(state) || !code || code.length > 4096) return { status: "invalid" }; + const context = await this.link({ action: "inspect", id: input.session.id, auth }); + if (context.status !== "context") return context; + const i = context.intent; + if (i.state !== "pending") return { status: "already_claimed" }; + // Persisted policy is authoritative across reconstruction/config changes. + if ( + i.slackPolicy.audience !== this.policy.audience || + i.slackPolicy.tenant !== this.policy.tenant || + i.slackPolicy.callbackUri !== this.policy.callbackUri + ) + return { status: "invalid" }; + const result = await this.link({ + action: "claim", + id: i.id, + auth, + expectedRevision: i.revision, + stateHash: proofDigest(state), + }); + if (result.status !== "claimed") return result; + claimed = { id: i.id, auth, expectedRevision: result.intent.revision }; + const proof = await this.deps.slack.exchange({ + code, + policy: { ...i.slackPolicy }, + nonceHash: i.nonceHash, + createdAt: i.createdAt, + }); + if (proof) { + const expectedRevision = await this.revision(proof.identity); + if (expectedRevision !== null) { + // Explicit allowlist: even a replaceable provider cannot persist its + // full response, profile, code, JWT, access token or refresh token. + const staged = await this.link({ + action: "prove", + ...claimed, + proof: { + identity: { + issuer: proof.identity.issuer, + tenant: proof.identity.tenant, + subject: proof.identity.subject, + }, + audience: proof.audience, + callbackUri: proof.callbackUri, + nonceHash: proof.nonceHash, + expiresAt: proof.expiresAt, + expectedRevision, + }, + }); + if (staged.status === "ok") return staged; + } + } + } catch { + /* Provider and storage errors may contain credentials. Never expose them. */ + } + // A process crash can leave exchanging durable; explicit interrupt below + // recovers it without code replay. Concurrent callbacks never interrupt it. + return claimed ? this.link({ action: "interrupt", ...claimed }) : { status: "unavailable" }; + } + async interrupt(session: LinkBrowserSession, evidence: AccessEvidence): Promise { + const auth = await this.auth(evidence, session.browser); + if (!auth) return { status: "invalid" }; + const context = await this.link({ action: "inspect", id: session.id, auth }); + if (context.status !== "context") return context; + return this.link({ action: "interrupt", id: session.id, auth, expectedRevision: context.intent.revision }); + } + async commit( + session: LinkBrowserSession, + evidence: AccessEvidence, + expectedRevision: number, + consent: boolean, + ): Promise { + const auth = await this.auth(evidence, session.browser); + return auth + ? this.link({ action: "commit", id: session.id, auth, expectedRevision, consent }) + : { status: "invalid" }; + } + async read(session: LinkBrowserSession, evidence: AccessEvidence): Promise { + const auth = await this.auth(evidence, session.browser); + return auth ? this.link({ action: "read", id: session.id, auth }) : { status: "invalid" }; + } +} diff --git a/src/core/identity/linkContract.ts b/src/core/identity/linkContract.ts index 31e9fbb87..cd3b56b80 100644 --- a/src/core/identity/linkContract.ts +++ b/src/core/identity/linkContract.ts @@ -54,6 +54,8 @@ export const linkCommandSchema = z.discriminatedUnion("action", [ z.object({ action: z.literal("cancel"), ...mutation }).strict(), z.object({ action: z.literal("interrupt"), ...mutation }).strict(), z.object({ action: z.literal("read"), ...addressed }).strict(), + // Authenticated internal proof context, never exposed as a public response. + z.object({ action: z.literal("inspect"), ...addressed }).strict(), ]); export type LinkCommand = z.infer; const failureSchema = z.enum(["conflict", "stale", "revoked", "failed", "cancelled", "expired"]); @@ -160,6 +162,7 @@ export type LinkIntent = z.infer; export type ActiveLinkIntent = z.infer; export type LinkView = Pick; export type LinkResult = + | { status: "context"; intent: ActiveLinkIntent } | { status: "ok" | "claimed"; intent: LinkView } | { status: "committed"; receipt: LinkAudit } | { diff --git a/src/core/identity/linkEngine.ts b/src/core/identity/linkEngine.ts index 7db28d48e..90c70fef1 100644 --- a/src/core/identity/linkEngine.ts +++ b/src/core/identity/linkEngine.ts @@ -174,6 +174,7 @@ export function executeLink(tx: LinkTransaction, input: LinkCommand, now: number } if (now >= i.expiresAt) return refuse(tx, i, now, "expired"); if (c.action === "read") return { status: "ok", intent: view(i) }; + if (c.action === "inspect") return { status: "context", intent: structuredClone(i) }; if (c.expectedRevision !== i.revision) return { status: "stale" }; if (c.action === "cancel") return refuse(tx, i, now, "cancelled"); if (c.action === "interrupt") diff --git a/src/core/identity/testing/fakeHumanProofs.ts b/src/core/identity/testing/fakeHumanProofs.ts new file mode 100644 index 000000000..71f3fae86 --- /dev/null +++ b/src/core/identity/testing/fakeHumanProofs.ts @@ -0,0 +1,43 @@ +import { + proofDigest, + type AccessEvidence, + type AccessHumanProof, + type AccessProofProvider, + type SlackHumanProof, + type SlackPolicy, + type SlackProofProvider, + type SlackProofRequest, +} from "../humanProof.js"; + +/** Deterministic fixture handles, not tokens; never constructed by production. */ +export class FakeAccessProofProvider implements AccessProofProvider { + constructor(private readonly fixtures: readonly { credential: string; proof: AccessHumanProof }[]) {} + async verify(evidence: AccessEvidence): Promise { + if (evidence.strategy !== "access" || evidence.viewAs !== undefined) return null; + const fixture = this.fixtures.find((f) => f.credential === evidence.token); + return fixture ? structuredClone(fixture.proof) : null; + } +} +export class FakeSlackProofProvider implements SlackProofProvider { + exchanges = 0; + private readonly nonceHashes = new Set(); + constructor(private readonly fixture: Omit) {} + private matches(policy: SlackPolicy): boolean { + return ( + policy.audience === this.fixture.audience && + policy.tenant === this.fixture.identity.tenant && + policy.callbackUri === this.fixture.callbackUri + ); + } + async authorization(input: { policy: SlackPolicy; state: string; nonce: string }): Promise { + if (!this.matches(input.policy)) return null; + this.nonceHashes.add(proofDigest(input.nonce)); + return `https://provider.test/authorize?${new URLSearchParams({ state: input.state, nonce: input.nonce })}`; + } + async exchange(input: SlackProofRequest): Promise { + this.exchanges++; + if (input.code !== "fixture-code" || !this.matches(input.policy) || !this.nonceHashes.delete(input.nonceHash)) + return null; + return { ...structuredClone(this.fixture), nonceHash: input.nonceHash }; + } +}