From e9c3d950f023130791d8ac5fe7aa0992e3430738 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 20:30:10 -0700 Subject: [PATCH] fix(auth): preserve explicitly granted pairing scopes (#9785) Co-authored-by: Claude Fable 5 --- apps/mobile/src/connection/platform.ts | 2 - apps/server/src/auth/EnvironmentAuth.test.ts | 55 +++++- apps/server/src/auth/EnvironmentAuth.ts | 105 +++++++----- .../server/src/auth/PairingGrantStore.test.ts | 16 +- apps/server/src/auth/PairingGrantStore.ts | 34 +++- 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/cli/auth.ts | 7 +- apps/server/src/cli/authScopes.ts | 11 ++ apps/server/src/cli/pair.test.ts | 116 ++++++++++++- apps/server/src/cli/pair.ts | 13 +- .../src/persistence/AuthPairingLinks.ts | 10 +- apps/server/src/persistence/AuthSessions.ts | 9 +- apps/web/src/authBootstrap.test.ts | 159 +++++++++++++++++- apps/web/src/connection/platform.ts | 3 +- apps/web/src/environments/primary/auth.ts | 16 ++ apps/web/src/routes/pair.tsx | 10 +- docs/user/remote-access.md | 19 +++ .../src/authorization/layer.test.ts | 95 ++++++++++- .../src/authorization/service.ts | 1 - .../src/connection/onboarding.test.ts | 67 +++++++- .../src/connection/onboarding.ts | 1 - .../src/connection/registry.test.ts | 83 ++++++++- .../client-runtime/src/connection/registry.ts | 5 +- .../src/connection/resolver.test.ts | 1 - .../src/connection/supervisor.test.ts | 1 - .../src/platform/capabilities.ts | 2 - 28 files changed, 812 insertions(+), 91 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 a8bf9c8f4b5dd7fb09817b860d1a49335c38ea7a..e8817de69eeb2a6fcf0b32d775fd4b339349430c 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -10,7 +10,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"; @@ -216,7 +215,6 @@ const layerCapabilities = Layer.effectContext( ClientCapabilities.ClientPresentation, ClientCapabilities.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 12ab851aff6ca85fe0e4463550bb559d088733a1..61ebba5775f945b4334682f05c16a042fe05ddeb 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -353,7 +353,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }).pipe(Effect.provide(layerEnvironmentAuth({ 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(); @@ -367,10 +367,40 @@ 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(layerEnvironmentAuth())), ); - it.effect("inherits a constrained pairing grant when token exchange omits scope", () => + it.effect.each([ + { label: "omits scope", requestedScopes: undefined }, + { label: "requests no scopes", requestedScopes: [] }, + ])("inherits a constrained pairing grant when token exchange $label", ({ requestedScopes }) => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const pairingCredential = yield* serverAuth.issuePairingCredential({ @@ -379,7 +409,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( pairingCredential.credential, - undefined, + requestedScopes, requestMetadata, ); @@ -387,6 +417,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }).pipe(Effect.provide(layerEnvironmentAuth())), ); + 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(layerEnvironmentAuth({ 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 516860cee3bf2c806f912dca565a189da18f675b..dae46d1351cbccb3fa9b55e3148c04a9073960e1 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -466,6 +466,7 @@ export class EnvironmentAuth extends Context.Service< readonly createBrowserSession: ( credential: string, requestMetadata: AuthClientMetadata, + previousSessionToken?: string, ) => Effect.Effect< { readonly response: AuthBrowserSessionResult; @@ -786,12 +787,11 @@ export const make = Effect.gen(function* () { Effect.withSpan("EnvironmentAuth.getSessionState"), ); - const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( - credential, - requestMetadata, - ) => { + const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = Effect.fn( + "EnvironmentAuth.createBrowserSession", + )(function* (credential, requestMetadata, previousSessionToken) { if (devAuth?.matches(credential)) { - return sessions.verify(credential).pipe( + return yield* sessions.verify(credential).pipe( mapSessionVerificationErrors, Effect.flatMap((session) => DateTime.now.pipe( @@ -814,38 +814,40 @@ export const make = Effect.gen(function* () { Effect.withSpan("EnvironmentAuth.createBrowserSession"), ); } - return 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 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; + }); type ResolvedBootstrapGrant = Pick< PairingGrantStore.BootstrapGrant, @@ -855,15 +857,24 @@ export const make = Effect.gen(function* () { }; const resolveBootstrapGrant = ( credential: string, - input?: { readonly proofKeyThumbprint?: string }, + input?: { + readonly proofKeyThumbprint?: string; + readonly requestedScopes?: ReadonlyArray; + }, ): Effect.Effect< ResolvedBootstrapGrant, - ServerAuthInvalidCredentialError | ServerAuthInternalError + ServerAuthInvalidCredentialError | ServerAuthInternalError | ServerAuthScopeNotGrantedError > => { if (!devAuth?.matches(credential)) { return bootstrapCredentials .consume(credential, input) - .pipe(Effect.mapError(toBootstrapExchangeError)); + .pipe( + Effect.mapError((cause) => + cause._tag === "BootstrapCredentialScopeNotGrantedError" + ? new ServerAuthScopeNotGrantedError({}) + : toBootstrapExchangeError(cause), + ), + ); } return sessions.verify(credential).pipe( mapSessionVerificationErrors, @@ -879,14 +890,15 @@ export const make = Effect.gen(function* () { }; const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = - (credential, requestedScopes, requestMetadata, input) => - resolveBootstrapGrant(credential, input).pipe( + (credential, requestedScopesInput, requestMetadata, input) => { + const requestedScopes = requestedScopesInput?.length ? requestedScopesInput : undefined; + return resolveBootstrapGrant(credential, { + ...input, + ...(requestedScopes !== undefined ? { requestedScopes } : {}), + }).pipe( 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", @@ -934,6 +946,7 @@ export const make = Effect.gen(function* () { ), Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), ); + }; const issuePairingCredentialForSubject = (input: { readonly scopes: ReadonlyArray; @@ -1238,6 +1251,8 @@ export const make = Effect.gen(function* () { Effect.catchTags({ ServerAuthInvalidCredentialError: () => Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), + ServerAuthScopeNotGrantedError: () => + Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })), }), Effect.flatMap((grant) => grant.method !== "one-time-token" && grant.method !== "reusable-dev-token" diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 2103768d71f881fc848c874b54a3fecc3db9322b..db29e3c34ca4684b229ef998d8f68b6dda36b411 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -104,7 +104,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", @@ -128,20 +132,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(layerPairingGrantStore())), ); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 322f60d76899a0cf645bb441f506512d7d57fce5..6fcc087400d9a4fc138a5841b9e07b08660511eb 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -69,6 +69,15 @@ export class UnavailableBootstrapCredentialError extends Schema.TaggedError()( + "BootstrapCredentialScopeNotGrantedError", + {}, +) { + override get message(): string { + return "The requested authentication scope was not granted."; + } +} + export const BootstrapCredentialInvalidError = Schema.Union([ UnknownBootstrapCredentialError, ExpiredBootstrapCredentialError, @@ -174,6 +183,7 @@ export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialI export const BootstrapCredentialError = Schema.Union([ BootstrapCredentialInvalidError, BootstrapCredentialInternalError, + BootstrapCredentialScopeNotGrantedError, ]); export type BootstrapCredentialError = typeof BootstrapCredentialError.Type; @@ -220,6 +230,7 @@ export class PairingGrantStore extends Context.Service< credential: string, input?: { readonly proofKeyThumbprint?: string; + readonly requestedScopes?: ReadonlyArray; }, ) => Effect.Effect; } @@ -232,7 +243,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; } | { @@ -493,6 +504,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) { @@ -531,10 +553,16 @@ export const make = Effect.gen(function* () { return yield* seededResult.error; } + // The scope check is part of the UPDATE's WHERE clause so a rejected + // request cannot consume a one-time link. The re-check below only + // explains why nothing matched. const consumed = yield* pairingLinks .consumeAvailable({ credential, proofKeyThumbprint: input?.proofKeyThumbprint ?? null, + ...(input?.requestedScopes !== undefined + ? { requestedScopes: input.requestedScopes } + : {}), consumedAt: now, now, }) @@ -580,6 +608,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/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 706e46120e596f5504df873577e4c72f6d0c73c6..330d55ba43d8ad3e57bdf2dd40c98ac5babb3e5a 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -428,6 +428,47 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))), ); + 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(layerSessionStore(), SqlitePersistence.layerMemory))), + ); + 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 a431b44069386f20e64a9f57f93be9782f790818..007106f25a39c432e3bacfef25cde9e64781abf4 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -383,6 +383,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: ( @@ -708,8 +710,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 3d619af95ff952f231dd50dd6361b507186bdceb..ae6ec0076222a9cdea826afbf12733a220a66ecf 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -303,9 +303,18 @@ export const layer = 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, ); const cookieName = result.cookieName ?? sessions.cookieName; const selectedCookie = yield* Effect.fromResult( diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 6e5437313205bf53bf7a7a7e288fd935d144163f..32934a97ca46b63db76032b4e5f8673897aa0b99 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 0000000000000000000000000000000000000000..b8ff958b9e3ad233a7b9dafc09eb8dce6a7a98ec --- /dev/null +++ b/apps/server/src/cli/authScopes.ts @@ -0,0 +1,11 @@ +import { AuthEnvironmentScope } from "@t3tools/contracts"; +import { Flag } from "effect/cli"; + +export const authScopesFlag = (defaults: ReadonlyArray) => + Flag.Literals("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 77cd8f1f0bc9c83f2d9ea5cd3a8afebee9d49568..fa7c6b7588fd68b2c18059ecfa881d440253d132 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -5,13 +5,14 @@ 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"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as TestConsole from "effect/testing/TestConsole"; -import { Command } from "effect/cli"; +import { Command, CliError } from "effect/cli"; import { cli } from "../binCli.ts"; import { @@ -173,9 +174,13 @@ describe("t3 pair", () => { const listed = yield* captureStdout( runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), ); - 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), @@ -195,6 +200,43 @@ 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"]), + ); + 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* () { @@ -288,3 +330,73 @@ describe("t3 pair", () => { }).pipe(Effect.provide(NodeServices.layer)), ); }); + +describe("auth scope options", () => { + it.effect.each([ + { group: "pairing", action: "create" }, + { group: "session", action: "issue" }, + ] as const)( + "issues and persists only the selected scopes for auth $group $action", + ({ 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", + ]), + ); + const issued = JSON.parse(output) as { readonly scopes: ReadonlyArray }; + const listOutput = yield* captureStdout( + runCli(["auth", group, "list", "--base-dir", baseDir, "--json"]), + ); + 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); + }), + ); + + it.effect.each( + [["pair"], ["auth", "pairing", "create"], ["auth", "session", "issue"]].map((command) => ({ + command, + label: command.join(" "), + })), + )("rejects invalid scopes before running $label", ({ command }) => + Effect.gen(function* () { + const error = yield* runCli([ + ...command, + "--scope", + "orchestration:read", + "--scope", + "admin", + ]).pipe(Effect.provide(layerCliRuntime), 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"); + }), + ); +}); diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 63e7de96c232466ddece58e0d721cd0a2691cc71..debac218a9838416d583cca86988546580fb9867 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, @@ -51,6 +52,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"; @@ -426,13 +428,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 } : {}), @@ -475,6 +478,7 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( export const pairCommand = Command.make("pair", { baseDir: baseDirFlag, + scopes: authScopesFlag(AuthStandardClientScopes), ttl: ttlFlag, label: labelFlag, tailscale: tailscaleFlag, @@ -516,7 +520,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 f1082a1fa3737baaf03494b303bb78bc1bc47c5e..fa10dd7493c2996b5e8c27459d87f976d5c2df9c 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, }); @@ -159,7 +160,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} @@ -171,6 +172,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/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 995a21acb2255c93e9f49ed04696a21089d35e0e..ac65c446dcd8d86ddc384352630a83e925b8593f 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; @@ -276,11 +277,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/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 0e4c5d46df4a336d779cfa1d551d6acfdf633052..6add5c1856e7d4735f40d3400a60cc851bd99d2e 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/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: { @@ -286,6 +300,139 @@ 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 }, + { 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 { + resolveInitialServerAuthGateState, + submitServerAuthCredential, + takePairingTokenFromUrl, + } = await import("./environments/primary"); + const { fetchSessionState } = await import("./environments/primary/auth"); + + 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.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 { + resolveInitialServerAuthGateState, + submitServerAuthCredential, + takePairingTokenFromUrl, + } = await import("./environments/primary"); + const { fetchSessionState } = await import("./environments/primary/auth"); + 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( unauthenticatedSession(LOOPBACK_AUTH), @@ -522,7 +669,7 @@ describe("resolveInitialServerAuthGateState", () => { expect(testWindow.location.hash).toBe(""); }); - it("exchanges an explicit pair link after caching an authenticated state", async () => { + it("exchanges a root URL token after caching an authenticated state", async () => { const testApi = await installAuthApi({ session: () => authenticatedSession(LOOPBACK_AUTH), browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), @@ -533,7 +680,7 @@ describe("resolveInitialServerAuthGateState", () => { await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ status: "authenticated", }); - testWindow.location = new URL("http://localhost/pair#token=reusable-token"); + testWindow.location = new URL("http://localhost/#token=reusable-token"); await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); @@ -575,7 +722,7 @@ describe("resolveInitialServerAuthGateState", () => { const { resolveInitialServerAuthGateState } = await import("./environments/primary"); const initialBootstrap = resolveInitialServerAuthGateState(); - testWindow.location = new URL("http://localhost/pair#token=reusable-token"); + testWindow.location = new URL("http://localhost/#token=reusable-token"); const explicitPairing = resolveInitialServerAuthGateState(); const laterCaller = resolveInitialServerAuthGateState(); let laterCallerSettled = false; diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 5352876f214cc331e01912b5e46bdb16e9f8cc50..63c6563aea700d1595009434a88dcc65e08f74bf 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -228,7 +228,6 @@ const layerCapabilities = Layer.effectContext( Effect.sync(() => { const presentation = ClientCapabilities.ClientPresentation.of({ metadata: clientMetadata(), - scopes: AuthStandardClientScopes, }); const cloudSession = ClientCapabilities.CloudSession.of({ identity: Effect.sync(() => @@ -383,6 +382,8 @@ const loadSecondaryConnectionRegistration = Effect.fn( Effect.mapError(mapRemoteEnvironmentError), ); const issuedAtEpochMs = yield* Clock.currentTimeMillis; + // The desktop seed grant is administrative so the primary window can manage + // access; a secondary backend session only needs to operate its environment. const access = yield* bootstrapRemoteBearerSession({ httpBaseUrl, credential: entry.bootstrapToken, diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 5a09dcb1288461475047fc0b8db9ebd5ee180fcc..f281c540b355b52f5b0000d847e8082945fb4661 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -140,6 +140,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; @@ -347,6 +348,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. Keep that intent + // after stripping the token, which causes the router to load this gate again. + if (window.location.pathname.replace(/\/+$/, "") !== "/pair") { + explicitPairingRequested = false; + } else if (peekPairingTokenFromUrl()) { + explicitPairingRequested = true; + } + if (explicitPairingRequested) { + const currentSession = await fetchSessionState(); + return { status: "requires-auth", auth: currentSession.auth }; + } + const urlCredential = takePairingTokenFromUrl(); const previousPromise = bootstrapPromise; if (urlCredential) { @@ -468,4 +483,5 @@ export async function resolveInitialServerAuthGateState(): Promise { - void navigate({ to: "/", replace: true }); + // Recreate the primary connection so its WebSocket and cached scopes + // use the newly issued cookie after re-pairing. + router.history.replace("/"); + router.history.flush(); + window.location.reload(); }} {...(authGateState.errorMessage ? { initialErrorMessage: authGateState.errorMessage } : {})} /> diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 4db4f57ef3ab249672ce5c7f2c938683c8b1f494..4f6eb711526a0cd87031750db3ae8e88180553a6 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -258,6 +258,25 @@ management is available through `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 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 diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index f2b7b8c066f6d3f170d1abe3a1156436e5cad66d..91cbdae1426f0a9aacd62686a48be2b43b174e00 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, }), ), ), @@ -490,6 +495,82 @@ describe("RemoteEnvironmentAuthorization", () => { }), ); + it.effect.each([ + { name: "read-only", grantScopes: ["orchestration:read"] }, + { name: "administrative", grantScopes: AuthAdministrativeScopes }, + ] as const)( + "inherits $name pairing grant scopes for websocket authorization and HTTP renewal", + ({ grantScopes }) => + 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); + expect(fields.get("scope")).toBeNull(); + 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 68f0e13cacdd95092cdb34d8443cb195e7375cdc..780c3a77fdc0d2f7cb3ad53665559f009d2d6b57 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -341,7 +341,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 291eebcd1546e76eb2567ce2c8474f463dc72c4c..77f86b552f4ebf694f2d6e6b8abb0c7bb5daaf39 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -1,6 +1,8 @@ import { + AuthAdministrativeScopes, AuthStandardClientScopes, EnvironmentId, + type AuthEnvironmentScope, ORCHESTRATION_PROTOCOL_VERSION, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -10,6 +12,7 @@ import * as Option from "effect/Option"; import * as RpcHttp from "../rpc/http.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; +import { fetchRemoteSessionState } from "../authorization/remote.ts"; import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts"; import { BearerConnectionTarget } from "./model.ts"; import { @@ -26,7 +29,6 @@ const layerClientPresentation = Layer.succeed( deviceType: "desktop", os: "Test OS", }, - scopes: AuthStandardClientScopes, }), ); @@ -36,8 +38,11 @@ function layerPairingHttp( readonly failDescriptor?: boolean; readonly protocolVersion?: number; readonly selfUpdate?: boolean; + readonly grantScopes?: ReadonlyArray; }, ) { + const grantScopes = options?.grantScopes ?? AuthStandardClientScopes; + let sessionScopes: ReadonlyArray = []; const fetchFn = ((input, init = {}) => { const url = String(input); calls.push({ url, init }); @@ -67,13 +72,46 @@ function layerPairingHttp( } 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", }), ); } @@ -123,8 +161,10 @@ 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"); }), ); @@ -149,6 +189,27 @@ describe("connection onboarding", () => { ]); }), ); + it.effect.each([ + { label: "read-only", scopes: ["orchestration:read"] }, + { label: "administrative", scopes: AuthAdministrativeScopes }, + ] as const)("preserves the $label grant when pairing a remote environment", ({ scopes }) => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const httpLayer = layerPairingHttp(calls, { grantScopes: scopes }); + const registration = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe(Effect.provide(Layer.mergeAll(layerClientPresentation, 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("refuses to add a route that reaches a different machine, keeping the code", () => Effect.gen(function* () { diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 461d54dc36ef584f0e932194e8fab15309a7bbf2..da2981b48aca72b10e119e4d6271e99e9a4f71b1 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -131,7 +131,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 = bearerConnectionId(descriptor.environmentId, target.httpBaseUrl); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 12296df81a3079e6b545bf7008570415c5fb05cc..62f9efb722f4a76fce95ebcb2e00015f5c15810a 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -382,10 +382,17 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( const connectRoute = (entry: ConnectionCatalogEntry, route: ConnectionRoute) => Effect.gen(function* () { const target = route.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, }; if (options?.prepareError) return yield* options.prepareError; @@ -1201,6 +1208,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 d0cb52ddfc39a014fdd761e79241b718430f0b74..1efc0548de89982d37b5ed4c83c9825686c1c63c 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -452,7 +452,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, diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index f999d73c1cfef6be025cb667af45c2757c7f9869..b99b686fd48fa951a822a50b608c1d699b696229 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -176,7 +176,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/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 5a336a1650bc06a6cfb29792764e83684ddc9d2f..37adf87c1b47c6ad7406eb2b88fac37bbc6f6f20 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1572,7 +1572,6 @@ describe("EnvironmentSupervisor", () => { }), Layer.succeed(ClientCapabilities.ClientPresentation, { metadata: { label: "Test client", deviceType: "desktop" }, - scopes: AuthStandardClientScopes, }), ), ), diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts index c3ad29286bcb4d541940c41629fcbc23d45354b2..2de4522a592ec72eaf32ec46a9c472ce53b7270c 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") {} -- 2.54.0