Part II · Boot and connectPairing and auth
Chapter 8source checked

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.

Authentication policy and bootstrap methods by runtime posture
Runtime postureAdvertised policyBootstrap methods
desktop, local-only hostdesktop-managed-localdesktop bootstrap
desktop, remotely reachable hostremote-reachabledesktop bootstrap + one-time token
web/server, local-only hostloopback-browserone-time token
web/server, remotely reachable hostremote-reachableone-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.

Figure 8.1 · Credential ladder and enforcement pointsarrows exchange, derive, or verify credentials
T3 authentication credential ladderDiagram loading

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.

T3 authentication credential ladder
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.

Figure 8.1. Browser, plain-token, and DPoP token exchanges establish sessions differently. The DPoP exchange validates an initial proof before binding the issued token; later requests carry a separate token-bound proof. A WebSocket can use a ticket or direct request authentication, and RPC scopes are still enforced per method.

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, default lifetime, and replay behavior
CredentialStorage / bindingDefault lifetime / acceptance windowUse behavior
desktop bootstrap seedin-memory map; trusted process handoff24 hoursunbounded reuse while process state survives
ordinary pairing linkplaintext credential in SQLite row5 minutesexactly one successful atomic consume
development startup linkplaintext credential in SQLite row24 hoursexactly one successful atomic consume
normal startup linkplaintext credential in SQLite row5 minutesexactly one successful atomic consume
browser or Bearer sessionserver-signed token + SQLite session row30 daysreusable until expiry or revocation
DPoP access sessionsigned token with a jkt claim + SQLite session row; bound to a P-256 key1 hourrequires a valid token-bound proof per request
DPoP proofsigned JWT + exclusive replay-marker file5-minute acceptance window; 5-second future tolerance; no exp claimone use for each thumbprint + jti
WebSocket ticketserver-signed ticket containing session id; row reloaded5 minutesthe 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.

How pairing and exchange paths select environment scopes
PathScope resultExpansion rule
desktop bootstrap seedadministrative setseeded by the trusted desktop process
ordinary one-time linkstandard set unless the issuer supplied another setissuance policy owns the grant
startup linkadministrative setcreated by server startup
browser-session exchangethe complete grant setthe browser path does not request a narrower subset
Bearer or DPoP token exchangerequested subset, or the grant set by defaultcannot add a scope absent from the grant
session delegates another pairing linknonempty, unique requested subsetissuer needs access:write and must hold every delegated scope

One exchange, then steady-state authentication

Interactive flow

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.

1 / 8

Step 1 of 8: Advertise

EnvironmentAuthPolicyClient stateAdvertise

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. 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–202apps/server/src/auth/EnvironmentAuthPolicy.ts:16–47
  2. 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–330apps/server/src/auth/PairingGrantStore.ts:379–568
  3. 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–97apps/web/src/environments/primary/auth.ts:156–176
  4. 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–180apps/server/src/auth/EnvironmentAuth.ts:655–744apps/server/src/auth/http.ts:335–432
  5. 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–744apps/server/src/auth/http.ts:164–319apps/server/src/auth/SessionStore.ts:410–434apps/server/src/auth/SessionStore.ts:603–747apps/server/src/persistence/AuthSessions.ts:34–55packages/shared/src/dpop.ts:1–175
  6. 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–632apps/server/src/auth/SessionStore.ts:603–747packages/shared/src/dpop.ts:1–175apps/server/src/auth/dpop.ts:29–87
  7. 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–961apps/server/src/auth/SessionStore.ts:749–851
  8. 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–538apps/server/src/auth/RpcAuthorization.ts:18–139

Credential selection has no fallback after selection

apps/server/src/auth/EnvironmentAuth.ts:592–632 ↗verbatim · typescript · d18c91b8
  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);
      }),
    );
  };
Read this as: HTTP selects cookie, then Bearer, then DPoP. If the selected credential fails verification or binding rules, authentication fails; a lower-priority credential is not attempted.
Interactive lab

Resolve an authentication attempt

Choose a representative request and inspect the exact selection, replay, and revocation boundary.

Scenario 1 of 4: Cookie + Bearer

Cookie + BearerCaveat

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.
  1. 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
  2. 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–175apps/server/src/auth/dpop.ts:29–87
  3. 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–851apps/server/src/auth/SessionStore.ts:876–929
  4. 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–929apps/server/src/auth/SessionStore.ts:603–747apps/server/src/auth/SessionStore.ts:749–851apps/server/src/auth/EnvironmentAuth.ts:769–961apps/server/src/ws.ts:385–538
apps/server/src/auth/SessionStore.ts:785–851 ↗verbatim · typescript · dcb5b1a1
  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;
  });
Read this as: The ticket's signed claims identify a session. Verification checks ticket time, then reloads the session row and returns its current method, subject, scopes, expiry, and revocation status.

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.

T3
Source-locked editionRead against fa219001d · 23 Aug 2026
Book search

Find a concept, module, or source path

Type two or more characters.