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 @@ -171,7 +170,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
183 changes: 96 additions & 87 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ export class EnvironmentAuth extends Context.Service<
readonly createBrowserSession: (
credential: string,
requestMetadata: AuthClientMetadata,
previousSessionToken?: string,
) => Effect.Effect<
{
readonly response: AuthBrowserSessionResult;
Expand Down Expand Up @@ -693,99 +694,107 @@ export const make = Effect.gen(function* () {
Effect.withSpan("EnvironmentAuth.getSessionState"),
);

const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = (
credential,
requestMetadata,
) =>
bootstrapCredentials.consume(credential).pipe(
Effect.mapError(toBootstrapExchangeError),
Effect.flatMap((grant) =>
sessions
.issue({
method: "browser-session-cookie",
subject: grant.subject,
scopes: grant.scopes,
client: {
...requestMetadata,
...(grant.label ? { label: grant.label } : {}),
},
})
.pipe(
Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })),
),
),
Effect.map(
(session) =>
({
response: {
authenticated: true,
scopes: session.scopes,
sessionMethod: session.method,
expiresAt: DateTime.toUtc(session.expiresAt),
} satisfies AuthBrowserSessionResult,
sessionToken: session.token,
}) satisfies BootstrapExchangeResult,
),
Effect.withSpan("EnvironmentAuth.createBrowserSession"),
);
const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = Effect.fn(
"EnvironmentAuth.createBrowserSession",
)(function* (credential, requestMetadata, previousSessionToken) {
const previousSession =
previousSessionToken === undefined
? undefined
: yield* sessions.verify(previousSessionToken).pipe(
Effect.catchIf(SessionStore.isSessionCredentialInvalidError, () => Effect.void),
Effect.mapError((cause) => new ServerAuthSessionCredentialValidationError({ cause })),
);
const grant = yield* bootstrapCredentials
.consume(credential)
.pipe(Effect.mapError(toBootstrapExchangeError));
const session = yield* sessions
.issue({
method: "browser-session-cookie",
subject: grant.subject,
scopes: grant.scopes,
...(previousSession?.method === "browser-session-cookie"
? { replaceSessionId: previousSession.sessionId }
: {}),
client: {
...requestMetadata,
...(grant.label ? { label: grant.label } : {}),
},
})
.pipe(Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })));
return {
response: {
authenticated: true,
scopes: session.scopes,
sessionMethod: session.method,
expiresAt: DateTime.toUtc(session.expiresAt),
} satisfies AuthBrowserSessionResult,
sessionToken: session.token,
} satisfies BootstrapExchangeResult;
});

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
Comment thread
cursor[bot] marked this conversation as resolved.
.consume(credential, {
...input,
...(requestedScopes !== undefined ? { requestedScopes } : {}),
})
.pipe(
Effect.mapError((cause) =>
cause._tag === "BootstrapCredentialScopeNotGrantedError"
? new ServerAuthScopeNotGrantedError({})
: toBootstrapExchangeError(cause),
),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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
Loading
Loading