From bc024db43f500ec97e5513417818de67bc4a99e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:35:30 -0700 Subject: [PATCH 1/7] fix(auth): preserve custom scopes through client pairing --- apps/mobile/src/connection/platform.ts | 2 - apps/server/src/auth/EnvironmentAuth.test.ts | 50 +++++++++- apps/server/src/auth/EnvironmentAuth.ts | 14 ++- .../server/src/auth/PairingGrantStore.test.ts | 16 +++- apps/server/src/auth/PairingGrantStore.ts | 31 +++++- apps/server/src/bin.test.ts | 74 ++++++++++++++ apps/server/src/cli/auth.ts | 7 +- apps/server/src/cli/authScopes.ts | 11 +++ apps/server/src/cli/pair.test.ts | 45 ++++++++- apps/server/src/cli/pair.ts | 13 ++- .../src/persistence/AuthPairingLinks.ts | 10 +- apps/web/src/connection/platform.ts | 3 - docs/user/remote-access.md | 12 +++ .../src/authorization/layer.test.ts | 96 +++++++++++++++++-- .../src/authorization/service.ts | 1 - .../src/connection/onboarding.test.ts | 81 +++++++++++++++- .../src/connection/onboarding.ts | 1 - .../src/connection/resolver.test.ts | 1 - .../src/platform/capabilities.ts | 2 - 19 files changed, 434 insertions(+), 36 deletions(-) create mode 100644 apps/server/src/cli/authScopes.ts diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index f7fa30c8f230..18bbe578d224 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -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"; @@ -171,7 +170,6 @@ const capabilitiesLayer = Layer.effectContext( ClientPresentation, ClientPresentation.of({ metadata: authClientMetadata(Constants.expoConfig?.version), - scopes: AuthStandardClientScopes, }), ), Context.add( diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 028fe53e0191..28bb924ccca5 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -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(); @@ -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())), ); @@ -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; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index b0406b6e6ecd..4b67abbcd227 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -731,14 +731,18 @@ export const make = Effect.gen(function* () { const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => - bootstrapCredentials.consume(credential, input).pipe( - Effect.mapError(toBootstrapExchangeError), + bootstrapCredentials.consume(credential, { + ...input, + ...(requestedScopes !== undefined ? { requestedScopes } : {}), + }).pipe( + Effect.mapError((cause) => + cause._tag === "BootstrapCredentialScopeNotGrantedError" + ? new ServerAuthScopeNotGrantedError({}) + : toBootstrapExchangeError(cause), + ), 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", diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 9a093be41ca4..d1d21fcdaa67 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -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", @@ -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())), ); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index d455d3ac451c..c6bf54d2c742 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -65,6 +65,15 @@ export class UnavailableBootstrapCredentialError extends Schema.TaggedErrorClass } } +export class BootstrapCredentialScopeNotGrantedError extends Schema.TaggedErrorClass()( + "BootstrapCredentialScopeNotGrantedError", + {}, +) { + override get message(): string { + return "The requested authentication scope was not granted."; + } +} + export const BootstrapCredentialInvalidError = Schema.Union([ UnknownBootstrapCredentialError, ExpiredBootstrapCredentialError, @@ -171,6 +180,7 @@ export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialI export const BootstrapCredentialError = Schema.Union([ BootstrapCredentialInvalidError, BootstrapCredentialInternalError, + BootstrapCredentialScopeNotGrantedError, ]); export type BootstrapCredentialError = typeof BootstrapCredentialError.Type; export const isBootstrapCredentialError = Schema.is(BootstrapCredentialError); @@ -218,6 +228,7 @@ export class PairingGrantStore extends Context.Service< credential: string, input?: { readonly proofKeyThumbprint?: string; + readonly requestedScopes?: ReadonlyArray; }, ) => Effect.Effect; } @@ -230,7 +241,7 @@ interface StoredBootstrapGrant extends BootstrapGrant { type ConsumeResult = | { readonly _tag: "error"; - readonly reason: "not-found" | "expired"; + readonly reason: "not-found" | "expired" | "scope-not-granted"; readonly error: BootstrapCredentialError; } | { @@ -473,6 +484,17 @@ export const make = Effect.gen(function* () { ]; } + if (input?.requestedScopes?.some((scope) => !grant.scopes.includes(scope))) { + return [ + { + _tag: "error", + reason: "scope-not-granted", + error: new BootstrapCredentialScopeNotGrantedError({}), + }, + current, + ]; + } + const remainingUses = grant.remainingUses; if (typeof remainingUses === "number") { if (remainingUses <= 1) { @@ -515,6 +537,9 @@ export const make = Effect.gen(function* () { .consumeAvailable({ credential, proofKeyThumbprint: input?.proofKeyThumbprint ?? null, + ...(input?.requestedScopes !== undefined + ? { requestedScopes: input.requestedScopes } + : {}), consumedAt: now, now, }) @@ -560,6 +585,10 @@ export const make = Effect.gen(function* () { return yield* new BootstrapCredentialProofKeyMismatchError({}); } + if (input?.requestedScopes?.some((scope) => !matching.value.scopes.includes(scope))) { + return yield* new BootstrapCredentialScopeNotGrantedError({}); + } + return yield* new UnavailableBootstrapCredentialError({}); }, ); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index e1a13d4ce8e7..089656b7d1e7 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -8,6 +8,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + AuthStandardClientScopes, CommandId, EnvironmentOrchestrationHttpApi, ProviderInstanceId, @@ -592,6 +593,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { const created = JSON.parse(createdOutput.output) as { readonly id: string; readonly credential: string; + readonly scopes: ReadonlyArray; }; const listedOutput = yield* captureStdout( runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), @@ -605,6 +607,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(typeof created.id, "string"); assert.equal(typeof created.credential, "string"); assert.equal(created.credential.length > 0, true); + assert.deepEqual(created.scopes, AuthStandardClientScopes); assert.equal(listed.length, 1); assert.equal(listed[0]?.id, created.id); assert.equal("credential" in (listed[0] ?? {}), false); @@ -664,6 +667,77 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); + for (const [group, action] of [ + ["pairing", "create"], + ["session", "issue"], + ] as const) { + it.effect(`issues and persists only the selected scopes for auth ${group} ${action}`, () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-scopes-test-"), + ); + const { output } = yield* captureStdout( + runCli([ + "auth", + group, + action, + "--base-dir", + baseDir, + "--json", + "--scope", + "orchestration:read", + "--scope", + "access:read", + "--scope", + "orchestration:read", + ]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON is a presentation DTO. + const issued = JSON.parse(output) as { readonly scopes: ReadonlyArray }; + const { output: listOutput } = yield* captureStdout( + runCli(["auth", group, "list", "--base-dir", baseDir, "--json"]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON is a presentation DTO. + const listed = JSON.parse(listOutput) as ReadonlyArray<{ + readonly scopes: ReadonlyArray; + }>; + + assert.deepEqual(issued.scopes, ["orchestration:read", "access:read"]); + assert.lengthOf(listed, 1); + assert.deepEqual(listed[0]?.scopes, issued.scopes); + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), + ); + } + + for (const command of [ + ["pair"], + ["auth", "pairing", "create"], + ["auth", "session", "issue"], + ]) { + it.effect(`rejects invalid scopes before running ${command.join(" ")}`, () => + Effect.gen(function* () { + const error = yield* runCliWithRuntime([ + ...command, + "--scope", + "orchestration:read", + "--scope", + "admin", + ]).pipe(Effect.flip); + + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${String(error)}`); + } + assert.deepEqual(error.commandPath, ["t3", ...command]); + const scopeError = error.errors[0]; + if (scopeError?._tag !== "InvalidValue") { + assert.fail(`Expected InvalidValue, got ${String(scopeError?._tag)}`); + } + assert.equal(scopeError.option, "scope"); + assert.equal(scopeError.value, "admin"); + }), + ); + } + it.effect("rejects invalid ttl values before running auth commands", () => Effect.gen(function* () { const error = yield* runCliWithRuntime(["auth", "pairing", "create", "--ttl", "soon"]).pipe( diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 1b349111811c..9c3240065443 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -19,6 +19,7 @@ import { formatSessionList, } from "../cliAuthFormat.ts"; import * as ServerConfig from "../config.ts"; +import { authScopesFlag } from "./authScopes.ts"; import { authLocationFlags, type CliAuthLocationFlags, @@ -83,6 +84,7 @@ const tokenOnlyFlag = Flag.boolean("token-only").pipe( const pairingCreateCommand = Command.make("create", { ...authLocationFlags, + scopes: authScopesFlag(AuthStandardClientScopes), ttl: ttlFlag, label: labelFlag, baseUrl: baseUrlFlag, @@ -95,7 +97,7 @@ const pairingCreateCommand = Command.make("create", { (environmentAuth) => Effect.gen(function* () { const issued = yield* environmentAuth.createPairingLink({ - scopes: AuthStandardClientScopes, + scopes: flags.scopes, subject: "one-time-token", ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), @@ -161,6 +163,7 @@ const pairingCommand = Command.make("pairing").pipe( const sessionIssueCommand = Command.make("issue", { ...authLocationFlags, + scopes: authScopesFlag(AuthAdministrativeScopes), ttl: ttlFlag, label: labelFlag, subject: subjectFlag, @@ -174,7 +177,7 @@ const sessionIssueCommand = Command.make("issue", { (environmentAuth) => Effect.gen(function* () { const issued = yield* environmentAuth.issueSession({ - scopes: AuthAdministrativeScopes, + scopes: flags.scopes, ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), diff --git a/apps/server/src/cli/authScopes.ts b/apps/server/src/cli/authScopes.ts new file mode 100644 index 000000000000..36a54356cabd --- /dev/null +++ b/apps/server/src/cli/authScopes.ts @@ -0,0 +1,11 @@ +import { AuthEnvironmentScope } from "@t3tools/contracts"; +import { Flag } from "effect/unstable/cli"; + +export const authScopesFlag = (defaults: ReadonlyArray) => + Flag.choice("scope", AuthEnvironmentScope.literals).pipe( + Flag.withDescription( + `Authorization scope to grant. Repeat for multiple scopes; replaces the default set: ${defaults.join(", ")}.`, + ), + Flag.atLeast(0), + Flag.map((scopes) => [...new Set(scopes.length > 0 ? scopes : defaults)]), + ); diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index dd15c41fdd91..4b821d790b8e 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -5,6 +5,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthStandardClientScopes } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { assert, describe, expect, it } from "@effect/vitest"; @@ -174,9 +175,13 @@ describe("t3 pair", () => { runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), ); // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO. - const credentials = JSON.parse(listed) as ReadonlyArray<{ readonly label?: string }>; + const credentials = JSON.parse(listed) as ReadonlyArray<{ + readonly label?: string; + readonly scopes: ReadonlyArray; + }>; assert.equal(credentials.length, 1); assert.equal(credentials[0]?.label, "t3 pair"); + assert.deepEqual(credentials[0]?.scopes, AuthStandardClientScopes); }), ).pipe( Effect.provide(NodeServices.layer), @@ -196,6 +201,44 @@ describe("t3 pair", () => { ), ); + it.effect("mints a pairing grant with only the selected scopes", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-scopes-test-")); + yield* persistServerRuntimeState({ + path: NodePath.join(baseDir, "userdata", "server-runtime.json"), + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: Number(new URL(origin).port), + }), + }); + + yield* captureStdout( + runCli([ + "pair", + "--base-dir", + baseDir, + "--scope", + "orchestration:read", + "--scope", + "relay:read", + "--scope", + "orchestration:read", + ]), + ); + const listed = yield* captureStdout( + runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON is a presentation DTO. + const credentials = JSON.parse(listed) as ReadonlyArray<{ + readonly scopes: ReadonlyArray; + }>; + assert.lengthOf(credentials, 1); + assert.deepEqual(credentials[0]?.scopes, ["orchestration:read", "relay:read"]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("pairs through the recorded dev web URL for dev servers", () => withDescriptorServer((origin) => Effect.gen(function* () { diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index d40e0d97e484..f49f96be2d1e 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -10,6 +10,7 @@ * HTTPS and pairs through the tailnet URL instead. */ import { + type AuthEnvironmentScope, AuthStandardClientScopes, ExecutionEnvironmentDescriptor, PortSchema, @@ -54,6 +55,7 @@ import { renderTerminalQrCode, resolveHeadlessConnectionString, } from "../startupAccess.ts"; +import { authScopesFlag } from "./authScopes.ts"; import { baseDirFlag, DurationFromString } from "./config.ts"; const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; @@ -424,13 +426,14 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase" const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: { readonly config: ServerConfig.ServerConfig["Service"]; + readonly scopes: ReadonlyArray; readonly ttl: Option.Option; readonly label: Option.Option; }) { return yield* Effect.gen(function* () { const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; return yield* environmentAuth.createPairingLink({ - scopes: AuthStandardClientScopes, + scopes: input.scopes, subject: "one-time-token", label: Option.getOrElse(input.label, () => "t3 pair"), ...(Option.isSome(input.ttl) ? { ttl: input.ttl.value } : {}), @@ -473,6 +476,7 @@ const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( export const pairCommand = Command.make("pair", { baseDir: baseDirFlag, + scopes: authScopesFlag(AuthStandardClientScopes), ttl: ttlFlag, label: labelFlag, tailscale: tailscaleFlag, @@ -514,7 +518,12 @@ export const pairCommand = Command.make("pair", { } const config = yield* makePairServerConfig({ target, logLevel }); - const issued = yield* mintPairingLink({ config, ttl: flags.ttl, label: flags.label }); + const issued = yield* mintPairingLink({ + config, + scopes: flags.scopes, + ttl: flags.ttl, + label: flags.label, + }); const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential); yield* Console.log( diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index e54c977e7ab7..6a20fa560bc2 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -46,6 +46,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; export const ConsumeAuthPairingLinkInput = Schema.Struct({ credential: Schema.String, proofKeyThumbprint: Schema.NullOr(Schema.String), + requestedScopes: Schema.optionalKey(AuthEnvironmentScopes), consumedAt: Schema.DateTimeUtcFromString, now: Schema.DateTimeUtcFromString, }); @@ -158,7 +159,7 @@ export const make = Effect.gen(function* () { const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ Request: ConsumeAuthPairingLinkInput, Result: AuthPairingLinkRawDbRow, - execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => + execute: ({ credential, proofKeyThumbprint, requestedScopes, consumedAt, now }) => sql` UPDATE auth_pairing_links SET consumed_at = ${consumedAt} @@ -170,6 +171,13 @@ export const make = Effect.gen(function* () { proof_key_thumbprint IS NULL OR proof_key_thumbprint = ${proofKeyThumbprint} ) + AND NOT EXISTS ( + SELECT 1 + FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested + WHERE requested.value NOT IN ( + SELECT value FROM json_each(auth_pairing_links.scopes) + ) + ) RETURNING id AS "id", credential AS "credential", diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 7e88c4aae3c7..5d4498122856 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -26,7 +26,6 @@ import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/enviro import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; import { - AuthStandardClientScopes, type DesktopBridge, type DesktopEnvironmentBootstrap, type DesktopSshEnvironmentTarget, @@ -179,7 +178,6 @@ const capabilitiesLayer = Layer.effectContext( Effect.sync(() => { const presentation = ClientPresentation.of({ metadata: clientMetadata(), - scopes: AuthStandardClientScopes, }); const cloudSession = CloudSession.of({ identity: Effect.sync(() => @@ -332,7 +330,6 @@ const loadSecondaryConnectionRegistration = Effect.fn( const access = yield* bootstrapRemoteBearerSession({ httpBaseUrl, credential: entry.bootstrapToken, - scopes: AuthStandardClientScopes, clientMetadata: clientMetadata(), }).pipe(Effect.mapError(mapRemoteEnvironmentError)); // Keep the desktop pool's stable backend id in the connection id. The diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index ec2724b4b04e..db41e362231d 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -135,6 +135,18 @@ management is available through `npx t3 auth --help`. A session with an open connection stays listed after its access credential expires. +To choose a token's permissions, pass `--scope` once for each scope you want: + +```sh +npx t3 pair --scope orchestration:read --scope relay:read +``` + +The selected scopes replace the default permissions. The same option works with +`npx t3 auth pairing create` and `npx t3 auth session issue`; each command's +`--help` lists the available scopes. Without `--scope`, pairing tokens retain +standard client permissions and issued bearer sessions retain administrative +permissions. + To remove an environment from T3 Connect, open your account menu's **T3 Connect** page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This revokes its cloud access and frees its host space even when the environment is diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index 5cd2d89d1ac2..5d09f5db1a5e 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -1,4 +1,8 @@ -import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; +import { + AuthAdministrativeScopes, + AuthStandardClientScopes, + EnvironmentId, +} from "@t3tools/contracts"; import { RelayEnvironmentConnectScope, type RelayEnvironmentConnectResponse, @@ -46,7 +50,9 @@ const BOOTSTRAP: RelayEnvironmentConnectResponse = { expiresAt: "2026-06-06T01:00:00.000Z", }; -function recordedFetch(responses: ReadonlyArray) { +type RecordedResponse = Response | ((init: RequestInit) => Response); + +function recordedFetch(responses: ReadonlyArray) { const calls: Array = []; let responseIndex = 0; const fetchFn = ((input, init) => { @@ -54,7 +60,7 @@ function recordedFetch(responses: ReadonlyArray) { const response = responses[responseIndex++]; return response === undefined ? Promise.reject(new Error(`Unexpected fetch call to ${String(input)}`)) - : Promise.resolve(response); + : Promise.resolve(typeof response === "function" ? response(init ?? {}) : response); }) satisfies typeof fetch; return { calls, fetchFn }; } @@ -65,13 +71,13 @@ const websocketTicket = (ticket: string) => expiresAt: "2026-06-06T01:00:00.000Z", }); -const accessToken = (token: string) => +const accessToken = (token: string, scope = AuthStandardClientScopes.join(" ")) => Response.json({ access_token: token, issued_token_type: "urn:ietf:params:oauth:token-type:access_token", token_type: "DPoP", expires_in: 3_600, - scope: AuthStandardClientScopes.join(" "), + scope, }); const authInvalid = () => @@ -105,7 +111,7 @@ const persistedToken = ( const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { readonly initialToken?: TokenStore.RemoteDpopAccessToken; - readonly responses: ReadonlyArray; + readonly responses: ReadonlyArray; readonly bootstrap?: RelayEnvironmentConnectResponse; readonly beforeBootstrap?: Effect.Effect; readonly beforePut?: Effect.Effect; @@ -210,7 +216,6 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( deviceType: "mobile", os: "test", }, - scopes: AuthStandardClientScopes, }), ), ), @@ -379,6 +384,83 @@ describe("RemoteEnvironmentAuthorization", () => { }), ); + for (const [name, grantScopes] of [ + ["read-only", ["orchestration:read"]], + ["administrative", AuthAdministrativeScopes], + ] as const) { + it.effect( + `inherits ${name} pairing grant scopes for websocket authorization and HTTP renewal`, + () => + Effect.gen(function* () { + const grantedScope = grantScopes.join(" "); + let exchangeCount = 0; + const tokenFields = (init: RequestInit) => + new URLSearchParams( + init.body instanceof Uint8Array + ? new TextDecoder().decode(init.body) + : String(init.body), + ); + const exchangeGrant = (init: RequestInit) => { + const fields = tokenFields(init); + const scope = fields.get("scope") ?? grantedScope; + const scopes = scope.split(" "); + if (scopes.some((requested) => !grantScopes.some((grant) => grant === requested))) { + return authInvalid(); + } + return accessToken(`access-token:${++exchangeCount}:${scopes.join(",")}`, scope); + }; + const harness = yield* makeHarness({ + responses: [ + Response.json(DESCRIPTOR), + exchangeGrant, + websocketTicket("granted-ticket"), + Response.json(DESCRIPTOR), + exchangeGrant, + ], + }); + + const [authorized, refreshed] = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + const first = yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + }); + yield* TestClock.adjust("1 hour"); + const refreshed = yield* remote.authorizeDpopHttp({ + expectedEnvironmentId: ENVIRONMENT_ID, + }); + return [first, refreshed] as const; + }).pipe(Effect.provide(Layer.merge(harness.layer, TestClock.layer()))); + + expect(authorized.socketUrl).toContain("wsTicket=granted-ticket"); + expect(authorized.httpAuthorization).toMatchObject({ + _tag: "Dpop", + accessToken: `access-token:1:${grantScopes.join(",")}`, + }); + expect(refreshed.httpAuthorization).toMatchObject({ + _tag: "Dpop", + accessToken: `access-token:2:${grantScopes.join(",")}`, + }); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toMatchObject({ + accessToken: `access-token:2:${grantScopes.join(",")}`, + dpopThumbprint: "thumbprint-1", + }); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(2); + const exchanges = harness.fetch.calls.filter(([url]) => + String(url).endsWith("/oauth/token"), + ); + expect(exchanges).toHaveLength(2); + for (const [, init] of exchanges) { + expect(Object.fromEntries(tokenFields(init))).toMatchObject({ + subject_token: BOOTSTRAP.credential, + client_label: "T3 Code Test", + client_device_type: "mobile", + client_os: "test", + }); + } + }), + ); + } + it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () => Effect.gen(function* () { const cached = new TokenStore.RemoteDpopAccessToken({ diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 85929f73dd38..927ff2f7d4ac 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -314,7 +314,6 @@ export const make = Effect.gen(function* () { httpBaseUrl: bootstrap.endpoint.httpBaseUrl, credential: bootstrap.credential, dpopProof: bootstrapProof, - scopes: presentation.scopes, clientMetadata: presentation.metadata, }).pipe( Effect.mapError(mapRemoteDpopEnvironmentError), diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 9bee0dad6fb0..fbb1618d92fc 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -1,10 +1,16 @@ -import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; +import { + AuthAdministrativeScopes, + AuthStandardClientScopes, + EnvironmentId, + type AuthEnvironmentScope, +} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { fetchRemoteSessionState } from "../authorization/remote.ts"; import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts"; import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts"; import { BearerConnectionTarget } from "./model.ts"; @@ -22,14 +28,18 @@ const CLIENT_PRESENTATION_LAYER = Layer.succeed( deviceType: "desktop", os: "Test OS", }, - scopes: AuthStandardClientScopes, }), ); function pairingHttpLayer( calls: Array<{ readonly url: string; readonly init: RequestInit }>, - options?: { readonly failDescriptor?: boolean }, + options?: { + readonly failDescriptor?: boolean; + readonly grantScopes?: ReadonlyArray; + }, ) { + const grantScopes = options?.grantScopes ?? AuthStandardClientScopes; + let sessionScopes: ReadonlyArray = []; const fetchFn = ((input, init = {}) => { const url = String(input); calls.push({ url, init }); @@ -57,13 +67,48 @@ function pairingHttpLayer( } if (url.endsWith("/oauth/token")) { + const body = + init.body instanceof Uint8Array + ? new TextDecoder().decode(init.body) + : String(init.body); + const requestedScope = new URLSearchParams(body).get("scope"); + sessionScopes = requestedScope === null ? grantScopes : requestedScope.split(" "); + if (!sessionScopes.every((scope) => grantScopes.some((granted) => granted === scope))) { + return Promise.resolve( + Response.json( + { + _tag: "EnvironmentRequestInvalidError", + code: "invalid_request", + reason: "scope_not_granted", + traceId: "pairing-scope-test", + }, + { status: 400 }, + ), + ); + } return Promise.resolve( Response.json({ access_token: "bearer-token", issued_token_type: "urn:ietf:params:oauth:token-type:access_token", token_type: "Bearer", expires_in: 3600, - scope: AuthStandardClientScopes.join(" "), + scope: sessionScopes.join(" "), + }), + ); + } + + if (url.endsWith("/api/auth/session")) { + return Promise.resolve( + Response.json({ + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", + }, + scopes: sessionScopes, + sessionMethod: "bearer-access-token", }), ); } @@ -113,11 +158,37 @@ describe("connection onboarding", () => { : String(tokenRequest?.init.body); const tokenParams = new URLSearchParams(tokenBody); expect(tokenParams.get("subject_token")).toBe("pairing-token"); - expect(tokenParams.get("scope")).toBe(AuthStandardClientScopes.join(" ")); + expect(tokenParams.has("scope")).toBe(false); expect(tokenParams.get("client_label")).toBe("T3 Code Test"); + expect(tokenParams.get("client_device_type")).toBe("desktop"); + expect(tokenParams.get("client_os")).toBe("Test OS"); }), ); + for (const { label, scopes } of [ + { label: "read-only", scopes: ["orchestration:read"] }, + { label: "administrative", scopes: AuthAdministrativeScopes }, + ] as const) { + it.effect(`preserves the ${label} grant when pairing a remote environment`, () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const httpLayer = pairingHttpLayer(calls, { grantScopes: scopes }); + const registration = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe(Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, httpLayer))); + + const session = yield* fetchRemoteSessionState({ + httpBaseUrl: registration.profile.httpBaseUrl, + bearerToken: registration.credential.token, + }).pipe(Effect.provide(httpLayer)); + + expect(session.authenticated).toBe(true); + expect(session.scopes).toEqual(scopes); + }), + ); + } + it.effect("does not consume a pairing credential when descriptor discovery fails", () => Effect.gen(function* () { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index e76bcd50a2cc..17930af964a5 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -94,7 +94,6 @@ export const preparePairingRegistration = Effect.fn( const access = yield* bootstrapRemoteBearerSession({ httpBaseUrl: target.httpBaseUrl, credential: target.credential, - scopes: presentation.scopes, clientMetadata: presentation.metadata, }).pipe(Effect.mapError(mapRemoteEnvironmentError)); const connectionId = `bearer:${descriptor.environmentId}`; diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index d1fc270f21fe..cfd6b4311e40 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -146,7 +146,6 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o ClientCapabilities.ClientPresentation, ClientCapabilities.ClientPresentation.of({ metadata: { label: "Test Client", deviceType: "desktop", surface: "web" }, - scopes: [], }), ), Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts index bc793b00565d..cafecae14768 100644 --- a/packages/client-runtime/src/platform/capabilities.ts +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -1,6 +1,5 @@ import { type AuthClientPresentationMetadata, - type AuthEnvironmentScope, type DesktopSshEnvironmentBootstrap, type DesktopSshEnvironmentTarget, EnvironmentId, @@ -45,7 +44,6 @@ export class ClientPresentation extends Context.Service< ClientPresentation, { readonly metadata: AuthClientPresentationMetadata; - readonly scopes: ReadonlyArray; } >()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {} From 5403bc93a0997c85191672673824ead8dd88ce54 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:40:33 -0700 Subject: [PATCH 2/7] style(auth): format scoped pairing changes --- apps/server/src/auth/EnvironmentAuth.ts | 114 +++++++++--------- apps/server/src/bin.test.ts | 6 +- .../src/connection/onboarding.test.ts | 4 +- 3 files changed, 60 insertions(+), 64 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 4b67abbcd227..205215748a76 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -731,65 +731,67 @@ export const make = Effect.gen(function* () { const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => - bootstrapCredentials.consume(credential, { - ...input, - ...(requestedScopes !== undefined ? { requestedScopes } : {}), - }).pipe( - Effect.mapError((cause) => - cause._tag === "BootstrapCredentialScopeNotGrantedError" - ? new ServerAuthScopeNotGrantedError({}) - : toBootstrapExchangeError(cause), - ), - 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 }), - ), - ); - }), - ), - Effect.flatMap((session) => - DateTime.now.pipe( - Effect.map( - (now) => - ({ - access_token: session.token, - issued_token_type: AuthAccessTokenType, - token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", - expires_in: Math.max( - 0, - Math.floor( - (session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000, - ), + bootstrapCredentials + .consume(credential, { + ...input, + ...(requestedScopes !== undefined ? { requestedScopes } : {}), + }) + .pipe( + Effect.mapError((cause) => + cause._tag === "BootstrapCredentialScopeNotGrantedError" + ? new ServerAuthScopeNotGrantedError({}) + : toBootstrapExchangeError(cause), + ), + 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; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 089656b7d1e7..57e6cc98418f 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -709,11 +709,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { ); } - for (const command of [ - ["pair"], - ["auth", "pairing", "create"], - ["auth", "session", "issue"], - ]) { + for (const command of [["pair"], ["auth", "pairing", "create"], ["auth", "session", "issue"]]) { it.effect(`rejects invalid scopes before running ${command.join(" ")}`, () => Effect.gen(function* () { const error = yield* runCliWithRuntime([ diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index fbb1618d92fc..f06b6a87523e 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -68,9 +68,7 @@ function pairingHttpLayer( if (url.endsWith("/oauth/token")) { const body = - init.body instanceof Uint8Array - ? new TextDecoder().decode(init.body) - : String(init.body); + init.body instanceof Uint8Array ? new TextDecoder().decode(init.body) : String(init.body); const requestedScope = new URLSearchParams(body).get("scope"); sessionScopes = requestedScope === null ? grantScopes : requestedScope.split(" "); if (!sessionScopes.every((scope) => grantScopes.some((granted) => granted === scope))) { From ec16b9e2f595d1b85febcc0c2ccc57483c68a922 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:01:46 -0700 Subject: [PATCH 3/7] fix(auth): allow browser sessions to pair again --- apps/web/src/authBootstrap.test.ts | 90 +++++++++++++++++++++++ apps/web/src/environments/primary/auth.ts | 10 +++ apps/web/src/routes/pair.tsx | 7 +- docs/user/remote-access.md | 7 ++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729bb03..7b429eb0e1da 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -286,6 +286,96 @@ describe("resolveInitialServerAuthGateState", () => { expect(testWindow.location.searchParams.get("token")).toBeNull(); }); + it.each([ + { suffix: "#token=replacement-token", cached: false }, + { suffix: "#token=replacement-token", cached: true }, + { suffix: "?token=replacement-token", cached: true }, + ])( + "re-pairs an authenticated browser with $suffix when cached=$cached", + async ({ suffix, cached }) => { + let scopes: AuthBrowserSessionResult["scopes"] = ["orchestration:read"]; + const testApi = await installAuthApi({ + session: () => ({ ...authenticatedSession(LOOPBACK_AUTH), scopes }), + browserSession: () => + Effect.sync(() => { + scopes = ["orchestration:read", "orchestration:operate"]; + return browserSession(scopes); + }), + }); + const testWindow = installTestBrowser("http://localhost/"); + const { + fetchSessionState, + resolveInitialServerAuthGateState, + submitServerAuthCredential, + takePairingTokenFromUrl, + } = await import("./environments/primary"); + + if (cached) { + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + } + + testWindow.location = new URL(`http://localhost/pair${suffix}`); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + }); + expect(testApi.calls.browserSession).toEqual([]); + + const token = takePairingTokenFromUrl(); + expect(token).toBe("replacement-token"); + await submitServerAuthCredential(token!); + + expect(testApi.calls.browserSession).toEqual([{ credential: "replacement-token" }]); + await expect(fetchSessionState()).resolves.toMatchObject({ + authenticated: true, + scopes: ["orchestration:read", "orchestration:operate"], + }); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + }, + ); + + it("keeps the existing grant when a replacement pairing token is rejected", async () => { + const existingSession = { + ...authenticatedSession(LOOPBACK_AUTH), + scopes: ["orchestration:read"] as const, + }; + const testApi = await installAuthApi({ + session: () => existingSession, + browserSession: () => + Effect.fail( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-replacement", + }), + ), + }); + const testWindow = installTestBrowser("http://localhost/"); + const { + fetchSessionState, + resolveInitialServerAuthGateState, + submitServerAuthCredential, + takePairingTokenFromUrl, + } = await import("./environments/primary"); + await resolveInitialServerAuthGateState(); + testWindow.location = new URL("http://localhost/pair#token=invalid-replacement"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + }); + await expect(submitServerAuthCredential(takePairingTokenFromUrl()!)).rejects.toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialRejectedError", + message: "Invalid pairing token. Check the token and try again.", + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "invalid-replacement" }]); + await expect(fetchSessionState()).resolves.toEqual(existingSession); + }); + it("allows manual token submission after the initial auth check requires pairing", async () => { const nextSession = sequence( unauthenticatedSession(LOOPBACK_AUTH), diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index fe0345e41524..429a71443d73 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -505,6 +505,16 @@ export async function revokeOtherServerClientSessions(): Promise { } export async function resolveInitialServerAuthGateState(): Promise { + // An explicit pairing link replaces this browser's grant, even when the + // current cookie or a cached gate already authenticates it. + if (window.location.pathname === "/pair" && peekPairingTokenFromUrl()) { + const currentSession = await fetchSessionState(); + return { + status: "requires-auth", + auth: currentSession.auth, + }; + } + if (resolvedAuthenticatedGateState?.status === "authenticated") { return resolvedAuthenticatedGateState; } diff --git a/apps/web/src/routes/pair.tsx b/apps/web/src/routes/pair.tsx index 6575cd1bafa0..a2f19b2dec3f 100644 --- a/apps/web/src/routes/pair.tsx +++ b/apps/web/src/routes/pair.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; import { HostedPairingRouteSurface, @@ -28,7 +28,6 @@ export const Route = createFileRoute("/pair")({ function PairRouteView() { const { authGateState } = Route.useRouteContext(); - const navigate = useNavigate(); if (!authGateState) { return null; @@ -42,7 +41,9 @@ function PairRouteView() { { - void navigate({ to: "/", replace: true }); + // Recreate the primary connection so its WebSocket and cached scopes + // use the newly issued cookie after re-pairing. + window.location.replace("/"); }} {...(authGateState.errorMessage ? { initialErrorMessage: authGateState.errorMessage } : {})} /> diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index db41e362231d..c0e25b709c2e 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -147,6 +147,13 @@ The selected scopes replace the default permissions. The same option works with standard client permissions and issued bearer sessions retain administrative permissions. +To change an existing client's permissions, create a fresh pairing link with the +scopes it needs. In a browser opened directly on the environment, open that link +to replace the browser's current grant. For mobile or a saved remote environment +in web or desktop, use **Add Environment** with the fresh link or code; pairing +the same environment replaces its saved grant. Reconnecting alone does not change +permissions. + To remove an environment from T3 Connect, open your account menu's **T3 Connect** page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This revokes its cloud access and frees its host space even when the environment is From 22f2a9f0bcd4519ccb7bf6f98ddc18408d9f3f16 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 15:54:15 -0700 Subject: [PATCH 4/7] fix(auth): retain replacement pairing through route reloads --- apps/web/src/authBootstrap.test.ts | 134 +++++++++++++++------- apps/web/src/environments/primary/auth.ts | 13 ++- 2 files changed, 106 insertions(+), 41 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 7b429eb0e1da..42ce673cad1e 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -5,6 +5,7 @@ import { type AuthSessionState, type DesktopBridge, } from "@t3tools/contracts"; +import { createBrowserHistory } from "@tanstack/react-router"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; @@ -16,7 +17,8 @@ import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "./ type TestWindow = { location: URL; history: { - replaceState: (_data: unknown, _unused: string, url: string) => void; + state: unknown; + replaceState: (_data: unknown, _unused: string, url?: string) => void; }; desktopBridge?: DesktopBridge; }; @@ -59,8 +61,12 @@ function installTestBrowser(url: string) { const testWindow: TestWindow = { location: new URL(url), history: { - replaceState: (_data, _unused, nextUrl) => { - testWindow.location = new URL(nextUrl, testWindow.location.href); + state: null, + replaceState: (data, _unused, nextUrl) => { + testWindow.history.state = data; + if (nextUrl !== undefined) { + testWindow.location = new URL(nextUrl, testWindow.location.href); + } }, }, }; @@ -91,6 +97,14 @@ function sequence(...values: ReadonlyArray) { return () => values[Math.min(index++, values.length - 1)]!; } +function createSignal() { + let resolve = () => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + let disposeHttpTest: (() => Promise) | undefined; async function installAuthApi(input: { @@ -338,43 +352,85 @@ describe("resolveInitialServerAuthGateState", () => { }, ); - it("keeps the existing grant when a replacement pairing token is rejected", async () => { - const existingSession = { - ...authenticatedSession(LOOPBACK_AUTH), - scopes: ["orchestration:read"] as const, - }; - const testApi = await installAuthApi({ - session: () => existingSession, - browserSession: () => - Effect.fail( - new EnvironmentAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-invalid-replacement", + it.each([false, true])( + "keeps rejected replacement pairing open when cached=%s", + async (cached) => { + const existingSession = { + ...authenticatedSession(LOOPBACK_AUTH), + scopes: ["orchestration:read"] as const, + }; + const exchangeStarted = createSignal(); + const finishExchange = createSignal(); + const testApi = await installAuthApi({ + session: () => existingSession, + browserSession: () => + Effect.gen(function* () { + exchangeStarted.resolve(); + yield* Effect.promise(() => finishExchange.promise); + return yield* new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-replacement", + }); }), - ), - }); - const testWindow = installTestBrowser("http://localhost/"); - const { - fetchSessionState, - resolveInitialServerAuthGateState, - submitServerAuthCredential, - takePairingTokenFromUrl, - } = await import("./environments/primary"); - await resolveInitialServerAuthGateState(); - testWindow.location = new URL("http://localhost/pair#token=invalid-replacement"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - }); - await expect(submitServerAuthCredential(takePairingTokenFromUrl()!)).rejects.toMatchObject({ - _tag: "PrimaryEnvironmentPairingCredentialRejectedError", - message: "Invalid pairing token. Check the token and try again.", - }); - expect(testApi.calls.browserSession).toEqual([{ credential: "invalid-replacement" }]); - await expect(fetchSessionState()).resolves.toEqual(existingSession); - }); + }); + const testWindow = installTestBrowser("http://localhost/"); + const { + fetchSessionState, + resolveInitialServerAuthGateState, + submitServerAuthCredential, + takePairingTokenFromUrl, + } = await import("./environments/primary"); + if (cached) { + await resolveInitialServerAuthGateState(); + } + testWindow.location = new URL("http://localhost/pair#token=invalid-replacement"); + const history = createBrowserHistory({ + window: { + get location() { + return testWindow.location; + }, + history: testWindow.history, + addEventListener() {}, + removeEventListener() {}, + }, + }); + const gateLoads: Array> = []; + const unsubscribe = history.subscribe(() => { + gateLoads.push(resolveInitialServerAuthGateState()); + }); + try { + const requiresAuth = { status: "requires-auth", auth: LOOPBACK_AUTH }; + await expect(resolveInitialServerAuthGateState()).resolves.toEqual(requiresAuth); + + // The pairing form strips the token before submitting. TanStack history + // reloads the auth gate synchronously when replaceState runs. + const token = takePairingTokenFromUrl(); + expect(gateLoads).toHaveLength(1); + await expect(gateLoads[0]).resolves.toEqual(requiresAuth); + const rejected = expect(submitServerAuthCredential(token!)).rejects.toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialRejectedError", + message: "Invalid pairing token. Check the token and try again.", + }); + await exchangeStarted.promise; + await expect(resolveInitialServerAuthGateState()).resolves.toEqual(requiresAuth); + finishExchange.resolve(); + await rejected; + await expect(resolveInitialServerAuthGateState()).resolves.toEqual(requiresAuth); + expect(testApi.calls.browserSession).toEqual([{ credential: "invalid-replacement" }]); + await expect(fetchSessionState()).resolves.toEqual(existingSession); + + testWindow.history.replaceState({}, "", "/"); + await expect(gateLoads[1]).resolves.toEqual({ status: "authenticated" }); + testWindow.history.replaceState({}, "", "/pair"); + await expect(gateLoads[2]).resolves.toEqual({ status: "authenticated" }); + } finally { + finishExchange.resolve(); + unsubscribe(); + history.destroy(); + } + }, + ); it("allows manual token submission after the initial auth check requires pairing", async () => { const nextSession = sequence( diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 429a71443d73..49613d76bbd4 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -149,6 +149,7 @@ type ServerAuthGateState = let bootstrapPromise: Promise | null = null; let resolvedAuthenticatedGateState: ServerAuthGateState | null = null; +let explicitPairingRequested = false; const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; const AUTH_SESSION_ESTABLISH_STEP_MS = 100; @@ -354,6 +355,7 @@ export async function submitServerAuthCredential(credential: string): Promise { export async function resolveInitialServerAuthGateState(): Promise { // An explicit pairing link replaces this browser's grant, even when the - // current cookie or a cached gate already authenticates it. - if (window.location.pathname === "/pair" && peekPairingTokenFromUrl()) { + // current cookie or a cached gate already authenticates it. Keep that intent + // after stripping the token, which causes the router to load this gate again. + if (window.location.pathname !== "/pair") { + explicitPairingRequested = false; + } else if (peekPairingTokenFromUrl()) { + explicitPairingRequested = true; + } + if (explicitPairingRequested) { const currentSession = await fetchSessionState(); return { status: "requires-auth", @@ -552,4 +560,5 @@ export async function reauthenticatePrimaryEnvironment(): Promise Date: Fri, 4 Sep 2026 20:11:19 -0700 Subject: [PATCH 5/7] fix(auth): revoke replaced browser sessions --- apps/server/src/auth/EnvironmentAuth.ts | 73 +++++++------ apps/server/src/auth/SessionStore.test.ts | 41 ++++++++ apps/server/src/auth/SessionStore.ts | 12 ++- apps/server/src/auth/http.ts | 9 ++ apps/server/src/persistence/AuthSessions.ts | 9 +- apps/server/src/server.test.ts | 111 ++++++++++++++++++++ 6 files changed, 216 insertions(+), 39 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 205215748a76..63dbf8098640 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -423,6 +423,7 @@ export class EnvironmentAuth extends Context.Service< readonly createBrowserSession: ( credential: string, requestMetadata: AuthClientMetadata, + previousSessionToken?: string, ) => Effect.Effect< { readonly response: AuthBrowserSessionResult; @@ -693,41 +694,43 @@ 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) => diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1e2d5c60e9c9..97724420f8a9 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -265,6 +265,47 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), ); + it.effect.each(["insert", "revoke"] as const)( + "keeps existing browser sessions valid when replacement cannot %s", + (operation) => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const previous = yield* sessions.issue({ subject: "one-time-token" }); + const unrelated = yield* sessions.issue({ subject: "one-time-token" }); + if (operation === "insert") { + yield* sql` + CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions + BEGIN + SELECT RAISE(ABORT, 'simulated insert failure'); + END + `; + } else { + yield* sql` + CREATE TRIGGER reject_auth_session_revocation BEFORE UPDATE OF revoked_at ON auth_sessions + BEGIN + SELECT RAISE(ABORT, 'simulated revocation failure'); + END + `; + } + + const error = yield* sessions + .issue({ + subject: "replacement-pairing", + scopes: ["orchestration:read"], + replaceSessionId: previous.sessionId, + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("SessionCredentialIssueError"); + expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId); + expect((yield* sessions.verify(unrelated.token)).sessionId).toBe(unrelated.sessionId); + expect((yield* sessions.listActive()).map((session) => session.sessionId).sort()).toEqual( + [previous.sessionId, unrelated.sessionId].sort(), + ); + }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), + ); + it.effect("rejects websocket tokens once the parent session has expired", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index f4e5e3c487ac..d513bcdd041e 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -375,6 +375,8 @@ export class SessionStore extends Context.Service< * before storing this session. */ readonly replaceActiveForSubjectAndMethod?: boolean; + /** Replace only this session, of the same method, in the issuance transaction. */ + readonly replaceSessionId?: AuthSessionId; }) => Effect.Effect; readonly verify: (token: string) => Effect.Effect; readonly issueWebSocketToken: ( @@ -674,8 +676,14 @@ export const make = Effect.gen(function* () { expiresAt, } satisfies AuthSessions.CreateAuthSessionInput; const replacedSessionIds = yield* ( - input?.replaceActiveForSubjectAndMethod - ? authSessions.createReplacingActive({ session: sessionRecord, revokedAt: issuedAt }) + input?.replaceSessionId !== undefined || input?.replaceActiveForSubjectAndMethod + ? authSessions.createReplacingActive({ + session: sessionRecord, + revokedAt: issuedAt, + ...(input.replaceSessionId !== undefined + ? { replaceSessionId: input.replaceSessionId } + : {}), + }) : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray)) ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); if (replacedSessionIds.length > 0) { diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index cc74966c41e2..919de8b4cc94 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -269,9 +269,18 @@ export const authHttpApiLayer = HttpApiBuilder.group( function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); const request = yield* HttpServerRequest.HttpServerRequest; + const previousCredential = EnvironmentAuth.selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); const result = yield* serverAuth.createBrowserSession( args.payload.credential, deriveAuthClientMetadata({ request }), + previousCredential?.source === "cookie" || + previousCredential?.source === "legacy-cookie" + ? previousCredential.token + : undefined, ); yield* appendSessionCookie( sessions.cookieName, diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index aebbb839e5ce..cdfc386fd23d 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -58,6 +58,7 @@ export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; export const CreateReplacingActiveAuthSessionInput = Schema.Struct({ session: CreateAuthSessionInput, revokedAt: Schema.DateTimeUtcFromString, + replaceSessionId: Schema.optionalKey(AuthSessionId), }); export type CreateReplacingActiveAuthSessionInput = typeof CreateReplacingActiveAuthSessionInput.Type; @@ -268,11 +269,15 @@ export const make = Effect.gen(function* () { const revokeActiveSessionsForReplacement = SqlSchema.findAll({ Request: CreateReplacingActiveAuthSessionInput, Result: Schema.Struct({ sessionId: AuthSessionId }), - execute: ({ session, revokedAt }) => + execute: ({ session, revokedAt, replaceSessionId }) => sql` UPDATE auth_sessions SET revoked_at = ${revokedAt} - WHERE subject = ${session.subject} + WHERE ${ + replaceSessionId === undefined + ? sql`subject = ${session.subject}` + : sql`session_id = ${replaceSessionId}` + } AND method = ${session.method} AND revoked_at IS NULL AND expires_at > ${revokedAt} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index bebde7eded76..1716cd8d4c94 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2228,6 +2228,117 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect.each(["current", "legacy"] as const)( + "replaces only the presented %s browser session when pairing again", + (cookieKind) => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + const previous = yield* bootstrapBrowserSession(); + const unrelated = yield* bootstrapBrowserSession(); + const currentCookie = previous.cookie?.split(";")[0] ?? ""; + const previousCookie = + cookieKind === "legacy" + ? currentCookie.replace(/^t3_session_[^=]+=/, "t3_session=") + : currentCookie; + const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: previousCookie }, + body: yield* HttpBody.json({ scopes: ["orchestration:read"] }), + }); + assert.equal(pairingResponse.status, 200); + const pairing = yield* responseJsonEffect<{ readonly credential: string }>(pairingResponse); + + const replacement = yield* bootstrapBrowserSession(pairing.credential, { + headers: { cookie: previousCookie }, + }); + assert.equal(replacement.response.status, 200); + assert.isDefined(replacement.cookie); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const readSession = (cookie: string) => + fetchEffect(sessionUrl, { headers: { cookie } }).pipe( + Effect.flatMap( + responseJsonEffect<{ + readonly authenticated: boolean; + readonly scopes?: ReadonlyArray; + }>, + ), + ); + assert.equal((yield* readSession(previousCookie)).authenticated, false); + assert.deepEqual((yield* readSession(replacement.cookie?.split(";")[0] ?? "")).scopes, [ + "orchestration:read", + ]); + assert.equal( + (yield* readSession(unrelated.cookie?.split(";")[0] ?? "")).authenticated, + true, + ); + const oldTicket = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { cookie: previousCookie }, + }); + assert.equal(oldTicket.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("preserves the presented browser session when replacement pairing is invalid", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const previous = yield* bootstrapBrowserSession(); + const previousCookie = previous.cookie?.split(";")[0] ?? ""; + const rejected = yield* bootstrapBrowserSession("invalid-pairing-credential", { + headers: { cookie: previousCookie }, + }); + + assert.equal(rejected.response.status, 401); + assert.isUndefined(rejected.cookie); + const sessionResponse = yield* fetchEffect(yield* getHttpServerUrl("/api/auth/session"), { + headers: { cookie: previousCookie }, + }); + const session = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + sessionResponse, + ); + assert.equal(session.authenticated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("pairs a browser without revoking a bearer token presented as its cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const initial = yield* bootstrapBrowserSession(); + const token = yield* getAuthenticatedBearerSessionToken(); + const cookieName = initial.cookie?.split("=")[0] ?? ""; + const replacement = yield* bootstrapBrowserSession(defaultDesktopBootstrapToken, { + headers: { cookie: `${cookieName}=${token}` }, + }); + + assert.equal(replacement.response.status, 200); + const sessionResponse = yield* fetchEffect(yield* getHttpServerUrl("/api/auth/session"), { + headers: { authorization: `Bearer ${token}` }, + }); + const session = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + sessionResponse, + ); + assert.equal(session.authenticated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("accepts valid pairing when the previous browser cookie is invalid", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const initial = yield* bootstrapBrowserSession(); + const cookieName = initial.cookie?.split("=")[0] ?? ""; + const replacement = yield* bootstrapBrowserSession(defaultDesktopBootstrapToken, { + headers: { cookie: `${cookieName}=invalid-session-token` }, + }); + + assert.equal(replacement.response.status, 200); + const sessionResponse = yield* fetchEffect(yield* getHttpServerUrl("/api/auth/session"), { + headers: { cookie: replacement.cookie?.split(";")[0] ?? "" }, + }); + const session = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + sessionResponse, + ); + assert.equal(session.authenticated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect.each(["cookie", "bearer"])( "does not migrate a stale legacy cookie when %s auth succeeds", (source) => From 528bf27e9a1ca88b48f84d0b1ef49b355452a0e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 20:27:14 -0700 Subject: [PATCH 6/7] test(auth): align supervisor fixture with client presentation --- packages/client-runtime/src/connection/supervisor.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 2da7a68bd3ca..f189f5ee1536 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1301,7 +1301,6 @@ describe("EnvironmentSupervisor", () => { }), Layer.succeed(ClientCapabilities.ClientPresentation, { metadata: { label: "Test client", deviceType: "desktop" }, - scopes: AuthStandardClientScopes, }), ), ), From ea19072b3a2688a5834243f339ac1abaa2ffedb7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 20:42:43 -0700 Subject: [PATCH 7/7] fix(auth): refresh environment observers after re-pairing --- .../src/connection/registry.test.ts | 83 ++++++++++++++++++- .../client-runtime/src/connection/registry.ts | 5 +- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 15df040643ea..e43a4e262eb5 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -342,10 +342,17 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( connect: (entry, reportProgress) => Effect.gen(function* () { const target = entry.target; - const prepared = { + const credential = + target._tag === "BearerConnectionTarget" + ? yield* credentialStore.get(target.connectionId) + : Option.none(); + const prepared: PreparedConnection = { ...PREPARED, environmentId: target.environmentId, label: target.label, + httpAuthorization: Option.isSome(credential) + ? { _tag: "Bearer", token: credential.value.token } + : null, target, }; yield* reportProgress({ stage: "preparing" }); @@ -709,6 +716,80 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("moves prepared credential observers when re-pairing an unchanged environment", () => + Effect.gen(function* () { + const replacementCredential = new BearerConnectionCredential({ token: "replacement-token" }); + const harness = yield* makeHarness( + [BEARER_TARGET], + [BEARER_PROFILE], + [[BEARER_TARGET.connectionId, BEARER_CREDENTIAL]], + ); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const initialObserved = yield* Deferred.make(); + const replacementObserved = yield* Deferred.make(); + const tokens = yield* Ref.make>([]); + const followedSupervisors = yield* Ref.make(0); + yield* registry.start; + + const subscription = yield* Effect.forkChild( + registry + .followStream( + BEARER_TARGET.environmentId, + Stream.unwrap( + Effect.gen(function* () { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + yield* Ref.update(followedSupervisors, (count) => count + 1); + return SubscriptionRef.changes(supervisor.prepared); + }), + ), + ) + .pipe( + Stream.filterMap((prepared) => + Option.isSome(prepared) && prepared.value.httpAuthorization?._tag === "Bearer" + ? Result.succeed(prepared.value.httpAuthorization.token) + : Result.failVoid, + ), + Stream.changes, + Stream.runForEach((token) => + Effect.gen(function* () { + yield* Ref.update(tokens, (current) => [...current, token]); + yield* Deferred.succeed( + token === replacementCredential.token ? replacementObserved : initialObserved, + undefined, + ); + }), + ), + ), + ); + + yield* Deferred.await(initialObserved); + yield* registry.register(new RelayConnectionRegistration({ target: RELAY_TARGET })); + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + yield* registry.register( + new BearerConnectionRegistration({ + target: new BearerConnectionTarget({ ...BEARER_TARGET }), + profile: new BearerConnectionProfile({ ...BEARER_PROFILE }), + credential: replacementCredential, + }), + ); + yield* Deferred.await(replacementObserved); + yield* Fiber.interrupt(subscription); + + expect(yield* Ref.get(tokens)).toEqual([ + BEARER_CREDENTIAL.token, + replacementCredential.token, + ]); + expect(yield* Ref.get(followedSupervisors)).toBe(2); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("ignores retry signals for environments that are no longer registered", () => Effect.gen(function* () { const harness = yield* makeHarness([]); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 6907c43d6037..c9eb819748db 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -322,7 +322,10 @@ export const make = Effect.gen(function* () { SubscriptionRef.changes(entries), ).pipe( Stream.map((current) => Option.fromUndefinedOr(current.get(environmentId))), - Stream.changes, + // Re-pairing can replace the supervisor while its catalog details stay unchanged. + Stream.changesWith( + (previous, current) => Option.getOrNull(previous) === Option.getOrNull(current), + ), Stream.switchMap( Option.match({ onNone: () => Stream.empty,