Skip to content
Open
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
7 changes: 7 additions & 0 deletions apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
);

Expand Down
65 changes: 34 additions & 31 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/auth/PairingGrantStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
67 changes: 67 additions & 0 deletions apps/server/src/auth/PairingGrantStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ export class PairingGrantStore extends Context.Service<
>;
readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>;
readonly inspect: (
credential: string,
input?: {
readonly proofKeyThumbprint?: string;
},
) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
readonly consume: (
credential: string,
input?: {
Expand Down Expand Up @@ -567,13 +573,74 @@ 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,
get streamChanges() {
return Stream.fromPubSub(changesPubSub);
},
revoke,
inspect,
consume,
});
});
Expand Down
3 changes: 2 additions & 1 deletion docs/internals/environment-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading