From 023d4e2032fa5352775ac85fce851a45329321f1 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:33:16 +0300 Subject: [PATCH] fix(server): do not burn pairing grants on a scope mismatch Token exchange consumed the one-time pairing grant, then rejected extra scopes. The QR was already gone. The caller had to mint another link. Check requested scopes against the grant first. Only consume after that check passes. --- apps/server/src/auth/EnvironmentAuth.test.ts | 7 ++ apps/server/src/auth/EnvironmentAuth.ts | 65 +++++++++--------- .../server/src/auth/PairingGrantStore.test.ts | 13 ++++ apps/server/src/auth/PairingGrantStore.ts | 67 +++++++++++++++++++ docs/internals/environment-auth.md | 3 +- 5 files changed, 123 insertions(+), 32 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 440efcee51ee..f8f05e31727c 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -123,6 +123,13 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { .pipe(Effect.flip); expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); + + const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + pairingCredential.credential, + ["orchestration:read"], + requestMetadata, + ); + expect(token.scope).toBe("orchestration:read"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index eb0563421408..bbf9c890f370 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -690,37 +690,40 @@ export const make = Effect.gen(function* () { const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => - bootstrapCredentials.consume(credential, input).pipe( - Effect.mapError(toBootstrapExchangeError), - Effect.flatMap((grant) => - Effect.gen(function* () { - const grantedScopes = requestedScopes ?? grant.scopes; - if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { - return yield* new ServerAuthScopeNotGrantedError({}); - } - return yield* sessions - .issue({ - method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", - subject: grant.subject, - scopes: grantedScopes, - ...(input?.proofKeyThumbprint - ? { - proofKeyThumbprint: input.proofKeyThumbprint, - ttl: Duration.hours(1), - } - : {}), - client: { - ...requestMetadata, - ...(grant.label ? { label: grant.label } : {}), - }, - }) - .pipe( - Effect.mapError( - (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), - ), - ); - }), - ), + Effect.gen(function* () { + if (requestedScopes !== undefined) { + const inspected = yield* bootstrapCredentials + .inspect(credential, input) + .pipe(Effect.mapError(toBootstrapExchangeError)); + if (!requestedScopes.every((scope) => inspected.scopes.includes(scope))) { + return yield* new ServerAuthScopeNotGrantedError({}); + } + } + + const grant = yield* bootstrapCredentials + .consume(credential, input) + .pipe(Effect.mapError(toBootstrapExchangeError)); + const grantedScopes = requestedScopes ?? grant.scopes; + return yield* sessions + .issue({ + method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", + subject: grant.subject, + scopes: grantedScopes, + ...(input?.proofKeyThumbprint + ? { + proofKeyThumbprint: input.proofKeyThumbprint, + ttl: Duration.hours(1), + } + : {}), + client: { + ...requestMetadata, + ...(grant.label ? { label: grant.label } : {}), + }, + }) + .pipe( + Effect.mapError((cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause })), + ); + }).pipe( Effect.flatMap((session) => DateTime.now.pipe( Effect.map( diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 5242dd738b89..55da20d25072 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -89,6 +89,19 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { }).pipe(Effect.provide(makePairingGrantStoreLayer())), ); + it.effect("inspects a pairing grant without consuming it", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Inspect me" }); + const inspected = yield* bootstrapCredentials.inspect(issued.credential); + const consumed = yield* bootstrapCredentials.consume(issued.credential); + + expect(inspected.scopes).toEqual(consumed.scopes); + expect(inspected.label).toBe("Inspect me"); + expect(consumed.label).toBe("Inspect me"); + }).pipe(Effect.provide(makePairingGrantStoreLayer())), + ); + it.effect("atomically consumes a one-time token when multiple requests race", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 057a257ba664..3ad5e6538c82 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -214,6 +214,12 @@ export class PairingGrantStore extends Context.Service< >; readonly streamChanges: Stream.Stream; readonly revoke: (id: string) => Effect.Effect; + readonly inspect: ( + credential: string, + input?: { + readonly proofKeyThumbprint?: string; + }, + ) => Effect.Effect; readonly consume: ( credential: string, input?: { @@ -567,6 +573,66 @@ export const make = Effect.gen(function* () { }, ); + const inspect: PairingGrantStore["Service"]["inspect"] = Effect.fn("PairingGrantStore.inspect")( + function* (credential, input) { + const now = yield* DateTime.now; + const seeded = (yield* Ref.get(seededGrantsRef)).get(credential); + if (seeded) { + if (DateTime.isGreaterThanOrEqualTo(now, seeded.expiresAt)) { + return yield* new ExpiredBootstrapCredentialError({}); + } + if (seeded.proofKeyThumbprint && seeded.proofKeyThumbprint !== input?.proofKeyThumbprint) { + return yield* new BootstrapCredentialProofKeyMismatchError({}); + } + return { + method: seeded.method, + scopes: seeded.scopes, + subject: seeded.subject, + ...(seeded.label ? { label: seeded.label } : {}), + ...(seeded.proofKeyThumbprint ? { proofKeyThumbprint: seeded.proofKeyThumbprint } : {}), + expiresAt: seeded.expiresAt, + } satisfies BootstrapGrant; + } + + const matching = yield* pairingLinks + .getByCredential({ credential }) + .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause }))); + if (Option.isNone(matching)) { + return yield* new UnknownBootstrapCredentialError({}); + } + + if (matching.value.revokedAt !== null) { + return yield* new UnavailableBootstrapCredentialError({}); + } + + if (matching.value.consumedAt !== null) { + return yield* new UnknownBootstrapCredentialError({}); + } + + if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { + return yield* new ExpiredBootstrapCredentialError({}); + } + + if ( + matching.value.proofKeyThumbprint !== null && + matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint + ) { + return yield* new BootstrapCredentialProofKeyMismatchError({}); + } + + return { + method: matching.value.method, + scopes: matching.value.scopes, + subject: matching.value.subject, + ...(matching.value.label ? { label: matching.value.label } : {}), + ...(matching.value.proofKeyThumbprint + ? { proofKeyThumbprint: matching.value.proofKeyThumbprint } + : {}), + expiresAt: matching.value.expiresAt, + } satisfies BootstrapGrant; + }, + ); + return PairingGrantStore.of({ issueOneTimeToken, listActive, @@ -574,6 +640,7 @@ export const make = Effect.gen(function* () { return Stream.fromPubSub(changesPubSub); }, revoke, + inspect, consume, }); }); diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 5f4f5b6e9607..f4a7205e15da 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -75,7 +75,8 @@ proof key. See `SessionStore.ts` and `EnvironmentAuth.ts`. Requested scopes must be a subset of the one-time bootstrap credential grant. An ordinary paired client therefore cannot exchange its grant for -`access:read`, `access:write`, or `relay:write`. +`access:read`, `access:write`, or `relay:write`. A request that asks for +scopes the grant does not include is rejected and the grant is left unused. ### DPoP-Bound Access Token