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
2 changes: 0 additions & 2 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
Wakeups,
} from "@t3tools/client-runtime/connection";
import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
import { AuthStandardClientScopes } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -168,7 +167,6 @@ const capabilitiesLayer = Layer.effectContext(
ClientPresentation,
ClientPresentation.of({
metadata: authClientMetadata(Constants.expoConfig?.version),
scopes: AuthStandardClientScopes,
}),
),
Context.add(
Expand Down
50 changes: 49 additions & 1 deletion apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
}).pipe(Effect.provide(makeEnvironmentAuthLayer({ mode: "web", host: "192.168.1.50" }))),
);

it.effect("does not exchange ordinary pairing grants for administrative access tokens", () =>
it.effect("preserves pairing grants after rejecting scopes they do not grant", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const pairingCredential = yield* serverAuth.issuePairingCredential();
Expand All @@ -151,6 +151,33 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
.pipe(Effect.flip);

expect(error._tag).toBe("ServerAuthScopeNotGrantedError");
expect((yield* serverAuth.listPairingLinks()).map((link) => link.id)).toContain(
pairingCredential.id,
);
expect(yield* serverAuth.listSessions()).toEqual([]);

const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
pairingCredential.credential,
["orchestration:read"],
requestMetadata,
);
const session = yield* serverAuth.authenticateHttpRequest(
makeBearerRequest(token.access_token),
);

expect(token.scope).toBe("orchestration:read");
expect(session.scopes).toEqual(["orchestration:read"]);
expect((yield* serverAuth.listPairingLinks()).map((link) => link.id)).not.toContain(
pairingCredential.id,
);
const reused = yield* serverAuth
.exchangeBootstrapCredentialForAccessToken(
pairingCredential.credential,
["orchestration:read"],
requestMetadata,
)
.pipe(Effect.flip);
expect(reused._tag).toBe("ServerAuthInvalidCredentialError");
}).pipe(Effect.provide(makeEnvironmentAuthLayer())),
);

Expand All @@ -171,6 +198,27 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
}).pipe(Effect.provide(makeEnvironmentAuthLayer())),
);

it.effect("narrows seeded desktop grants to the requested scopes", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
"desktop-bootstrap-token",
["orchestration:read"],
requestMetadata,
);
const session = yield* serverAuth.authenticateHttpRequest(
makeBearerRequest(token.access_token),
);

expect(token.scope).toBe("orchestration:read");
expect(session.scopes).toEqual(["orchestration:read"]);
}).pipe(
Effect.provide(
makeEnvironmentAuthLayer({ desktopBootstrapToken: "desktop-bootstrap-token" }),
),
),
);

it.effect("rotates desktop bearer sessions without accumulating authorized clients", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
Expand Down
110 changes: 58 additions & 52 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,61 +731,67 @@ 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),
}
: {}),
// Desktop restarts forget the previous bearer token. Replace
// its session, including stale entries left by older versions.
replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap",
client: {
...requestMetadata,
...(grant.label ? { label: grant.label } : {}),
},
})
.pipe(
Effect.mapError(
(cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }),
),
);
}),
),
Effect.flatMap((session) =>
DateTime.now.pipe(
Effect.map(
(now) =>
({
access_token: session.token,
issued_token_type: AuthAccessTokenType,
token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer",
expires_in: Math.max(
0,
Math.floor(
(session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000,
),
bootstrapCredentials
.consume(credential, {
...input,
...(requestedScopes !== undefined ? { requestedScopes } : {}),
})
.pipe(
Effect.mapError((cause) =>
cause._tag === "BootstrapCredentialScopeNotGrantedError"
? new ServerAuthScopeNotGrantedError({})
: toBootstrapExchangeError(cause),
),
Comment on lines +740 to +744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hand-rolls tag discrimination inside mapError. Consider recovering the known tagged failure with Effect.catchTags first and leaving mapError as the unconditional wrapper (the pattern used elsewhere in this repo, e.g. ProjectFaviconResolver.ts).

Suggested change
Effect.mapError((cause) =>
cause._tag === "BootstrapCredentialScopeNotGrantedError"
? new ServerAuthScopeNotGrantedError({})
: toBootstrapExchangeError(cause),
),
Effect.catchTags({
BootstrapCredentialScopeNotGrantedError: () =>
Effect.fail(new ServerAuthScopeNotGrantedError({})),
}),
Effect.mapError(toBootstrapExchangeError),

Posted via Macroscope — Effect Service Conventions

Effect.flatMap((grant) =>
Effect.gen(function* () {
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),
}
: {}),
// Desktop restarts forget the previous bearer token. Replace
// its session, including stale entries left by older versions.
replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap",
client: {
...requestMetadata,
...(grant.label ? { label: grant.label } : {}),
},
})
.pipe(
Effect.mapError(
(cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }),
),
scope: encodeOAuthScope(session.scopes),
}) satisfies AuthAccessTokenResult,
);
}),
),
Effect.flatMap((session) =>
DateTime.now.pipe(
Effect.map(
(now) =>
({
access_token: session.token,
issued_token_type: AuthAccessTokenType,
token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer",
expires_in: Math.max(
0,
Math.floor(
(session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000,
),
),
scope: encodeOAuthScope(session.scopes),
}) satisfies AuthAccessTokenResult,
),
),
),
),
Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"),
);
Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"),
);

const issuePairingCredentialForSubject = (input: {
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/auth/PairingGrantStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
const token = yield* bootstrapCredentials.issueOneTimeToken();
const results = yield* Effect.all(
Array.from({ length: 8 }, () =>
Effect.result(bootstrapCredentials.consume(token.credential)),
Effect.result(
bootstrapCredentials.consume(token.credential, {
requestedScopes: ["orchestration:read"],
}),
),
),
{
concurrency: "unbounded",
Expand All @@ -121,20 +125,30 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
const token = yield* bootstrapCredentials.issueOneTimeToken({
proofKeyThumbprint: "client-proof-key-thumbprint",
scopes: ["orchestration:read"],
});

const missing = yield* Effect.flip(bootstrapCredentials.consume(token.credential));
const wrong = yield* Effect.flip(
bootstrapCredentials.consume(token.credential, {
proofKeyThumbprint: "other-proof-key-thumbprint",
requestedScopes: ["access:write"],
}),
);
const forbiddenScope = yield* Effect.flip(
bootstrapCredentials.consume(token.credential, {
proofKeyThumbprint: "client-proof-key-thumbprint",
requestedScopes: ["access:write"],
}),
);
const consumed = yield* bootstrapCredentials.consume(token.credential, {
proofKeyThumbprint: "client-proof-key-thumbprint",
requestedScopes: ["orchestration:read"],
});

expect(missing.message).toContain("proof key mismatch");
expect(wrong.message).toContain("proof key mismatch");
expect(forbiddenScope._tag).toBe("BootstrapCredentialScopeNotGrantedError");
expect(consumed.proofKeyThumbprint).toBe("client-proof-key-thumbprint");
}).pipe(Effect.provide(makePairingGrantStoreLayer())),
);
Expand Down
31 changes: 30 additions & 1 deletion apps/server/src/auth/PairingGrantStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ export class UnavailableBootstrapCredentialError extends Schema.TaggedErrorClass
}
}

export class BootstrapCredentialScopeNotGrantedError extends Schema.TaggedErrorClass<BootstrapCredentialScopeNotGrantedError>()(
"BootstrapCredentialScopeNotGrantedError",
{},
) {
override get message(): string {
return "The requested authentication scope was not granted.";
}
}

export const BootstrapCredentialInvalidError = Schema.Union([
UnknownBootstrapCredentialError,
ExpiredBootstrapCredentialError,
Expand Down Expand Up @@ -171,6 +180,7 @@ export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialI
export const BootstrapCredentialError = Schema.Union([
BootstrapCredentialInvalidError,
BootstrapCredentialInternalError,
BootstrapCredentialScopeNotGrantedError,
]);
export type BootstrapCredentialError = typeof BootstrapCredentialError.Type;
export const isBootstrapCredentialError = Schema.is(BootstrapCredentialError);
Expand Down Expand Up @@ -218,6 +228,7 @@ export class PairingGrantStore extends Context.Service<
credential: string,
input?: {
readonly proofKeyThumbprint?: string;
readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>;
},
) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
}
Expand All @@ -230,7 +241,7 @@ interface StoredBootstrapGrant extends BootstrapGrant {
type ConsumeResult =
| {
readonly _tag: "error";
readonly reason: "not-found" | "expired";
readonly reason: "not-found" | "expired" | "scope-not-granted";
readonly error: BootstrapCredentialError;
}
| {
Expand Down Expand Up @@ -473,6 +484,17 @@ export const make = Effect.gen(function* () {
];
}

if (input?.requestedScopes?.some((scope) => !grant.scopes.includes(scope))) {
return [
{
_tag: "error",
reason: "scope-not-granted",
error: new BootstrapCredentialScopeNotGrantedError({}),
},
current,
];
}

const remainingUses = grant.remainingUses;
if (typeof remainingUses === "number") {
if (remainingUses <= 1) {
Expand Down Expand Up @@ -515,6 +537,9 @@ export const make = Effect.gen(function* () {
.consumeAvailable({
credential,
proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
...(input?.requestedScopes !== undefined
? { requestedScopes: input.requestedScopes }
: {}),
consumedAt: now,
now,
})
Expand Down Expand Up @@ -560,6 +585,10 @@ export const make = Effect.gen(function* () {
return yield* new BootstrapCredentialProofKeyMismatchError({});
}

if (input?.requestedScopes?.some((scope) => !matching.value.scopes.includes(scope))) {
return yield* new BootstrapCredentialScopeNotGrantedError({});
}

return yield* new UnavailableBootstrapCredentialError({});
},
);
Expand Down
Loading
Loading