Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions docs/reference/specs/person-directory.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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` |
51 changes: 51 additions & 0 deletions src/channels/linkProofAccess.ts
Original file line number Diff line number Diff line change
@@ -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<VerifyDeps, "now" | "fetchJwks">) {
this.config = { ...config };
this.deps = {
now: deps.now,
fetchJwks: async (url) => signingKeys(await deps.fetchJwks(url)),
cache: new JwksCache(),
};
}
async verify(evidence: AccessEvidence): Promise<AccessHumanProof | null> {
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;
}
}
}
Loading