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