authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 20:30:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:30:10-07:00
loge9c3d950f023130791d8ac5fe7aa0992e3430738
treee6886f00006c86fce8d32ccba2d14adc3aa9264c
parentbfec2387b8102975c84690f99be0f5f834fd0cbe
signature Signed by PGP key B5690EEEBB952194

fix(auth): preserve explicitly granted pairing scopes (#9785)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

28 files changed, 812 insertions(+), 91 deletions(-)

apps/mobile/src/connection/platform.ts-2
...@@ -10,7 +10,6 @@ import {...@@ -10,7 +10,6 @@ import {
10 Wakeups,10 Wakeups,
11} from "@t3tools/client-runtime/connection";11} from "@t3tools/client-runtime/connection";
12import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";12import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
13import { AuthStandardClientScopes } from "@t3tools/contracts";
14import * as Context from "effect/Context";13import * as Context from "effect/Context";
15import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
...@@ -216,7 +215,6 @@ const layerCapabilities = Layer.effectContext(...@@ -216,7 +215,6 @@ const layerCapabilities = Layer.effectContext(
216 ClientCapabilities.ClientPresentation,215 ClientCapabilities.ClientPresentation,
217 ClientCapabilities.ClientPresentation.of({216 ClientCapabilities.ClientPresentation.of({
218 metadata: authClientMetadata(Constants.expoConfig?.version),217 metadata: authClientMetadata(Constants.expoConfig?.version),
219 scopes: AuthStandardClientScopes,
220 }),218 }),
221 ),219 ),
222 Context.add(220 Context.add(
apps/server/src/auth/EnvironmentAuth.test.ts+52-3
...@@ -353,7 +353,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -353,7 +353,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
353 }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))),353 }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))),
354 );354 );
355355
356 it.effect("does not exchange ordinary pairing grants for administrative access tokens", () =>356 it.effect("preserves pairing grants after rejecting scopes they do not grant", () =>
357 Effect.gen(function* () {357 Effect.gen(function* () {
358 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;358 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
359 const pairingCredential = yield* serverAuth.issuePairingCredential();359 const pairingCredential = yield* serverAuth.issuePairingCredential();
...@@ -367,10 +367,40 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -367,10 +367,40 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
367 .pipe(Effect.flip);367 .pipe(Effect.flip);
368368
369 expect(error._tag).toBe("ServerAuthScopeNotGrantedError");369 expect(error._tag).toBe("ServerAuthScopeNotGrantedError");
370 expect((yield* serverAuth.listPairingLinks()).map((link) => link.id)).toContain(
371 pairingCredential.id,
372 );
373 expect(yield* serverAuth.listSessions()).toEqual([]);
374
375 const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
376 pairingCredential.credential,
377 ["orchestration:read"],
378 requestMetadata,
379 );
380 const session = yield* serverAuth.authenticateHttpRequest(
381 makeBearerRequest(token.access_token),
382 );
383
384 expect(token.scope).toBe("orchestration:read");
385 expect(session.scopes).toEqual(["orchestration:read"]);
386 expect((yield* serverAuth.listPairingLinks()).map((link) => link.id)).not.toContain(
387 pairingCredential.id,
388 );
389 const reused = yield* serverAuth
390 .exchangeBootstrapCredentialForAccessToken(
391 pairingCredential.credential,
392 ["orchestration:read"],
393 requestMetadata,
394 )
395 .pipe(Effect.flip);
396 expect(reused._tag).toBe("ServerAuthInvalidCredentialError");
370 }).pipe(Effect.provide(layerEnvironmentAuth())),397 }).pipe(Effect.provide(layerEnvironmentAuth())),
371 );398 );
372399
373 it.effect("inherits a constrained pairing grant when token exchange omits scope", () =>400 it.effect.each([
401 { label: "omits scope", requestedScopes: undefined },
402 { label: "requests no scopes", requestedScopes: [] },
403 ])("inherits a constrained pairing grant when token exchange $label", ({ requestedScopes }) =>
374 Effect.gen(function* () {404 Effect.gen(function* () {
375 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;405 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
376 const pairingCredential = yield* serverAuth.issuePairingCredential({406 const pairingCredential = yield* serverAuth.issuePairingCredential({
...@@ -379,7 +409,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -379,7 +409,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
379409
380 const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(410 const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
381 pairingCredential.credential,411 pairingCredential.credential,
382 undefined,412 requestedScopes,
383 requestMetadata,413 requestMetadata,
384 );414 );
385415
...@@ -387,6 +417,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -387,6 +417,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
387 }).pipe(Effect.provide(layerEnvironmentAuth())),417 }).pipe(Effect.provide(layerEnvironmentAuth())),
388 );418 );
389419
420 it.effect("narrows seeded desktop grants to the requested scopes", () =>
421 Effect.gen(function* () {
422 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
423 const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
424 "desktop-bootstrap-token",
425 ["orchestration:read"],
426 requestMetadata,
427 );
428 const session = yield* serverAuth.authenticateHttpRequest(
429 makeBearerRequest(token.access_token),
430 );
431
432 expect(token.scope).toBe("orchestration:read");
433 expect(session.scopes).toEqual(["orchestration:read"]);
434 }).pipe(
435 Effect.provide(layerEnvironmentAuth({ desktopBootstrapToken: "desktop-bootstrap-token" })),
436 ),
437 );
438
390 it.effect("rotates desktop bearer sessions without accumulating authorized clients", () =>439 it.effect("rotates desktop bearer sessions without accumulating authorized clients", () =>
391 Effect.gen(function* () {440 Effect.gen(function* () {
392 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;441 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
apps/server/src/auth/EnvironmentAuth.ts+60-45
...@@ -466,6 +466,7 @@ export class EnvironmentAuth extends Context.Service<...@@ -466,6 +466,7 @@ export class EnvironmentAuth extends Context.Service<
466 readonly createBrowserSession: (466 readonly createBrowserSession: (
467 credential: string,467 credential: string,
468 requestMetadata: AuthClientMetadata,468 requestMetadata: AuthClientMetadata,
469 previousSessionToken?: string,
469 ) => Effect.Effect<470 ) => Effect.Effect<
470 {471 {
471 readonly response: AuthBrowserSessionResult;472 readonly response: AuthBrowserSessionResult;
...@@ -786,12 +787,11 @@ export const make = Effect.gen(function* () {...@@ -786,12 +787,11 @@ export const make = Effect.gen(function* () {
786 Effect.withSpan("EnvironmentAuth.getSessionState"),787 Effect.withSpan("EnvironmentAuth.getSessionState"),
787 );788 );
788789
789 const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = (790 const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = Effect.fn(
790 credential,791 "EnvironmentAuth.createBrowserSession",
791 requestMetadata,792 )(function* (credential, requestMetadata, previousSessionToken) {
792 ) => {
793 if (devAuth?.matches(credential)) {793 if (devAuth?.matches(credential)) {
794 return sessions.verify(credential).pipe(794 return yield* sessions.verify(credential).pipe(
795 mapSessionVerificationErrors,795 mapSessionVerificationErrors,
796 Effect.flatMap((session) =>796 Effect.flatMap((session) =>
797 DateTime.now.pipe(797 DateTime.now.pipe(
...@@ -814,38 +814,40 @@ export const make = Effect.gen(function* () {...@@ -814,38 +814,40 @@ export const make = Effect.gen(function* () {
814 Effect.withSpan("EnvironmentAuth.createBrowserSession"),814 Effect.withSpan("EnvironmentAuth.createBrowserSession"),
815 );815 );
816 }816 }
817 return bootstrapCredentials.consume(credential).pipe(817 const previousSession =
818 Effect.mapError(toBootstrapExchangeError),818 previousSessionToken === undefined
819 Effect.flatMap((grant) =>819 ? undefined
820 sessions820 : yield* sessions.verify(previousSessionToken).pipe(
821 .issue({821 Effect.catchIf(SessionStore.isSessionCredentialInvalidError, () => Effect.void),
822 method: "browser-session-cookie",822 Effect.mapError((cause) => new ServerAuthSessionCredentialValidationError({ cause })),
823 subject: grant.subject,823 );
824 scopes: grant.scopes,824 const grant = yield* bootstrapCredentials
825 client: {825 .consume(credential)
826 ...requestMetadata,826 .pipe(Effect.mapError(toBootstrapExchangeError));
827 ...(grant.label ? { label: grant.label } : {}),827 const session = yield* sessions
828 },828 .issue({
829 })829 method: "browser-session-cookie",
830 .pipe(830 subject: grant.subject,
831 Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })),831 scopes: grant.scopes,
832 ),832 ...(previousSession?.method === "browser-session-cookie"
833 ),833 ? { replaceSessionId: previousSession.sessionId }
834 Effect.map(834 : {}),
835 (session) =>835 client: {
836 ({836 ...requestMetadata,
837 response: {837 ...(grant.label ? { label: grant.label } : {}),
838 authenticated: true,838 },
839 scopes: session.scopes,839 })
840 sessionMethod: session.method,840 .pipe(Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })));
841 expiresAt: DateTime.toUtc(session.expiresAt),841 return {
842 } satisfies AuthBrowserSessionResult,842 response: {
843 sessionToken: session.token,843 authenticated: true,
844 }) satisfies BootstrapExchangeResult,844 scopes: session.scopes,
845 ),845 sessionMethod: session.method,
846 Effect.withSpan("EnvironmentAuth.createBrowserSession"),846 expiresAt: DateTime.toUtc(session.expiresAt),
847 );847 } satisfies AuthBrowserSessionResult,
848 };848 sessionToken: session.token,
849 } satisfies BootstrapExchangeResult;
850 });
849851
850 type ResolvedBootstrapGrant = Pick<852 type ResolvedBootstrapGrant = Pick<
851 PairingGrantStore.BootstrapGrant,853 PairingGrantStore.BootstrapGrant,
...@@ -855,15 +857,24 @@ export const make = Effect.gen(function* () {...@@ -855,15 +857,24 @@ export const make = Effect.gen(function* () {
855 };857 };
856 const resolveBootstrapGrant = (858 const resolveBootstrapGrant = (
857 credential: string,859 credential: string,
858 input?: { readonly proofKeyThumbprint?: string },860 input?: {
861 readonly proofKeyThumbprint?: string;
862 readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>;
863 },
859 ): Effect.Effect<864 ): Effect.Effect<
860 ResolvedBootstrapGrant,865 ResolvedBootstrapGrant,
861 ServerAuthInvalidCredentialError | ServerAuthInternalError866 ServerAuthInvalidCredentialError | ServerAuthInternalError | ServerAuthScopeNotGrantedError
862 > => {867 > => {
863 if (!devAuth?.matches(credential)) {868 if (!devAuth?.matches(credential)) {
864 return bootstrapCredentials869 return bootstrapCredentials
865 .consume(credential, input)870 .consume(credential, input)
866 .pipe(Effect.mapError(toBootstrapExchangeError));871 .pipe(
872 Effect.mapError((cause) =>
873 cause._tag === "BootstrapCredentialScopeNotGrantedError"
874 ? new ServerAuthScopeNotGrantedError({})
875 : toBootstrapExchangeError(cause),
876 ),
877 );
867 }878 }
868 return sessions.verify(credential).pipe(879 return sessions.verify(credential).pipe(
869 mapSessionVerificationErrors,880 mapSessionVerificationErrors,
...@@ -879,14 +890,15 @@ export const make = Effect.gen(function* () {...@@ -879,14 +890,15 @@ export const make = Effect.gen(function* () {
879 };890 };
880891
881 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =892 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =
882 (credential, requestedScopes, requestMetadata, input) =>893 (credential, requestedScopesInput, requestMetadata, input) => {
883 resolveBootstrapGrant(credential, input).pipe(894 const requestedScopes = requestedScopesInput?.length ? requestedScopesInput : undefined;
895 return resolveBootstrapGrant(credential, {
896 ...input,
897 ...(requestedScopes !== undefined ? { requestedScopes } : {}),
898 }).pipe(
884 Effect.flatMap((grant) =>899 Effect.flatMap((grant) =>
885 Effect.gen(function* () {900 Effect.gen(function* () {
886 const grantedScopes = requestedScopes ?? grant.scopes;901 const grantedScopes = requestedScopes ?? grant.scopes;
887 if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) {
888 return yield* new ServerAuthScopeNotGrantedError({});
889 }
890 return yield* sessions902 return yield* sessions
891 .issue({903 .issue({
892 method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token",904 method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token",
...@@ -934,6 +946,7 @@ export const make = Effect.gen(function* () {...@@ -934,6 +946,7 @@ export const make = Effect.gen(function* () {
934 ),946 ),
935 Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"),947 Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"),
936 );948 );
949 };
937950
938 const issuePairingCredentialForSubject = (input: {951 const issuePairingCredentialForSubject = (input: {
939 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;952 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
...@@ -1238,6 +1251,8 @@ export const make = Effect.gen(function* () {...@@ -1238,6 +1251,8 @@ export const make = Effect.gen(function* () {
1238 Effect.catchTags({1251 Effect.catchTags({
1239 ServerAuthInvalidCredentialError: () =>1252 ServerAuthInvalidCredentialError: () =>
1240 Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })),1253 Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })),
1254 ServerAuthScopeNotGrantedError: () =>
1255 Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })),
1241 }),1256 }),
1242 Effect.flatMap((grant) =>1257 Effect.flatMap((grant) =>
1243 grant.method !== "one-time-token" && grant.method !== "reusable-dev-token"1258 grant.method !== "one-time-token" && grant.method !== "reusable-dev-token"
apps/server/src/auth/PairingGrantStore.test.ts+15-1
...@@ -104,7 +104,11 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {...@@ -104,7 +104,11 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
104 const token = yield* bootstrapCredentials.issueOneTimeToken();104 const token = yield* bootstrapCredentials.issueOneTimeToken();
105 const results = yield* Effect.all(105 const results = yield* Effect.all(
106 Array.from({ length: 8 }, () =>106 Array.from({ length: 8 }, () =>
107 Effect.result(bootstrapCredentials.consume(token.credential)),107 Effect.result(
108 bootstrapCredentials.consume(token.credential, {
109 requestedScopes: ["orchestration:read"],
110 }),
111 ),
108 ),112 ),
109 {113 {
110 concurrency: "unbounded",114 concurrency: "unbounded",
...@@ -128,20 +132,30 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {...@@ -128,20 +132,30 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
128 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;132 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
129 const token = yield* bootstrapCredentials.issueOneTimeToken({133 const token = yield* bootstrapCredentials.issueOneTimeToken({
130 proofKeyThumbprint: "client-proof-key-thumbprint",134 proofKeyThumbprint: "client-proof-key-thumbprint",
135 scopes: ["orchestration:read"],
131 });136 });
132137
133 const missing = yield* Effect.flip(bootstrapCredentials.consume(token.credential));138 const missing = yield* Effect.flip(bootstrapCredentials.consume(token.credential));
134 const wrong = yield* Effect.flip(139 const wrong = yield* Effect.flip(
135 bootstrapCredentials.consume(token.credential, {140 bootstrapCredentials.consume(token.credential, {
136 proofKeyThumbprint: "other-proof-key-thumbprint",141 proofKeyThumbprint: "other-proof-key-thumbprint",
142 requestedScopes: ["access:write"],
143 }),
144 );
145 const forbiddenScope = yield* Effect.flip(
146 bootstrapCredentials.consume(token.credential, {
147 proofKeyThumbprint: "client-proof-key-thumbprint",
148 requestedScopes: ["access:write"],
137 }),149 }),
138 );150 );
139 const consumed = yield* bootstrapCredentials.consume(token.credential, {151 const consumed = yield* bootstrapCredentials.consume(token.credential, {
140 proofKeyThumbprint: "client-proof-key-thumbprint",152 proofKeyThumbprint: "client-proof-key-thumbprint",
153 requestedScopes: ["orchestration:read"],
141 });154 });
142155
143 expect(missing.message).toContain("proof key mismatch");156 expect(missing.message).toContain("proof key mismatch");
144 expect(wrong.message).toContain("proof key mismatch");157 expect(wrong.message).toContain("proof key mismatch");
158 expect(forbiddenScope._tag).toBe("BootstrapCredentialScopeNotGrantedError");
145 expect(consumed.proofKeyThumbprint).toBe("client-proof-key-thumbprint");159 expect(consumed.proofKeyThumbprint).toBe("client-proof-key-thumbprint");
146 }).pipe(Effect.provide(layerPairingGrantStore())),160 }).pipe(Effect.provide(layerPairingGrantStore())),
147 );161 );
apps/server/src/auth/PairingGrantStore.ts+33-1
...@@ -69,6 +69,15 @@ export class UnavailableBootstrapCredentialError extends Schema.TaggedError<Unav...@@ -69,6 +69,15 @@ export class UnavailableBootstrapCredentialError extends Schema.TaggedError<Unav
69 }69 }
70}70}
7171
72export class BootstrapCredentialScopeNotGrantedError extends Schema.TaggedError<BootstrapCredentialScopeNotGrantedError>()(
73 "BootstrapCredentialScopeNotGrantedError",
74 {},
75) {
76 override get message(): string {
77 return "The requested authentication scope was not granted.";
78 }
79}
80
72export const BootstrapCredentialInvalidError = Schema.Union([81export const BootstrapCredentialInvalidError = Schema.Union([
73 UnknownBootstrapCredentialError,82 UnknownBootstrapCredentialError,
74 ExpiredBootstrapCredentialError,83 ExpiredBootstrapCredentialError,
...@@ -174,6 +183,7 @@ export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialI...@@ -174,6 +183,7 @@ export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialI
174export const BootstrapCredentialError = Schema.Union([183export const BootstrapCredentialError = Schema.Union([
175 BootstrapCredentialInvalidError,184 BootstrapCredentialInvalidError,
176 BootstrapCredentialInternalError,185 BootstrapCredentialInternalError,
186 BootstrapCredentialScopeNotGrantedError,
177]);187]);
178export type BootstrapCredentialError = typeof BootstrapCredentialError.Type;188export type BootstrapCredentialError = typeof BootstrapCredentialError.Type;
179189
...@@ -220,6 +230,7 @@ export class PairingGrantStore extends Context.Service<...@@ -220,6 +230,7 @@ export class PairingGrantStore extends Context.Service<
220 credential: string,230 credential: string,
221 input?: {231 input?: {
222 readonly proofKeyThumbprint?: string;232 readonly proofKeyThumbprint?: string;
233 readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>;
223 },234 },
224 ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;235 ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
225 }236 }
...@@ -232,7 +243,7 @@ interface StoredBootstrapGrant extends BootstrapGrant {...@@ -232,7 +243,7 @@ interface StoredBootstrapGrant extends BootstrapGrant {
232type ConsumeResult =243type ConsumeResult =
233 | {244 | {
234 readonly _tag: "error";245 readonly _tag: "error";
235 readonly reason: "not-found" | "expired";246 readonly reason: "not-found" | "expired" | "scope-not-granted";
236 readonly error: BootstrapCredentialError;247 readonly error: BootstrapCredentialError;
237 }248 }
238 | {249 | {
...@@ -493,6 +504,17 @@ export const make = Effect.gen(function* () {...@@ -493,6 +504,17 @@ export const make = Effect.gen(function* () {
493 ];504 ];
494 }505 }
495506
507 if (input?.requestedScopes?.some((scope) => !grant.scopes.includes(scope))) {
508 return [
509 {
510 _tag: "error",
511 reason: "scope-not-granted",
512 error: new BootstrapCredentialScopeNotGrantedError({}),
513 },
514 current,
515 ];
516 }
517
496 const remainingUses = grant.remainingUses;518 const remainingUses = grant.remainingUses;
497 if (typeof remainingUses === "number") {519 if (typeof remainingUses === "number") {
498 if (remainingUses <= 1) {520 if (remainingUses <= 1) {
...@@ -531,10 +553,16 @@ export const make = Effect.gen(function* () {...@@ -531,10 +553,16 @@ export const make = Effect.gen(function* () {
531 return yield* seededResult.error;553 return yield* seededResult.error;
532 }554 }
533555
556 // The scope check is part of the UPDATE's WHERE clause so a rejected
557 // request cannot consume a one-time link. The re-check below only
558 // explains why nothing matched.
534 const consumed = yield* pairingLinks559 const consumed = yield* pairingLinks
535 .consumeAvailable({560 .consumeAvailable({
536 credential,561 credential,
537 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,562 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
563 ...(input?.requestedScopes !== undefined
564 ? { requestedScopes: input.requestedScopes }
565 : {}),
538 consumedAt: now,566 consumedAt: now,
539 now,567 now,
540 })568 })
...@@ -580,6 +608,10 @@ export const make = Effect.gen(function* () {...@@ -580,6 +608,10 @@ export const make = Effect.gen(function* () {
580 return yield* new BootstrapCredentialProofKeyMismatchError({});608 return yield* new BootstrapCredentialProofKeyMismatchError({});
581 }609 }
582610
611 if (input?.requestedScopes?.some((scope) => !matching.value.scopes.includes(scope))) {
612 return yield* new BootstrapCredentialScopeNotGrantedError({});
613 }
614
583 return yield* new UnavailableBootstrapCredentialError({});615 return yield* new UnavailableBootstrapCredentialError({});
584 },616 },
585 );617 );
apps/server/src/auth/SessionStore.test.ts+41
...@@ -428,6 +428,47 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {...@@ -428,6 +428,47 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
428 }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))),428 }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))),
429 );429 );
430430
431 it.effect.each(["insert", "revoke"] as const)(
432 "keeps existing browser sessions valid when replacement cannot %s",
433 (operation) =>
434 Effect.gen(function* () {
435 const sessions = yield* SessionStore.SessionStore;
436 const sql = yield* SqlClient.SqlClient;
437 const previous = yield* sessions.issue({ subject: "one-time-token" });
438 const unrelated = yield* sessions.issue({ subject: "one-time-token" });
439 if (operation === "insert") {
440 yield* sql`
441 CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions
442 BEGIN
443 SELECT RAISE(ABORT, 'simulated insert failure');
444 END
445 `;
446 } else {
447 yield* sql`
448 CREATE TRIGGER reject_auth_session_revocation BEFORE UPDATE OF revoked_at ON auth_sessions
449 BEGIN
450 SELECT RAISE(ABORT, 'simulated revocation failure');
451 END
452 `;
453 }
454
455 const error = yield* sessions
456 .issue({
457 subject: "replacement-pairing",
458 scopes: ["orchestration:read"],
459 replaceSessionId: previous.sessionId,
460 })
461 .pipe(Effect.flip);
462
463 expect(error._tag).toBe("SessionCredentialIssueError");
464 expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId);
465 expect((yield* sessions.verify(unrelated.token)).sessionId).toBe(unrelated.sessionId);
466 expect((yield* sessions.listActive()).map((session) => session.sessionId).sort()).toEqual(
467 [previous.sessionId, unrelated.sessionId].sort(),
468 );
469 }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))),
470 );
471
431 it.effect("rejects websocket tokens once the parent session has expired", () =>472 it.effect("rejects websocket tokens once the parent session has expired", () =>
432 Effect.gen(function* () {473 Effect.gen(function* () {
433 const sessions = yield* SessionStore.SessionStore;474 const sessions = yield* SessionStore.SessionStore;
apps/server/src/auth/SessionStore.ts+10-2
...@@ -383,6 +383,8 @@ export class SessionStore extends Context.Service<...@@ -383,6 +383,8 @@ export class SessionStore extends Context.Service<
383 * before storing this session.383 * before storing this session.
384 */384 */
385 readonly replaceActiveForSubjectAndMethod?: boolean;385 readonly replaceActiveForSubjectAndMethod?: boolean;
386 /** Replace only this session, of the same method, in the issuance transaction. */
387 readonly replaceSessionId?: AuthSessionId;
386 }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;388 }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;
387 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;389 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
388 readonly issueWebSocketToken: (390 readonly issueWebSocketToken: (
...@@ -708,8 +710,14 @@ export const make = Effect.gen(function* () {...@@ -708,8 +710,14 @@ export const make = Effect.gen(function* () {
708 expiresAt,710 expiresAt,
709 } satisfies AuthSessions.CreateAuthSessionInput;711 } satisfies AuthSessions.CreateAuthSessionInput;
710 const replacedSessionIds = yield* (712 const replacedSessionIds = yield* (
711 input?.replaceActiveForSubjectAndMethod713 input?.replaceSessionId !== undefined || input?.replaceActiveForSubjectAndMethod
712 ? authSessions.createReplacingActive({ session: sessionRecord, revokedAt: issuedAt })714 ? authSessions.createReplacingActive({
715 session: sessionRecord,
716 revokedAt: issuedAt,
717 ...(input.replaceSessionId !== undefined
718 ? { replaceSessionId: input.replaceSessionId }
719 : {}),
720 })
713 : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray<AuthSessionId>))721 : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray<AuthSessionId>))
714 ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause })));722 ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause })));
715 if (replacedSessionIds.length > 0) {723 if (replacedSessionIds.length > 0) {
apps/server/src/auth/http.ts+9
...@@ -303,9 +303,18 @@ export const layer = HttpApiBuilder.group(...@@ -303,9 +303,18 @@ export const layer = HttpApiBuilder.group(
303 function* (args) {303 function* (args) {
304 yield* annotateEnvironmentRequest(args.endpoint.name);304 yield* annotateEnvironmentRequest(args.endpoint.name);
305 const request = yield* HttpServerRequest.HttpServerRequest;305 const request = yield* HttpServerRequest.HttpServerRequest;
306 const previousCredential = EnvironmentAuth.selectRequestCredential(
307 request,
308 sessions.cookieName,
309 sessions.legacyCookieName,
310 );
306 const result = yield* serverAuth.createBrowserSession(311 const result = yield* serverAuth.createBrowserSession(
307 args.payload.credential,312 args.payload.credential,
308 deriveAuthClientMetadata({ request }),313 deriveAuthClientMetadata({ request }),
314 previousCredential?.source === "cookie" ||
315 previousCredential?.source === "legacy-cookie"
316 ? previousCredential.token
317 : undefined,
309 );318 );
310 const cookieName = result.cookieName ?? sessions.cookieName;319 const cookieName = result.cookieName ?? sessions.cookieName;
311 const selectedCookie = yield* Effect.fromResult(320 const selectedCookie = yield* Effect.fromResult(
apps/server/src/cli/auth.ts+5-2
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 formatSessionList,19 formatSessionList,
20} from "../cliAuthFormat.ts";20} from "../cliAuthFormat.ts";
21import * as ServerConfig from "../config.ts";21import * as ServerConfig from "../config.ts";
22import { authScopesFlag } from "./authScopes.ts";
22import {23import {
23 authLocationFlags,24 authLocationFlags,
24 type CliAuthLocationFlags,25 type CliAuthLocationFlags,
...@@ -83,6 +84,7 @@ const tokenOnlyFlag = Flag.Boolean("token-only").pipe(...@@ -83,6 +84,7 @@ const tokenOnlyFlag = Flag.Boolean("token-only").pipe(
8384
84const pairingCreateCommand = Command.make("create", {85const pairingCreateCommand = Command.make("create", {
85 ...authLocationFlags,86 ...authLocationFlags,
87 scopes: authScopesFlag(AuthStandardClientScopes),
86 ttl: ttlFlag,88 ttl: ttlFlag,
87 label: labelFlag,89 label: labelFlag,
88 baseUrl: baseUrlFlag,90 baseUrl: baseUrlFlag,
...@@ -95,7 +97,7 @@ const pairingCreateCommand = Command.make("create", {...@@ -95,7 +97,7 @@ const pairingCreateCommand = Command.make("create", {
95 (environmentAuth) =>97 (environmentAuth) =>
96 Effect.gen(function* () {98 Effect.gen(function* () {
97 const issued = yield* environmentAuth.createPairingLink({99 const issued = yield* environmentAuth.createPairingLink({
98 scopes: AuthStandardClientScopes,100 scopes: flags.scopes,
99 subject: "one-time-token",101 subject: "one-time-token",
100 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),102 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
101 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),103 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
...@@ -161,6 +163,7 @@ const pairingCommand = Command.make("pairing").pipe(...@@ -161,6 +163,7 @@ const pairingCommand = Command.make("pairing").pipe(
161163
162const sessionIssueCommand = Command.make("issue", {164const sessionIssueCommand = Command.make("issue", {
163 ...authLocationFlags,165 ...authLocationFlags,
166 scopes: authScopesFlag(AuthAdministrativeScopes),
164 ttl: ttlFlag,167 ttl: ttlFlag,
165 label: labelFlag,168 label: labelFlag,
166 subject: subjectFlag,169 subject: subjectFlag,
...@@ -174,7 +177,7 @@ const sessionIssueCommand = Command.make("issue", {...@@ -174,7 +177,7 @@ const sessionIssueCommand = Command.make("issue", {
174 (environmentAuth) =>177 (environmentAuth) =>
175 Effect.gen(function* () {178 Effect.gen(function* () {
176 const issued = yield* environmentAuth.issueSession({179 const issued = yield* environmentAuth.issueSession({
177 scopes: AuthAdministrativeScopes,180 scopes: flags.scopes,
178 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),181 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
179 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),182 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
180 ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}),183 ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}),
apps/server/src/cli/authScopes.ts created+11
...@@ -0,0 +1,11 @@
1import { AuthEnvironmentScope } from "@t3tools/contracts";
2import { Flag } from "effect/cli";
3
4export const authScopesFlag = (defaults: ReadonlyArray<AuthEnvironmentScope>) =>
5 Flag.Literals("scope", AuthEnvironmentScope.literals).pipe(
6 Flag.withDescription(
7 `Authorization scope to grant. Repeat for multiple scopes; replaces the default set: ${defaults.join(", ")}.`,
8 ),
9 Flag.atLeast(0),
10 Flag.map((scopes) => [...new Set(scopes.length > 0 ? scopes : defaults)]),
11 );
apps/server/src/cli/pair.test.ts+114-2
...@@ -5,13 +5,14 @@ import * as NodeOS from "node:os";...@@ -5,13 +5,14 @@ import * as NodeOS from "node:os";
5import * as NodePath from "node:path";5import * as NodePath from "node:path";
66
7import * as NodeServices from "@effect/platform-node/NodeServices";7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { AuthStandardClientScopes } from "@t3tools/contracts";
8import * as NetService from "@t3tools/shared/Net";9import * as NetService from "@t3tools/shared/Net";
9import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";10import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
10import { assert, describe, expect, it } from "@effect/vitest";11import { assert, describe, expect, it } from "@effect/vitest";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
13import * as TestConsole from "effect/testing/TestConsole";14import * as TestConsole from "effect/testing/TestConsole";
14import { Command } from "effect/cli";15import { Command, CliError } from "effect/cli";
1516
16import { cli } from "../binCli.ts";17import { cli } from "../binCli.ts";
17import {18import {
...@@ -173,9 +174,13 @@ describe("t3 pair", () => {...@@ -173,9 +174,13 @@ describe("t3 pair", () => {
173 const listed = yield* captureStdout(174 const listed = yield* captureStdout(
174 runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),175 runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),
175 );176 );
176 const credentials = JSON.parse(listed) as ReadonlyArray<{ readonly label?: string }>;177 const credentials = JSON.parse(listed) as ReadonlyArray<{
178 readonly label?: string;
179 readonly scopes: ReadonlyArray<string>;
180 }>;
177 assert.equal(credentials.length, 1);181 assert.equal(credentials.length, 1);
178 assert.equal(credentials[0]?.label, "t3 pair");182 assert.equal(credentials[0]?.label, "t3 pair");
183 assert.deepEqual(credentials[0]?.scopes, AuthStandardClientScopes);
179 }),184 }),
180 ).pipe(185 ).pipe(
181 Effect.provide(NodeServices.layer),186 Effect.provide(NodeServices.layer),
...@@ -195,6 +200,43 @@ describe("t3 pair", () => {...@@ -195,6 +200,43 @@ describe("t3 pair", () => {
195 ),200 ),
196 );201 );
197202
203 it.effect("mints a pairing grant with only the selected scopes", () =>
204 withDescriptorServer((origin) =>
205 Effect.gen(function* () {
206 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-scopes-test-"));
207 yield* persistServerRuntimeState({
208 path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
209 state: yield* makePersistedServerRuntimeState({
210 config: { host: "127.0.0.1", devUrl: undefined },
211 port: Number(new URL(origin).port),
212 }),
213 });
214
215 yield* captureStdout(
216 runCli([
217 "pair",
218 "--base-dir",
219 baseDir,
220 "--scope",
221 "orchestration:read",
222 "--scope",
223 "relay:read",
224 "--scope",
225 "orchestration:read",
226 ]),
227 );
228 const listed = yield* captureStdout(
229 runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),
230 );
231 const credentials = JSON.parse(listed) as ReadonlyArray<{
232 readonly scopes: ReadonlyArray<string>;
233 }>;
234 assert.lengthOf(credentials, 1);
235 assert.deepEqual(credentials[0]?.scopes, ["orchestration:read", "relay:read"]);
236 }),
237 ).pipe(Effect.provide(NodeServices.layer)),
238 );
239
198 it.effect("pairs through the recorded dev web URL for dev servers", () =>240 it.effect("pairs through the recorded dev web URL for dev servers", () =>
199 withDescriptorServer((origin) =>241 withDescriptorServer((origin) =>
200 Effect.gen(function* () {242 Effect.gen(function* () {
...@@ -288,3 +330,73 @@ describe("t3 pair", () => {...@@ -288,3 +330,73 @@ describe("t3 pair", () => {
288 }).pipe(Effect.provide(NodeServices.layer)),330 }).pipe(Effect.provide(NodeServices.layer)),
289 );331 );
290});332});
333
334describe("auth scope options", () => {
335 it.effect.each([
336 { group: "pairing", action: "create" },
337 { group: "session", action: "issue" },
338 ] as const)(
339 "issues and persists only the selected scopes for auth $group $action",
340 ({ group, action }) =>
341 Effect.gen(function* () {
342 const baseDir = NodeFS.mkdtempSync(
343 NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-scopes-test-"),
344 );
345 const output = yield* captureStdout(
346 runCli([
347 "auth",
348 group,
349 action,
350 "--base-dir",
351 baseDir,
352 "--json",
353 "--scope",
354 "orchestration:read",
355 "--scope",
356 "access:read",
357 "--scope",
358 "orchestration:read",
359 ]),
360 );
361 const issued = JSON.parse(output) as { readonly scopes: ReadonlyArray<string> };
362 const listOutput = yield* captureStdout(
363 runCli(["auth", group, "list", "--base-dir", baseDir, "--json"]),
364 );
365 const listed = JSON.parse(listOutput) as ReadonlyArray<{
366 readonly scopes: ReadonlyArray<string>;
367 }>;
368
369 assert.deepEqual(issued.scopes, ["orchestration:read", "access:read"]);
370 assert.lengthOf(listed, 1);
371 assert.deepEqual(listed[0]?.scopes, issued.scopes);
372 }),
373 );
374
375 it.effect.each(
376 [["pair"], ["auth", "pairing", "create"], ["auth", "session", "issue"]].map((command) => ({
377 command,
378 label: command.join(" "),
379 })),
380 )("rejects invalid scopes before running $label", ({ command }) =>
381 Effect.gen(function* () {
382 const error = yield* runCli([
383 ...command,
384 "--scope",
385 "orchestration:read",
386 "--scope",
387 "admin",
388 ]).pipe(Effect.provide(layerCliRuntime), Effect.flip);
389
390 if (!CliError.isCliError(error) || error._tag !== "ShowHelp") {
391 assert.fail(`Expected ShowHelp, got ${String(error)}`);
392 }
393 assert.deepEqual(error.commandPath, ["t3", ...command]);
394 const scopeError = error.errors[0];
395 if (scopeError?._tag !== "InvalidValue") {
396 assert.fail(`Expected InvalidValue, got ${String(scopeError?._tag)}`);
397 }
398 assert.equal(scopeError.option, "scope");
399 assert.equal(scopeError.value, "admin");
400 }),
401 );
402});
apps/server/src/cli/pair.ts+11-2
...@@ -10,6 +10,7 @@...@@ -10,6 +10,7 @@
10 * HTTPS and pairs through the tailnet URL instead.10 * HTTPS and pairs through the tailnet URL instead.
11 */11 */
12import {12import {
13 type AuthEnvironmentScope,
13 AuthStandardClientScopes,14 AuthStandardClientScopes,
14 ExecutionEnvironmentDescriptor,15 ExecutionEnvironmentDescriptor,
15 PortSchema,16 PortSchema,
...@@ -51,6 +52,7 @@ import {...@@ -51,6 +52,7 @@ import {
51 renderTerminalQrCode,52 renderTerminalQrCode,
52 resolveHeadlessConnectionString,53 resolveHeadlessConnectionString,
53} from "../startupAccess.ts";54} from "../startupAccess.ts";
55import { authScopesFlag } from "./authScopes.ts";
54import { baseDirFlag, DurationFromString } from "./config.ts";56import { baseDirFlag, DurationFromString } from "./config.ts";
5557
56const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";58const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";
...@@ -426,13 +428,14 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase"...@@ -426,13 +428,14 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase"
426428
427const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: {429const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: {
428 readonly config: ServerConfig.ServerConfig["Service"];430 readonly config: ServerConfig.ServerConfig["Service"];
431 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
429 readonly ttl: Option.Option<Duration.Duration>;432 readonly ttl: Option.Option<Duration.Duration>;
430 readonly label: Option.Option<string>;433 readonly label: Option.Option<string>;
431}) {434}) {
432 return yield* Effect.gen(function* () {435 return yield* Effect.gen(function* () {
433 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;436 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
434 return yield* environmentAuth.createPairingLink({437 return yield* environmentAuth.createPairingLink({
435 scopes: AuthStandardClientScopes,438 scopes: input.scopes,
436 subject: "one-time-token",439 subject: "one-time-token",
437 label: Option.getOrElse(input.label, () => "t3 pair"),440 label: Option.getOrElse(input.label, () => "t3 pair"),
438 ...(Option.isSome(input.ttl) ? { ttl: input.ttl.value } : {}),441 ...(Option.isSome(input.ttl) ? { ttl: input.ttl.value } : {}),
...@@ -475,6 +478,7 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe(...@@ -475,6 +478,7 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe(
475478
476export const pairCommand = Command.make("pair", {479export const pairCommand = Command.make("pair", {
477 baseDir: baseDirFlag,480 baseDir: baseDirFlag,
481 scopes: authScopesFlag(AuthStandardClientScopes),
478 ttl: ttlFlag,482 ttl: ttlFlag,
479 label: labelFlag,483 label: labelFlag,
480 tailscale: tailscaleFlag,484 tailscale: tailscaleFlag,
...@@ -516,7 +520,12 @@ export const pairCommand = Command.make("pair", {...@@ -516,7 +520,12 @@ export const pairCommand = Command.make("pair", {
516 }520 }
517521
518 const config = yield* makePairServerConfig({ target, logLevel });522 const config = yield* makePairServerConfig({ target, logLevel });
519 const issued = yield* mintPairingLink({ config, ttl: flags.ttl, label: flags.label });523 const issued = yield* mintPairingLink({
524 config,
525 scopes: flags.scopes,
526 ttl: flags.ttl,
527 label: flags.label,
528 });
520 const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential);529 const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential);
521530
522 yield* Console.log(531 yield* Console.log(
apps/server/src/persistence/AuthPairingLinks.ts+9-1
...@@ -46,6 +46,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type;...@@ -46,6 +46,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type;
46export const ConsumeAuthPairingLinkInput = Schema.Struct({46export const ConsumeAuthPairingLinkInput = Schema.Struct({
47 credential: Schema.String,47 credential: Schema.String,
48 proofKeyThumbprint: Schema.NullOr(Schema.String),48 proofKeyThumbprint: Schema.NullOr(Schema.String),
49 requestedScopes: Schema.optionalKey(AuthEnvironmentScopes),
49 consumedAt: Schema.DateTimeUtcFromString,50 consumedAt: Schema.DateTimeUtcFromString,
50 now: Schema.DateTimeUtcFromString,51 now: Schema.DateTimeUtcFromString,
51});52});
...@@ -159,7 +160,7 @@ export const make = Effect.gen(function* () {...@@ -159,7 +160,7 @@ export const make = Effect.gen(function* () {
159 const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({160 const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({
160 Request: ConsumeAuthPairingLinkInput,161 Request: ConsumeAuthPairingLinkInput,
161 Result: AuthPairingLinkRawDbRow,162 Result: AuthPairingLinkRawDbRow,
162 execute: ({ credential, proofKeyThumbprint, consumedAt, now }) =>163 execute: ({ credential, proofKeyThumbprint, requestedScopes, consumedAt, now }) =>
163 sql`164 sql`
164 UPDATE auth_pairing_links165 UPDATE auth_pairing_links
165 SET consumed_at = ${consumedAt}166 SET consumed_at = ${consumedAt}
...@@ -171,6 +172,13 @@ export const make = Effect.gen(function* () {...@@ -171,6 +172,13 @@ export const make = Effect.gen(function* () {
171 proof_key_thumbprint IS NULL172 proof_key_thumbprint IS NULL
172 OR proof_key_thumbprint = ${proofKeyThumbprint}173 OR proof_key_thumbprint = ${proofKeyThumbprint}
173 )174 )
175 AND NOT EXISTS (
176 SELECT 1
177 FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested
178 WHERE requested.value NOT IN (
179 SELECT value FROM json_each(auth_pairing_links.scopes)
180 )
181 )
174 RETURNING182 RETURNING
175 id AS "id",183 id AS "id",
176 credential AS "credential",184 credential AS "credential",
apps/server/src/persistence/AuthSessions.ts+7-2
...@@ -58,6 +58,7 @@ export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type;...@@ -58,6 +58,7 @@ export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type;
58export const CreateReplacingActiveAuthSessionInput = Schema.Struct({58export const CreateReplacingActiveAuthSessionInput = Schema.Struct({
59 session: CreateAuthSessionInput,59 session: CreateAuthSessionInput,
60 revokedAt: Schema.DateTimeUtcFromString,60 revokedAt: Schema.DateTimeUtcFromString,
61 replaceSessionId: Schema.optionalKey(AuthSessionId),
61});62});
62export type CreateReplacingActiveAuthSessionInput =63export type CreateReplacingActiveAuthSessionInput =
63 typeof CreateReplacingActiveAuthSessionInput.Type;64 typeof CreateReplacingActiveAuthSessionInput.Type;
...@@ -276,11 +277,15 @@ export const make = Effect.gen(function* () {...@@ -276,11 +277,15 @@ export const make = Effect.gen(function* () {
276 const revokeActiveSessionsForReplacement = SqlSchema.findAll({277 const revokeActiveSessionsForReplacement = SqlSchema.findAll({
277 Request: CreateReplacingActiveAuthSessionInput,278 Request: CreateReplacingActiveAuthSessionInput,
278 Result: Schema.Struct({ sessionId: AuthSessionId }),279 Result: Schema.Struct({ sessionId: AuthSessionId }),
279 execute: ({ session, revokedAt }) =>280 execute: ({ session, revokedAt, replaceSessionId }) =>
280 sql`281 sql`
281 UPDATE auth_sessions282 UPDATE auth_sessions
282 SET revoked_at = ${revokedAt}283 SET revoked_at = ${revokedAt}
283 WHERE subject = ${session.subject}284 WHERE ${
285 replaceSessionId === undefined
286 ? sql`subject = ${session.subject}`
287 : sql`session_id = ${replaceSessionId}`
288 }
284 AND method = ${session.method}289 AND method = ${session.method}
285 AND revoked_at IS NULL290 AND revoked_at IS NULL
286 AND expires_at > ${revokedAt}291 AND expires_at > ${revokedAt}
apps/web/src/authBootstrap.test.ts+153-6
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 type AuthSessionState,5 type AuthSessionState,
6 type DesktopBridge,6 type DesktopBridge,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { createBrowserHistory } from "@tanstack/react-router";
8import * as DateTime from "effect/DateTime";9import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
10import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";11import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
...@@ -16,7 +17,8 @@ import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "./...@@ -16,7 +17,8 @@ import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "./
16type TestWindow = {17type TestWindow = {
17 location: URL;18 location: URL;
18 history: {19 history: {
19 replaceState: (_data: unknown, _unused: string, url: string) => void;20 state: unknown;
21 replaceState: (_data: unknown, _unused: string, url?: string) => void;
20 };22 };
21 desktopBridge?: DesktopBridge;23 desktopBridge?: DesktopBridge;
22};24};
...@@ -59,8 +61,12 @@ function installTestBrowser(url: string) {...@@ -59,8 +61,12 @@ function installTestBrowser(url: string) {
59 const testWindow: TestWindow = {61 const testWindow: TestWindow = {
60 location: new URL(url),62 location: new URL(url),
61 history: {63 history: {
62 replaceState: (_data, _unused, nextUrl) => {64 state: null,
63 testWindow.location = new URL(nextUrl, testWindow.location.href);65 replaceState: (data, _unused, nextUrl) => {
66 testWindow.history.state = data;
67 if (nextUrl !== undefined) {
68 testWindow.location = new URL(nextUrl, testWindow.location.href);
69 }
64 },70 },
65 },71 },
66 };72 };
...@@ -91,6 +97,14 @@ function sequence<A>(...values: ReadonlyArray<A>) {...@@ -91,6 +97,14 @@ function sequence<A>(...values: ReadonlyArray<A>) {
91 return () => values[Math.min(index++, values.length - 1)]!;97 return () => values[Math.min(index++, values.length - 1)]!;
92}98}
9399
100function createSignal() {
101 let resolve = () => {};
102 const promise = new Promise<void>((resolvePromise) => {
103 resolve = resolvePromise;
104 });
105 return { promise, resolve };
106}
107
94let disposeHttpTest: (() => Promise<void>) | undefined;108let disposeHttpTest: (() => Promise<void>) | undefined;
95109
96async function installAuthApi(input: {110async function installAuthApi(input: {
...@@ -286,6 +300,139 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -286,6 +300,139 @@ describe("resolveInitialServerAuthGateState", () => {
286 expect(testWindow.location.searchParams.get("token")).toBeNull();300 expect(testWindow.location.searchParams.get("token")).toBeNull();
287 });301 });
288302
303 it.each([
304 { suffix: "#token=replacement-token", cached: false },
305 { suffix: "#token=replacement-token", cached: true },
306 { suffix: "?token=replacement-token", cached: true },
307 { suffix: "/#token=replacement-token", cached: true },
308 ])(
309 "re-pairs an authenticated browser with $suffix when cached=$cached",
310 async ({ suffix, cached }) => {
311 let scopes: AuthBrowserSessionResult["scopes"] = ["orchestration:read"];
312 const testApi = await installAuthApi({
313 session: () => ({ ...authenticatedSession(LOOPBACK_AUTH), scopes }),
314 browserSession: () =>
315 Effect.sync(() => {
316 scopes = ["orchestration:read", "orchestration:operate"];
317 return browserSession(scopes);
318 }),
319 });
320 const testWindow = installTestBrowser("http://localhost/");
321 const {
322 resolveInitialServerAuthGateState,
323 submitServerAuthCredential,
324 takePairingTokenFromUrl,
325 } = await import("./environments/primary");
326 const { fetchSessionState } = await import("./environments/primary/auth");
327
328 if (cached) {
329 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
330 status: "authenticated",
331 });
332 }
333
334 testWindow.location = new URL(`http://localhost/pair${suffix}`);
335 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
336 status: "requires-auth",
337 auth: LOOPBACK_AUTH,
338 });
339 expect(testApi.calls.browserSession).toEqual([]);
340
341 const token = takePairingTokenFromUrl();
342 expect(token).toBe("replacement-token");
343 await submitServerAuthCredential(token!);
344
345 expect(testApi.calls.browserSession).toEqual([{ credential: "replacement-token" }]);
346 await expect(fetchSessionState()).resolves.toMatchObject({
347 authenticated: true,
348 scopes: ["orchestration:read", "orchestration:operate"],
349 });
350 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
351 status: "authenticated",
352 });
353 },
354 );
355
356 it.each([false, true])(
357 "keeps rejected replacement pairing open when cached=%s",
358 async (cached) => {
359 const existingSession = {
360 ...authenticatedSession(LOOPBACK_AUTH),
361 scopes: ["orchestration:read"] as const,
362 };
363 const exchangeStarted = createSignal();
364 const finishExchange = createSignal();
365 const testApi = await installAuthApi({
366 session: () => existingSession,
367 browserSession: () =>
368 Effect.gen(function* () {
369 exchangeStarted.resolve();
370 yield* Effect.promise(() => finishExchange.promise);
371 return yield* new EnvironmentAuthInvalidError({
372 code: "auth_invalid",
373 reason: "invalid_credential",
374 traceId: "trace-invalid-replacement",
375 });
376 }),
377 });
378 const testWindow = installTestBrowser("http://localhost/");
379 const {
380 resolveInitialServerAuthGateState,
381 submitServerAuthCredential,
382 takePairingTokenFromUrl,
383 } = await import("./environments/primary");
384 const { fetchSessionState } = await import("./environments/primary/auth");
385 if (cached) {
386 await resolveInitialServerAuthGateState();
387 }
388 testWindow.location = new URL("http://localhost/pair#token=invalid-replacement");
389 const history = createBrowserHistory({
390 window: {
391 get location() {
392 return testWindow.location;
393 },
394 history: testWindow.history,
395 addEventListener() {},
396 removeEventListener() {},
397 },
398 });
399 const gateLoads: Array<ReturnType<typeof resolveInitialServerAuthGateState>> = [];
400 const unsubscribe = history.subscribe(() => {
401 gateLoads.push(resolveInitialServerAuthGateState());
402 });
403 try {
404 const requiresAuth = { status: "requires-auth", auth: LOOPBACK_AUTH };
405 await expect(resolveInitialServerAuthGateState()).resolves.toEqual(requiresAuth);
406
407 // The pairing form strips the token before submitting. TanStack history
408 // reloads the auth gate synchronously when replaceState runs.
409 const token = takePairingTokenFromUrl();
410 expect(gateLoads).toHaveLength(1);
411 await expect(gateLoads[0]).resolves.toEqual(requiresAuth);
412 const rejected = expect(submitServerAuthCredential(token!)).rejects.toMatchObject({
413 _tag: "PrimaryEnvironmentPairingCredentialRejectedError",
414 message: "Invalid pairing token. Check the token and try again.",
415 });
416 await exchangeStarted.promise;
417 await expect(resolveInitialServerAuthGateState()).resolves.toEqual(requiresAuth);
418 finishExchange.resolve();
419 await rejected;
420 await expect(resolveInitialServerAuthGateState()).resolves.toEqual(requiresAuth);
421 expect(testApi.calls.browserSession).toEqual([{ credential: "invalid-replacement" }]);
422 await expect(fetchSessionState()).resolves.toEqual(existingSession);
423
424 testWindow.history.replaceState({}, "", "/");
425 await expect(gateLoads[1]).resolves.toEqual({ status: "authenticated" });
426 testWindow.history.replaceState({}, "", "/pair");
427 await expect(gateLoads[2]).resolves.toEqual({ status: "authenticated" });
428 } finally {
429 finishExchange.resolve();
430 unsubscribe();
431 history.destroy();
432 }
433 },
434 );
435
289 it("allows manual token submission after the initial auth check requires pairing", async () => {436 it("allows manual token submission after the initial auth check requires pairing", async () => {
290 const nextSession = sequence(437 const nextSession = sequence(
291 unauthenticatedSession(LOOPBACK_AUTH),438 unauthenticatedSession(LOOPBACK_AUTH),
...@@ -522,7 +669,7 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -522,7 +669,7 @@ describe("resolveInitialServerAuthGateState", () => {
522 expect(testWindow.location.hash).toBe("");669 expect(testWindow.location.hash).toBe("");
523 });670 });
524671
525 it("exchanges an explicit pair link after caching an authenticated state", async () => {672 it("exchanges a root URL token after caching an authenticated state", async () => {
526 const testApi = await installAuthApi({673 const testApi = await installAuthApi({
527 session: () => authenticatedSession(LOOPBACK_AUTH),674 session: () => authenticatedSession(LOOPBACK_AUTH),
528 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),675 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
...@@ -533,7 +680,7 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -533,7 +680,7 @@ describe("resolveInitialServerAuthGateState", () => {
533 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({680 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
534 status: "authenticated",681 status: "authenticated",
535 });682 });
536 testWindow.location = new URL("http://localhost/pair#token=reusable-token");683 testWindow.location = new URL("http://localhost/#token=reusable-token");
537684
538 await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]);685 await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]);
539686
...@@ -575,7 +722,7 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -575,7 +722,7 @@ describe("resolveInitialServerAuthGateState", () => {
575 const { resolveInitialServerAuthGateState } = await import("./environments/primary");722 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
576723
577 const initialBootstrap = resolveInitialServerAuthGateState();724 const initialBootstrap = resolveInitialServerAuthGateState();
578 testWindow.location = new URL("http://localhost/pair#token=reusable-token");725 testWindow.location = new URL("http://localhost/#token=reusable-token");
579 const explicitPairing = resolveInitialServerAuthGateState();726 const explicitPairing = resolveInitialServerAuthGateState();
580 const laterCaller = resolveInitialServerAuthGateState();727 const laterCaller = resolveInitialServerAuthGateState();
581 let laterCallerSettled = false;728 let laterCallerSettled = false;
apps/web/src/connection/platform.ts+2-1
...@@ -228,7 +228,6 @@ const layerCapabilities = Layer.effectContext(...@@ -228,7 +228,6 @@ const layerCapabilities = Layer.effectContext(
228 Effect.sync(() => {228 Effect.sync(() => {
229 const presentation = ClientCapabilities.ClientPresentation.of({229 const presentation = ClientCapabilities.ClientPresentation.of({
230 metadata: clientMetadata(),230 metadata: clientMetadata(),
231 scopes: AuthStandardClientScopes,
232 });231 });
233 const cloudSession = ClientCapabilities.CloudSession.of({232 const cloudSession = ClientCapabilities.CloudSession.of({
234 identity: Effect.sync(() =>233 identity: Effect.sync(() =>
...@@ -383,6 +382,8 @@ const loadSecondaryConnectionRegistration = Effect.fn(...@@ -383,6 +382,8 @@ const loadSecondaryConnectionRegistration = Effect.fn(
383 Effect.mapError(mapRemoteEnvironmentError),382 Effect.mapError(mapRemoteEnvironmentError),
384 );383 );
385 const issuedAtEpochMs = yield* Clock.currentTimeMillis;384 const issuedAtEpochMs = yield* Clock.currentTimeMillis;
385 // The desktop seed grant is administrative so the primary window can manage
386 // access; a secondary backend session only needs to operate its environment.
386 const access = yield* bootstrapRemoteBearerSession({387 const access = yield* bootstrapRemoteBearerSession({
387 httpBaseUrl,388 httpBaseUrl,
388 credential: entry.bootstrapToken,389 credential: entry.bootstrapToken,
apps/web/src/environments/primary/auth.ts+16
...@@ -140,6 +140,7 @@ type ServerAuthGateState =...@@ -140,6 +140,7 @@ type ServerAuthGateState =
140140
141let bootstrapPromise: Promise<ServerAuthGateState> | null = null;141let bootstrapPromise: Promise<ServerAuthGateState> | null = null;
142let resolvedAuthenticatedGateState: ServerAuthGateState | null = null;142let resolvedAuthenticatedGateState: ServerAuthGateState | null = null;
143let explicitPairingRequested = false;
143const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000;144const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000;
144const AUTH_SESSION_ESTABLISH_STEP_MS = 100;145const AUTH_SESSION_ESTABLISH_STEP_MS = 100;
145146
...@@ -347,6 +348,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo...@@ -347,6 +348,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo
347 await waitForAuthenticatedSessionAfterBootstrap();348 await waitForAuthenticatedSessionAfterBootstrap();
348 resolvedAuthenticatedGateState = { status: "authenticated" };349 resolvedAuthenticatedGateState = { status: "authenticated" };
349 bootstrapPromise = null;350 bootstrapPromise = null;
351 explicitPairingRequested = false;
350 stripPairingTokenFromUrl();352 stripPairingTokenFromUrl();
351}353}
352354
...@@ -428,6 +430,19 @@ export async function revokeOtherServerClientSessions(): Promise<number> {...@@ -428,6 +430,19 @@ export async function revokeOtherServerClientSessions(): Promise<number> {
428}430}
429431
430export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {432export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {
433 // An explicit pairing link replaces this browser's grant, even when the
434 // current cookie or a cached gate already authenticates it. Keep that intent
435 // after stripping the token, which causes the router to load this gate again.
436 if (window.location.pathname.replace(/\/+$/, "") !== "/pair") {
437 explicitPairingRequested = false;
438 } else if (peekPairingTokenFromUrl()) {
439 explicitPairingRequested = true;
440 }
441 if (explicitPairingRequested) {
442 const currentSession = await fetchSessionState();
443 return { status: "requires-auth", auth: currentSession.auth };
444 }
445
431 const urlCredential = takePairingTokenFromUrl();446 const urlCredential = takePairingTokenFromUrl();
432 const previousPromise = bootstrapPromise;447 const previousPromise = bootstrapPromise;
433 if (urlCredential) {448 if (urlCredential) {
...@@ -468,4 +483,5 @@ export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGat...@@ -468,4 +483,5 @@ export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGat
468export function __resetServerAuthBootstrapForTests() {483export function __resetServerAuthBootstrapForTests() {
469 bootstrapPromise = null;484 bootstrapPromise = null;
470 resolvedAuthenticatedGateState = null;485 resolvedAuthenticatedGateState = null;
486 explicitPairingRequested = false;
471}487}
apps/web/src/routes/pair.tsx+7-3
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";1import { createFileRoute, redirect, useRouter } from "@tanstack/react-router";
22
3import {3import {
4 HostedPairingRouteSurface,4 HostedPairingRouteSurface,
...@@ -27,8 +27,8 @@ export const Route = createFileRoute("/pair")({...@@ -27,8 +27,8 @@ export const Route = createFileRoute("/pair")({
27});27});
2828
29function PairRouteView() {29function PairRouteView() {
30 const router = useRouter();
30 const { authGateState } = Route.useRouteContext();31 const { authGateState } = Route.useRouteContext();
31 const navigate = useNavigate();
3232
33 if (!authGateState) {33 if (!authGateState) {
34 return null;34 return null;
...@@ -42,7 +42,11 @@ function PairRouteView() {...@@ -42,7 +42,11 @@ function PairRouteView() {
42 <PairingRouteSurface42 <PairingRouteSurface
43 auth={authGateState.auth}43 auth={authGateState.auth}
44 onAuthenticated={() => {44 onAuthenticated={() => {
45 void navigate({ to: "/", replace: true });45 // Recreate the primary connection so its WebSocket and cached scopes
46 // use the newly issued cookie after re-pairing.
47 router.history.replace("/");
48 router.history.flush();
49 window.location.reload();
46 }}50 }}
47 {...(authGateState.errorMessage ? { initialErrorMessage: authGateState.errorMessage } : {})}51 {...(authGateState.errorMessage ? { initialErrorMessage: authGateState.errorMessage } : {})}
48 />52 />
docs/user/remote-access.md+19
...@@ -258,6 +258,25 @@ management is available through `t3 auth --help`....@@ -258,6 +258,25 @@ management is available through `t3 auth --help`.
258A session with an open connection stays listed after its access credential258A session with an open connection stays listed after its access credential
259expires.259expires.
260260
261To choose a token's permissions, pass `--scope` once for each scope you want:
262
263```sh
264npx t3 pair --scope orchestration:read --scope relay:read
265```
266
267The selected scopes replace the default permissions. The same option works with
268`npx t3 auth pairing create` and `npx t3 auth session issue`; each command's
269`--help` lists the available scopes. Without `--scope`, pairing tokens retain
270standard client permissions and issued bearer sessions retain administrative
271permissions.
272
273To change an existing client's permissions, create a fresh pairing link with the
274scopes it needs. In a browser opened directly on the environment, open that link
275to replace the browser's current grant. For mobile or a saved remote environment
276in web or desktop, use **Add Environment** with the fresh link or code; pairing
277the same environment replaces its saved grant. Reconnecting alone does not change
278permissions.
279
261To remove an environment from T3 Connect, open your account menu's **T3 Connect**280To remove an environment from T3 Connect, open your account menu's **T3 Connect**
262page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This281page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
263revokes its cloud access and frees its host space even when the environment is282revokes its cloud access and frees its host space even when the environment is
packages/client-runtime/src/authorization/layer.test.ts+88-7
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";1import {
2 AuthAdministrativeScopes,
3 AuthStandardClientScopes,
4 EnvironmentId,
5} from "@t3tools/contracts";
2import {6import {
3 RelayEnvironmentConnectScope,7 RelayEnvironmentConnectScope,
4 type RelayEnvironmentConnectResponse,8 type RelayEnvironmentConnectResponse,
...@@ -46,7 +50,9 @@ const BOOTSTRAP: RelayEnvironmentConnectResponse = {...@@ -46,7 +50,9 @@ const BOOTSTRAP: RelayEnvironmentConnectResponse = {
46 expiresAt: "2026-06-06T01:00:00.000Z",50 expiresAt: "2026-06-06T01:00:00.000Z",
47};51};
4852
49function recordedFetch(responses: ReadonlyArray<Response>) {53type RecordedResponse = Response | ((init: RequestInit) => Response);
54
55function recordedFetch(responses: ReadonlyArray<RecordedResponse>) {
50 const calls: Array<readonly [RequestInfo | URL, RequestInit]> = [];56 const calls: Array<readonly [RequestInfo | URL, RequestInit]> = [];
51 let responseIndex = 0;57 let responseIndex = 0;
52 const fetchFn = ((input, init) => {58 const fetchFn = ((input, init) => {
...@@ -54,7 +60,7 @@ function recordedFetch(responses: ReadonlyArray<Response>) {...@@ -54,7 +60,7 @@ function recordedFetch(responses: ReadonlyArray<Response>) {
54 const response = responses[responseIndex++];60 const response = responses[responseIndex++];
55 return response === undefined61 return response === undefined
56 ? Promise.reject(new Error(`Unexpected fetch call to ${String(input)}`))62 ? Promise.reject(new Error(`Unexpected fetch call to ${String(input)}`))
57 : Promise.resolve(response);63 : Promise.resolve(typeof response === "function" ? response(init ?? {}) : response);
58 }) satisfies typeof fetch;64 }) satisfies typeof fetch;
59 return { calls, fetchFn };65 return { calls, fetchFn };
60}66}
...@@ -65,13 +71,13 @@ const websocketTicket = (ticket: string) =>...@@ -65,13 +71,13 @@ const websocketTicket = (ticket: string) =>
65 expiresAt: "2026-06-06T01:00:00.000Z",71 expiresAt: "2026-06-06T01:00:00.000Z",
66 });72 });
6773
68const accessToken = (token: string) =>74const accessToken = (token: string, scope = AuthStandardClientScopes.join(" ")) =>
69 Response.json({75 Response.json({
70 access_token: token,76 access_token: token,
71 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",77 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
72 token_type: "DPoP",78 token_type: "DPoP",
73 expires_in: 3_600,79 expires_in: 3_600,
74 scope: AuthStandardClientScopes.join(" "),80 scope,
75 });81 });
7682
77const authInvalid = () =>83const authInvalid = () =>
...@@ -105,7 +111,7 @@ const persistedToken = (...@@ -105,7 +111,7 @@ const persistedToken = (
105111
106const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: {112const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: {
107 readonly initialToken?: TokenStore.RemoteDpopAccessToken;113 readonly initialToken?: TokenStore.RemoteDpopAccessToken;
108 readonly responses: ReadonlyArray<Response>;114 readonly responses: ReadonlyArray<RecordedResponse>;
109 readonly bootstrap?: RelayEnvironmentConnectResponse;115 readonly bootstrap?: RelayEnvironmentConnectResponse;
110 readonly beforeBootstrap?: Effect.Effect<void, ManagedRelay.ManagedRelayClientError>;116 readonly beforeBootstrap?: Effect.Effect<void, ManagedRelay.ManagedRelayClientError>;
111 readonly beforePut?: Effect.Effect<void>;117 readonly beforePut?: Effect.Effect<void>;
...@@ -210,7 +216,6 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (...@@ -210,7 +216,6 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
210 deviceType: "mobile",216 deviceType: "mobile",
211 os: "test",217 os: "test",
212 },218 },
213 scopes: AuthStandardClientScopes,
214 }),219 }),
215 ),220 ),
216 ),221 ),
...@@ -490,6 +495,82 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -490,6 +495,82 @@ describe("RemoteEnvironmentAuthorization", () => {
490 }),495 }),
491 );496 );
492497
498 it.effect.each([
499 { name: "read-only", grantScopes: ["orchestration:read"] },
500 { name: "administrative", grantScopes: AuthAdministrativeScopes },
501 ] as const)(
502 "inherits $name pairing grant scopes for websocket authorization and HTTP renewal",
503 ({ grantScopes }) =>
504 Effect.gen(function* () {
505 const grantedScope = grantScopes.join(" ");
506 let exchangeCount = 0;
507 const tokenFields = (init: RequestInit) =>
508 new URLSearchParams(
509 init.body instanceof Uint8Array
510 ? new TextDecoder().decode(init.body)
511 : String(init.body),
512 );
513 const exchangeGrant = (init: RequestInit) => {
514 const fields = tokenFields(init);
515 expect(fields.get("scope")).toBeNull();
516 const scope = fields.get("scope") ?? grantedScope;
517 const scopes = scope.split(" ");
518 if (scopes.some((requested) => !grantScopes.some((grant) => grant === requested))) {
519 return authInvalid();
520 }
521 return accessToken(`access-token:${++exchangeCount}:${scopes.join(",")}`, scope);
522 };
523 const harness = yield* makeHarness({
524 responses: [
525 Response.json(DESCRIPTOR),
526 exchangeGrant,
527 websocketTicket("granted-ticket"),
528 Response.json(DESCRIPTOR),
529 exchangeGrant,
530 ],
531 });
532
533 const [authorized, refreshed] = yield* Effect.gen(function* () {
534 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
535 const first = yield* remote.authorizeDpop({
536 expectedEnvironmentId: ENVIRONMENT_ID,
537 });
538 yield* TestClock.adjust("1 hour");
539 const refreshed = yield* remote.authorizeDpopHttp({
540 expectedEnvironmentId: ENVIRONMENT_ID,
541 });
542 return [first, refreshed] as const;
543 }).pipe(Effect.provide(Layer.merge(harness.layer, TestClock.layer())));
544
545 expect(authorized.socketUrl).toContain("wsTicket=granted-ticket");
546 expect(authorized.httpAuthorization).toMatchObject({
547 _tag: "Dpop",
548 accessToken: `access-token:1:${grantScopes.join(",")}`,
549 });
550 expect(refreshed.httpAuthorization).toMatchObject({
551 _tag: "Dpop",
552 accessToken: `access-token:2:${grantScopes.join(",")}`,
553 });
554 expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toMatchObject({
555 accessToken: `access-token:2:${grantScopes.join(",")}`,
556 dpopThumbprint: "thumbprint-1",
557 });
558 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(2);
559 const exchanges = harness.fetch.calls.filter(([url]) =>
560 String(url).endsWith("/oauth/token"),
561 );
562 expect(exchanges).toHaveLength(2);
563 for (const [, init] of exchanges) {
564 expect(Object.fromEntries(tokenFields(init))).toMatchObject({
565 subject_token: BOOTSTRAP.credential,
566 client_label: "T3 Code Test",
567 client_device_type: "mobile",
568 client_os: "test",
569 });
570 }
571 }),
572 );
573
493 it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () =>574 it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () =>
494 Effect.gen(function* () {575 Effect.gen(function* () {
495 const cached = new TokenStore.RemoteDpopAccessToken({576 const cached = new TokenStore.RemoteDpopAccessToken({
packages/client-runtime/src/authorization/service.ts-1
...@@ -341,7 +341,6 @@ export const make = Effect.gen(function* () {...@@ -341,7 +341,6 @@ export const make = Effect.gen(function* () {
341 httpBaseUrl: bootstrap.endpoint.httpBaseUrl,341 httpBaseUrl: bootstrap.endpoint.httpBaseUrl,
342 credential: bootstrap.credential,342 credential: bootstrap.credential,
343 dpopProof: bootstrapProof,343 dpopProof: bootstrapProof,
344 scopes: presentation.scopes,
345 clientMetadata: presentation.metadata,344 clientMetadata: presentation.metadata,
346 }).pipe(345 }).pipe(
347 Effect.mapError(mapRemoteDpopEnvironmentError),346 Effect.mapError(mapRemoteDpopEnvironmentError),
packages/client-runtime/src/connection/onboarding.test.ts+64-3
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import {1import {
2 AuthAdministrativeScopes,
2 AuthStandardClientScopes,3 AuthStandardClientScopes,
3 EnvironmentId,4 EnvironmentId,
5 type AuthEnvironmentScope,
4 ORCHESTRATION_PROTOCOL_VERSION,6 ORCHESTRATION_PROTOCOL_VERSION,
5} from "@t3tools/contracts";7} from "@t3tools/contracts";
6import { describe, expect, it } from "@effect/vitest";8import { describe, expect, it } from "@effect/vitest";
...@@ -10,6 +12,7 @@ import * as Option from "effect/Option";...@@ -10,6 +12,7 @@ import * as Option from "effect/Option";
1012
11import * as RpcHttp from "../rpc/http.ts";13import * as RpcHttp from "../rpc/http.ts";
12import * as ClientCapabilities from "../platform/capabilities.ts";14import * as ClientCapabilities from "../platform/capabilities.ts";
15import { fetchRemoteSessionState } from "../authorization/remote.ts";
13import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts";16import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts";
14import { BearerConnectionTarget } from "./model.ts";17import { BearerConnectionTarget } from "./model.ts";
15import {18import {
...@@ -26,7 +29,6 @@ const layerClientPresentation = Layer.succeed(...@@ -26,7 +29,6 @@ const layerClientPresentation = Layer.succeed(
26 deviceType: "desktop",29 deviceType: "desktop",
27 os: "Test OS",30 os: "Test OS",
28 },31 },
29 scopes: AuthStandardClientScopes,
30 }),32 }),
31);33);
3234
...@@ -36,8 +38,11 @@ function layerPairingHttp(...@@ -36,8 +38,11 @@ function layerPairingHttp(
36 readonly failDescriptor?: boolean;38 readonly failDescriptor?: boolean;
37 readonly protocolVersion?: number;39 readonly protocolVersion?: number;
38 readonly selfUpdate?: boolean;40 readonly selfUpdate?: boolean;
41 readonly grantScopes?: ReadonlyArray<AuthEnvironmentScope>;
39 },42 },
40) {43) {
44 const grantScopes = options?.grantScopes ?? AuthStandardClientScopes;
45 let sessionScopes: ReadonlyArray<string> = [];
41 const fetchFn = ((input, init = {}) => {46 const fetchFn = ((input, init = {}) => {
42 const url = String(input);47 const url = String(input);
43 calls.push({ url, init });48 calls.push({ url, init });
...@@ -67,13 +72,46 @@ function layerPairingHttp(...@@ -67,13 +72,46 @@ function layerPairingHttp(
67 }72 }
6873
69 if (url.endsWith("/oauth/token")) {74 if (url.endsWith("/oauth/token")) {
75 const body =
76 init.body instanceof Uint8Array ? new TextDecoder().decode(init.body) : String(init.body);
77 const requestedScope = new URLSearchParams(body).get("scope");
78 sessionScopes = requestedScope === null ? grantScopes : requestedScope.split(" ");
79 if (!sessionScopes.every((scope) => grantScopes.some((granted) => granted === scope))) {
80 return Promise.resolve(
81 Response.json(
82 {
83 _tag: "EnvironmentRequestInvalidError",
84 code: "invalid_request",
85 reason: "scope_not_granted",
86 traceId: "pairing-scope-test",
87 },
88 { status: 400 },
89 ),
90 );
91 }
70 return Promise.resolve(92 return Promise.resolve(
71 Response.json({93 Response.json({
72 access_token: "bearer-token",94 access_token: "bearer-token",
73 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",95 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
74 token_type: "Bearer",96 token_type: "Bearer",
75 expires_in: 3600,97 expires_in: 3600,
76 scope: AuthStandardClientScopes.join(" "),98 scope: sessionScopes.join(" "),
99 }),
100 );
101 }
102
103 if (url.endsWith("/api/auth/session")) {
104 return Promise.resolve(
105 Response.json({
106 authenticated: true,
107 auth: {
108 policy: "remote-reachable",
109 bootstrapMethods: ["one-time-token"],
110 sessionMethods: ["bearer-access-token"],
111 sessionCookieName: "t3_session",
112 },
113 scopes: sessionScopes,
114 sessionMethod: "bearer-access-token",
77 }),115 }),
78 );116 );
79 }117 }
...@@ -123,8 +161,10 @@ describe("connection onboarding", () => {...@@ -123,8 +161,10 @@ describe("connection onboarding", () => {
123 : String(tokenRequest?.init.body);161 : String(tokenRequest?.init.body);
124 const tokenParams = new URLSearchParams(tokenBody);162 const tokenParams = new URLSearchParams(tokenBody);
125 expect(tokenParams.get("subject_token")).toBe("pairing-token");163 expect(tokenParams.get("subject_token")).toBe("pairing-token");
126 expect(tokenParams.get("scope")).toBe(AuthStandardClientScopes.join(" "));164 expect(tokenParams.has("scope")).toBe(false);
127 expect(tokenParams.get("client_label")).toBe("T3 Code Test");165 expect(tokenParams.get("client_label")).toBe("T3 Code Test");
166 expect(tokenParams.get("client_device_type")).toBe("desktop");
167 expect(tokenParams.get("client_os")).toBe("Test OS");
128 }),168 }),
129 );169 );
130170
...@@ -149,6 +189,27 @@ describe("connection onboarding", () => {...@@ -149,6 +189,27 @@ describe("connection onboarding", () => {
149 ]);189 ]);
150 }),190 }),
151 );191 );
192 it.effect.each([
193 { label: "read-only", scopes: ["orchestration:read"] },
194 { label: "administrative", scopes: AuthAdministrativeScopes },
195 ] as const)("preserves the $label grant when pairing a remote environment", ({ scopes }) =>
196 Effect.gen(function* () {
197 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
198 const httpLayer = layerPairingHttp(calls, { grantScopes: scopes });
199 const registration = yield* preparePairingRegistration({
200 host: "remote.example.test",
201 pairingCode: "pairing-token",
202 }).pipe(Effect.provide(Layer.mergeAll(layerClientPresentation, httpLayer)));
203
204 const session = yield* fetchRemoteSessionState({
205 httpBaseUrl: registration.profile.httpBaseUrl,
206 bearerToken: registration.credential.token,
207 }).pipe(Effect.provide(httpLayer));
208
209 expect(session.authenticated).toBe(true);
210 expect(session.scopes).toEqual(scopes);
211 }),
212 );
152213
153 it.effect("refuses to add a route that reaches a different machine, keeping the code", () =>214 it.effect("refuses to add a route that reaches a different machine, keeping the code", () =>
154 Effect.gen(function* () {215 Effect.gen(function* () {
packages/client-runtime/src/connection/onboarding.ts-1
...@@ -131,7 +131,6 @@ export const preparePairingRegistration = Effect.fn(...@@ -131,7 +131,6 @@ export const preparePairingRegistration = Effect.fn(
131 const access = yield* bootstrapRemoteBearerSession({131 const access = yield* bootstrapRemoteBearerSession({
132 httpBaseUrl: target.httpBaseUrl,132 httpBaseUrl: target.httpBaseUrl,
133 credential: target.credential,133 credential: target.credential,
134 scopes: presentation.scopes,
135 clientMetadata: presentation.metadata,134 clientMetadata: presentation.metadata,
136 }).pipe(Effect.mapError(mapRemoteEnvironmentError));135 }).pipe(Effect.mapError(mapRemoteEnvironmentError));
137 const connectionId = bearerConnectionId(descriptor.environmentId, target.httpBaseUrl);136 const connectionId = bearerConnectionId(descriptor.environmentId, target.httpBaseUrl);
packages/client-runtime/src/connection/registry.test.ts+82-1
...@@ -382,10 +382,17 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -382,10 +382,17 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
382 const connectRoute = (entry: ConnectionCatalogEntry, route: ConnectionRoute) =>382 const connectRoute = (entry: ConnectionCatalogEntry, route: ConnectionRoute) =>
383 Effect.gen(function* () {383 Effect.gen(function* () {
384 const target = route.target;384 const target = route.target;
385 const prepared = {385 const credential =
386 target._tag === "BearerConnectionTarget"
387 ? yield* credentialStore.get(target.connectionId)
388 : Option.none();
389 const prepared: PreparedConnection = {
386 ...PREPARED,390 ...PREPARED,
387 environmentId: target.environmentId,391 environmentId: target.environmentId,
388 label: target.label,392 label: target.label,
393 httpAuthorization: Option.isSome(credential)
394 ? { _tag: "Bearer", token: credential.value.token }
395 : null,
389 target,396 target,
390 };397 };
391 if (options?.prepareError) return yield* options.prepareError;398 if (options?.prepareError) return yield* options.prepareError;
...@@ -1201,6 +1208,80 @@ describe("EnvironmentRegistry", () => {...@@ -1201,6 +1208,80 @@ describe("EnvironmentRegistry", () => {
1201 }),1208 }),
1202 );1209 );
12031210
1211 it.effect("moves prepared credential observers when re-pairing an unchanged environment", () =>
1212 Effect.gen(function* () {
1213 const replacementCredential = new BearerConnectionCredential({ token: "replacement-token" });
1214 const harness = yield* makeHarness(
1215 [BEARER_TARGET],
1216 [BEARER_PROFILE],
1217 [[BEARER_TARGET.connectionId, BEARER_CREDENTIAL]],
1218 );
1219
1220 yield* Effect.gen(function* () {
1221 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1222 const initialObserved = yield* Deferred.make<void>();
1223 const replacementObserved = yield* Deferred.make<void>();
1224 const tokens = yield* Ref.make<ReadonlyArray<string>>([]);
1225 const followedSupervisors = yield* Ref.make(0);
1226 yield* registry.start;
1227
1228 const subscription = yield* Effect.forkChild(
1229 registry
1230 .followStream(
1231 BEARER_TARGET.environmentId,
1232 Stream.unwrap(
1233 Effect.gen(function* () {
1234 const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
1235 yield* Ref.update(followedSupervisors, (count) => count + 1);
1236 return SubscriptionRef.changes(supervisor.prepared);
1237 }),
1238 ),
1239 )
1240 .pipe(
1241 Stream.filterMap((prepared) =>
1242 Option.isSome(prepared) && prepared.value.httpAuthorization?._tag === "Bearer"
1243 ? Result.succeed(prepared.value.httpAuthorization.token)
1244 : Result.failVoid,
1245 ),
1246 Stream.changes,
1247 Stream.runForEach((token) =>
1248 Effect.gen(function* () {
1249 yield* Ref.update(tokens, (current) => [...current, token]);
1250 yield* Deferred.succeed(
1251 token === replacementCredential.token ? replacementObserved : initialObserved,
1252 undefined,
1253 );
1254 }),
1255 ),
1256 ),
1257 );
1258
1259 yield* Deferred.await(initialObserved);
1260 yield* registry.register(new RelayConnectionRegistration({ target: RELAY_TARGET }));
1261 yield* awaitConnectionState(
1262 registry,
1263 RELAY_TARGET.environmentId,
1264 (state) => state.phase === "connected",
1265 );
1266 yield* registry.register(
1267 new BearerConnectionRegistration({
1268 target: new BearerConnectionTarget({ ...BEARER_TARGET }),
1269 profile: new BearerConnectionProfile({ ...BEARER_PROFILE }),
1270 credential: replacementCredential,
1271 }),
1272 );
1273 yield* Deferred.await(replacementObserved);
1274 yield* Fiber.interrupt(subscription);
1275
1276 expect(yield* Ref.get(tokens)).toEqual([
1277 BEARER_CREDENTIAL.token,
1278 replacementCredential.token,
1279 ]);
1280 expect(yield* Ref.get(followedSupervisors)).toBe(2);
1281 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1282 }),
1283 );
1284
1204 it.effect("ignores retry signals for environments that are no longer registered", () =>1285 it.effect("ignores retry signals for environments that are no longer registered", () =>
1205 Effect.gen(function* () {1286 Effect.gen(function* () {
1206 const harness = yield* makeHarness([]);1287 const harness = yield* makeHarness([]);
packages/client-runtime/src/connection/registry.ts+4-1
...@@ -452,7 +452,10 @@ export const make = Effect.gen(function* () {...@@ -452,7 +452,10 @@ export const make = Effect.gen(function* () {
452 SubscriptionRef.changes(entries),452 SubscriptionRef.changes(entries),
453 ).pipe(453 ).pipe(
454 Stream.map((current) => Option.fromUndefinedOr(current.get(environmentId))),454 Stream.map((current) => Option.fromUndefinedOr(current.get(environmentId))),
455 Stream.changes,455 // Re-pairing can replace the supervisor while its catalog details stay unchanged.
456 Stream.changesWith(
457 (previous, current) => Option.getOrNull(previous) === Option.getOrNull(current),
458 ),
456 Stream.switchMap(459 Stream.switchMap(
457 Option.match({460 Option.match({
458 onNone: () => Stream.empty,461 onNone: () => Stream.empty,
packages/client-runtime/src/connection/resolver.test.ts-1
...@@ -176,7 +176,6 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o...@@ -176,7 +176,6 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
176 ClientCapabilities.ClientPresentation,176 ClientCapabilities.ClientPresentation,
177 ClientCapabilities.ClientPresentation.of({177 ClientCapabilities.ClientPresentation.of({
178 metadata: { label: "Test Client", deviceType: "desktop", surface: "web" },178 metadata: { label: "Test Client", deviceType: "desktop", surface: "web" },
179 scopes: [],
180 }),179 }),
181 ),180 ),
182 Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote),181 Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote),
packages/client-runtime/src/connection/supervisor.test.ts-1
...@@ -1572,7 +1572,6 @@ describe("EnvironmentSupervisor", () => {...@@ -1572,7 +1572,6 @@ describe("EnvironmentSupervisor", () => {
1572 }),1572 }),
1573 Layer.succeed(ClientCapabilities.ClientPresentation, {1573 Layer.succeed(ClientCapabilities.ClientPresentation, {
1574 metadata: { label: "Test client", deviceType: "desktop" },1574 metadata: { label: "Test client", deviceType: "desktop" },
1575 scopes: AuthStandardClientScopes,
1576 }),1575 }),
1577 ),1576 ),
1578 ),1577 ),
packages/client-runtime/src/platform/capabilities.ts-2
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import {1import {
2 type AuthClientPresentationMetadata,2 type AuthClientPresentationMetadata,
3 type AuthEnvironmentScope,
4 type DesktopSshEnvironmentBootstrap,3 type DesktopSshEnvironmentBootstrap,
5 type DesktopSshEnvironmentTarget,4 type DesktopSshEnvironmentTarget,
6 EnvironmentId,5 EnvironmentId,
...@@ -45,7 +44,6 @@ export class ClientPresentation extends Context.Service<...@@ -45,7 +44,6 @@ export class ClientPresentation extends Context.Service<
45 ClientPresentation,44 ClientPresentation,
46 {45 {
47 readonly metadata: AuthClientPresentationMetadata;46 readonly metadata: AuthClientPresentationMetadata;
48 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
49 }47 }
50>()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {}48>()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {}
5149