Pairing, credentials, TTLs, and scopes
Short-lived bootstrap grants establish trust, durable signed sessions carry scopes, DPoP binds selected tokens to a key, and WebSocket tickets adapt a current session to an upgrade URL.
What this chapter resolves
- Distinguish bootstrap grants, steady-state sessions, DPoP proofs, and WebSocket tickets.
- Apply credential precedence, lifetime, one-use, and scope rules exactly.
- Identify replay and revocation boundaries without overstating the implementation.
Pairing is not the authentication mechanism used for every request. It is a trust bootstrap: a short-lived or process-injected credential is exchanged for a SQLite-backed session. Ordinary HTTP traffic presents that session as a cookie, Bearer token, or DPoP-bound token. WebSocket upgrades can present a short-lived ticket that points back to the same current session row.
The advertised policy describes posture
The server derives an authentication descriptor from runtime mode and whether its bind host is remotely reachable.
| Runtime posture | Advertised policy | Bootstrap methods |
|---|---|---|
| desktop, local-only host | desktop-managed-local | desktop bootstrap |
| desktop, remotely reachable host | remote-reachable | desktop bootstrap + one-time token |
| web/server, local-only host | loopback-browser | one-time token |
| web/server, remotely reachable host | remote-reachable | one-time token |
All four environment-server posture rows advertise browser-cookie, Bearer, and DPoP session methods. The contract
also defines unsafe-no-auth, but the policy implementation does not produce it at
the pinned revision. A descriptor is capability and posture metadata; it is not an
authorization bypass.
Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
Mode and bind host produce an environment authentication descriptor. A desktop bootstrap seed or one-time pairing grant enters a browser-session exchange, plain Bearer token exchange, or DPoP token exchange. The DPoP token endpoint verifies a proof without an access-token hash, then issues a token bound to the proof key. Later DPoP requests verify a proof bound to the access token, method, and URL. HTTP authenticates the session, then an endpoint checks a scope where required. An authenticated session may mint a five-minute WebSocket ticket containing a session id. The WebSocket upgrade verifies the ticket and reloads the current session row; when no ticket is supplied it can instead authenticate the upgrade request directly. The RPC authorization map checks every method's required scope.
packages/contracts/src/auth.ts:29–202 ↗apps/server/src/auth/EnvironmentAuthPolicy.ts:16–47 ↗apps/server/src/auth/EnvironmentAuth.ts:655–744 ↗apps/server/src/auth/http.ts:164–319 ↗apps/server/src/auth/EnvironmentAuth.ts:592–632 ↗apps/server/src/auth/EnvironmentAuth.ts:769–961 ↗apps/server/src/auth/SessionStore.ts:749–851 ↗packages/shared/src/dpop.ts:1–175 ↗apps/server/src/auth/RpcAuthorization.ts:18–139 ↗Credential lifetime and replay ledger
The classes differ in storage, duration, and consumption semantics. “Token” alone is too vague to reason about their security properties.
| Credential | Storage / binding | Default lifetime / acceptance window | Use behavior |
|---|---|---|---|
| desktop bootstrap seed | in-memory map; trusted process handoff | 24 hours | unbounded reuse while process state survives |
| ordinary pairing link | plaintext credential in SQLite row | 5 minutes | exactly one successful atomic consume |
| development startup link | plaintext credential in SQLite row | 24 hours | exactly one successful atomic consume |
| normal startup link | plaintext credential in SQLite row | 5 minutes | exactly one successful atomic consume |
| browser or Bearer session | server-signed token + SQLite session row | 30 days | reusable until expiry or revocation |
| DPoP access session | signed token with a jkt claim + SQLite session row; bound to a P-256 key | 1 hour | requires a valid token-bound proof per request |
| DPoP proof | signed JWT + exclusive replay-marker file | 5-minute acceptance window; 5-second future tolerance; no exp claim | one use for each thumbprint + jti |
| WebSocket ticket | server-signed ticket containing session id; row reloaded | 5 minutes | the pinned issue/verify path records no one-use state |
Scopes are capabilities, not roles
The contract defines eight independent scopes. A standard client receives five:
orchestration:read, orchestration:operate, terminal:operate, review:write,
and relay:read. The administrative set adds access:read, access:write, and
relay:write.
| Path | Scope result | Expansion rule |
|---|---|---|
| desktop bootstrap seed | administrative set | seeded by the trusted desktop process |
| ordinary one-time link | standard set unless the issuer supplied another set | issuance policy owns the grant |
| startup link | administrative set | created by server startup |
| browser-session exchange | the complete grant set | the browser path does not request a narrower subset |
| Bearer or DPoP token exchange | requested subset, or the grant set by default | cannot add a scope absent from the grant |
| session delegates another pairing link | nonempty, unique requested subset | issuer needs access:write and must hold every delegated scope |
One exchange, then steady-state authentication
Follow a client from pairing link to authorized RPC
Use Previous/Next or select a boundary. Each transition changes the credential class or enforcement owner.
Step 1 of 8: Advertise
The server publishes posture and supported methods
Mode and bind host select a policy. The descriptor tells clients which bootstrap and session methods this environment accepts.
- 1. Advertise · The server publishes posture and supported methods (Client state)
Mode and bind host select a policy. The descriptor tells clients which bootstrap and session methods this environment accepts.
packages/contracts/src/auth.ts:29–202↗apps/server/src/auth/EnvironmentAuthPolicy.ts:16–47↗ - 2. Issue grant · A bootstrap credential establishes initial trust (Durable state)
Desktop may receive a process-injected seed. Other pairing flows create a persisted one-time link with scopes, subject, expiry, and optional proof-key binding; consumption must present the same verified key thumbprint when that binding exists.
apps/server/src/auth/PairingGrantStore.ts:241–330↗apps/server/src/auth/PairingGrantStore.ts:379–568↗ - 3. Strip fragment · The browser removes the pairing secret from its visible URL (Client state)
Startup constructs /pair#token=…. The client reads the fragment and immediately replaces browser history before it exchanges the credential.
apps/server/src/startupAccess.ts:92–97↗apps/web/src/environments/primary/auth.ts:156–176↗ - 4. Consume · The grant is atomically consumed (Durable state)
A persisted one-time grant has one winner. Requested token scopes must be a subset of the grant; delegated pairing scopes must likewise be held by the issuing session.
apps/server/src/persistence/AuthPairingLinks.ts:125–180↗apps/server/src/auth/EnvironmentAuth.ts:655–744↗apps/server/src/auth/http.ts:335–432↗ - 5. Issue session · The exchange selects a session shape (Durable state)
Browser exchange inherits the full grant. Token exchange may narrow it. DPoP token exchange first verifies a proof without an access token to hash, then issues a one-hour signed token whose jkt claim binds the key; ordinary cookie and Bearer sessions normally last 30 days.
apps/server/src/auth/EnvironmentAuth.ts:655–744↗apps/server/src/auth/http.ts:164–319↗apps/server/src/auth/SessionStore.ts:410–434↗apps/server/src/auth/SessionStore.ts:603–747↗apps/server/src/persistence/AuthSessions.ts:34–55↗packages/shared/src/dpop.ts:1–175↗ - 6. Authenticate · Steady-state HTTP verifies token and current server state (Transport boundary)
Verification checks signature, expiry, row existence, and revocation. A DPoP-authenticated request separately checks the bound key, method, URL, access-token hash, time, signature, and replay marker.
apps/server/src/auth/EnvironmentAuth.ts:592–632↗apps/server/src/auth/SessionStore.ts:603–747↗packages/shared/src/dpop.ts:1–175↗apps/server/src/auth/dpop.ts:29–87↗ - 7. Upgrade · A short-lived ticket adapts the session to WebSocket (Transport boundary)
The ticket carries a session id and timestamps—not scopes. Upgrade verification reloads the row's current method, subject, scopes, expiry, and revocation state.
apps/server/src/auth/EnvironmentAuth.ts:769–961↗apps/server/src/auth/SessionStore.ts:749–851↗ - 8. Authorize · Every RPC method maps to a required scope (Runtime work)
The authenticated socket captures its session and scopes; a centralized exhaustive map selects the scope checked before each handler runs.
apps/server/src/ws.ts:385–538↗apps/server/src/auth/RpcAuthorization.ts:18–139↗
Credential selection has no fallback after selection
const authenticateRequest = (
request: HttpServerRequest.HttpServerRequest,
): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
const cookieToken = request.cookies[sessions.cookieName];
const bearerToken = parseBearerToken(request);
const dpopToken = parseDpopToken(request);
const credential = cookieToken ?? bearerToken ?? dpopToken;
if (!credential) {
return Effect.fail(new ServerAuthMissingCredentialError({}));
}
return authenticateToken(credential).pipe(
Effect.flatMap((session) => {
if (session.proofKeyThumbprint) {
if (!dpopToken || dpopToken !== credential) {
return Effect.fail(
new ServerAuthInvalidCredentialError({
diagnostic: "DPoP-bound access token requires DPoP authorization.",
}),
);
}
return verifyRequestDpopProof({
request,
expectedThumbprint: session.proofKeyThumbprint,
expectedAccessToken: dpopToken,
}).pipe(
Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore),
Effect.provideService(Crypto.Crypto, crypto),
Effect.as(session),
);
}
if (dpopToken) {
return Effect.fail(
new ServerAuthInvalidCredentialError({
diagnostic: "DPoP authorization requires a proof-bound access token.",
}),
);
}
return Effect.succeed(session);
}),
);
};Resolve an authentication attempt
Choose a representative request and inspect the exact selection, replay, and revocation boundary.
Scenario 1 of 4: Cookie + Bearer
The cookie is selected first
Credential choice is `cookie ?? Bearer ?? DPoP`; coexistence does not create a fallback chain.
- A valid cookie wins over a Bearer header.
- An invalid cookie does not fall back to that Bearer token.
- A normal cookie plus a DPoP Authorization header is rejected; DPoP authorization must carry the selected proof-bound token.
- Cookie + Bearer · The cookie is selected first (Caveat)
Credential choice is `cookie ?? Bearer ?? DPoP`; coexistence does not create a fallback chain.
- A valid cookie wins over a Bearer header.
- An invalid cookie does not fall back to that Bearer token.
- A normal cookie plus a DPoP Authorization header is rejected; DPoP authorization must carry the selected proof-bound token.
apps/server/src/auth/EnvironmentAuth.ts:592–632↗ - DPoP replay · The second proof use is rejected (Failure path)
After cryptographic and request-binding checks, the server records a durable exclusive marker for the key thumbprint and `jti`.
- For an access-token-authenticated request, the proof binds method, normalized URL, and access-token hash; the initial token-exchange proof has no access token to hash.
- The proof timestamp must be recent and not more than five seconds in the future.
- A repeated thumbprint + jti cannot create the marker again.
packages/shared/src/dpop.ts:1–175↗apps/server/src/auth/dpop.ts:29–87↗ - WS ticket replay · The ticket is single-purpose, not single-use (Caveat)
The pinned five-minute ticket issue/verify path records no one-use state, so another presentation can verify while its parent session remains valid.
- Ticket claims contain only version, kind, session id, issue time, and expiry.
- Each verification reloads the current SQLite session row.
- Expiry or session revocation blocks a new upgrade.
apps/server/src/auth/SessionStore.ts:749–851↗apps/server/src/auth/SessionStore.ts:876–929↗ - Revoke session · New authentication fails; an existing socket keeps its captured scopes (Failure path)
Revocation updates the row and active-session bookkeeping, so later HTTP verification, ticket verification, and upgrades reject the session. An already-open socket does not reload that row per RPC.
- The WebSocket handler captured the authenticated session and scope array at upgrade.
- The revocation path does not signal that socket to close at this revision.
- The socket can keep using its captured authority until the transport otherwise closes.
apps/server/src/auth/SessionStore.ts:876–929↗apps/server/src/auth/SessionStore.ts:603–747↗apps/server/src/auth/SessionStore.ts:749–851↗apps/server/src/auth/EnvironmentAuth.ts:769–961↗apps/server/src/ws.ts:385–538↗
const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn(
"SessionStore.verifyWebSocketToken",
)(function* (token) {
const [encodedPayload, signature] = token.split(".");
if (!encodedPayload || !signature) {
return yield* new MalformedWebSocketTokenError({});
}
const expectedSignature = signPayload(encodedPayload, signingSecret);
if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
return yield* new InvalidWebSocketTokenSignatureError({});
}
const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),
);
const observedAt = yield* DateTime.now;
const expiresAt = DateTime.make(claims.exp);
if (Option.isNone(expiresAt)) {
return yield* new InvalidSessionExpirationClaimError({
sessionId: claims.sid,
expirationClaim: claims.exp,
});
}
if (claims.exp <= observedAt.epochMilliseconds) {
return yield* new WebSocketTokenExpiredError({
sessionId: claims.sid,
expiresAt: expiresAt.value,
observedAt,
});
}
const row = yield* authSessions
.getById({ sessionId: claims.sid })
.pipe(
Effect.mapError(
(cause) => new WebSocketTokenVerificationError({ sessionId: claims.sid, cause }),
),
);
if (Option.isNone(row)) {
return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid });
}
if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) {
return yield* new WebSocketSessionExpiredError({
sessionId: claims.sid,
expiresAt: row.value.expiresAt,
observedAt,
});
}
if (row.value.revokedAt !== null) {
return yield* new WebSocketSessionRevokedError({
sessionId: claims.sid,
revokedAt: row.value.revokedAt,
});
}
return {
sessionId: row.value.sessionId,
token,
method: row.value.method,
client: toClientMetadata(row.value.client),
expiresAt: row.value.expiresAt,
subject: row.value.subject,
scopes: row.value.scopes,
} satisfies VerifiedSession;
});The browser cookie is set HttpOnly, SameSite=Lax, and path /; the code does not
set an explicit Secure attribute. That is one environment-server session shape,
not the credential used by every remote client. Direct hosted pairing stores a
Bearer environment session, while the managed-relay path later establishes a
distinct DPoP-bound environment session. Chapter 35 follows those remote boundaries.