From 365aa87982a4d81cc8e0c085e8d1a40ca7daecdc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 20:30:14 -0700 Subject: [PATCH] fix(auth): keep old clients connected across scope changes (#10298) Co-authored-by: Claude Fable 5 --- apps/mobile/src/App.tsx | 2 + .../src/components/PermissionUpdateNotice.tsx | 84 ++++++++++ .../settings/environment-maintenance.test.ts | 27 ++++ .../settings/environment-maintenance.ts | 6 +- .../terminal/ThreadTerminalRouteScreen.tsx | 9 +- apps/mobile/src/state/mediaActions.ts | 10 +- apps/mobile/src/state/session.test.ts | 15 ++ apps/mobile/src/state/session.ts | 13 +- apps/server/src/auth/EnvironmentAuth.test.ts | 26 +++- apps/server/src/auth/EnvironmentAuth.ts | 20 ++- apps/server/src/auth/PairingGrantStore.ts | 10 +- apps/server/src/auth/RpcAuthorization.test.ts | 13 +- apps/server/src/auth/RpcAuthorization.ts | 3 +- apps/server/src/auth/SessionStore.test.ts | 29 ++++ apps/server/src/auth/SessionStore.ts | 3 +- apps/server/src/auth/http.test.ts | 19 +++ apps/server/src/auth/http.ts | 14 +- apps/server/src/cliAuthFormat.ts | 4 +- .../src/persistence/AuthPairingLinks.ts | 6 +- apps/server/src/ws.ts | 4 +- .../PermissionUpdateNotice.test.tsx | 111 ++++++++++++++ .../src/components/PermissionUpdateNotice.tsx | 78 ++++++++++ .../web/src/components/ServerUpdateAction.tsx | 14 +- .../web/src/components/media/MediaActions.tsx | 9 +- .../settings/ConnectionsSettings.tsx | 4 +- .../ProviderSettingsPanel.logic.test.ts | 16 +- .../settings/ProviderSettingsPanel.logic.ts | 12 +- .../components/usage/UsagePriceOverrides.tsx | 4 +- apps/web/src/hooks/useSettings.ts | 3 +- .../useThreadActions.permissions.test.ts | 1 + apps/web/src/hooks/useThreadActions.ts | 4 +- apps/web/src/routes/__root.tsx | 2 + apps/web/src/state/session.ts | 36 ++--- docs/internals/environment-auth.md | 16 +- docs/user/remote-access.md | 7 +- packages/client-runtime/src/rpc/client.ts | 3 +- .../src/state/commandPermissions.test.ts | 72 ++++++++- .../src/state/commandPermissions.ts | 12 +- .../src/state/filesystem.test.ts | 18 ++- .../client-runtime/src/state/filesystem.ts | 8 +- .../src/state/usageAccess.test.ts | 6 +- .../client-runtime/src/state/usageAccess.ts | 13 +- packages/contracts/src/auth.test.ts | 144 +++++++++++++++++- packages/contracts/src/auth.ts | 84 +++++++++- packages/contracts/src/environmentHttp.ts | 3 +- 45 files changed, 870 insertions(+), 127 deletions(-) create mode 100644 apps/mobile/src/components/PermissionUpdateNotice.tsx create mode 100644 apps/web/src/components/PermissionUpdateNotice.test.tsx create mode 100644 apps/web/src/components/PermissionUpdateNotice.tsx diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 645bc0b8a2fb659117a5275f72a78d73707e553d..f2a809361ee4322d21d846d245902108aee7ca38 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,3 +1,4 @@ +import { PermissionUpdateNotice } from "./components/PermissionUpdateNotice"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; @@ -75,6 +76,7 @@ function AppContent() { <> + diff --git a/apps/mobile/src/components/PermissionUpdateNotice.tsx b/apps/mobile/src/components/PermissionUpdateNotice.tsx new file mode 100644 index 0000000000000000000000000000000000000000..55f2de0638fc46c0140a3f18d074e8f19cf36798 --- /dev/null +++ b/apps/mobile/src/components/PermissionUpdateNotice.tsx @@ -0,0 +1,84 @@ +import { useAtomValue } from "@effect/atom-react"; +import { sessionHasLegacyPermissions } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import * as SecureStore from "expo-secure-store"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Alert } from "react-native"; + +import { useEnvironments } from "../state/environments"; +import { environmentSession } from "../state/session"; + +const storageKey = "t3code.permission-update.v1"; +const dismissedThisLaunch = new Set(); +const shownThisLaunch = new Set(); + +export function PermissionUpdateNotice() { + const { environments } = useEnvironments(); + const [dismissed, setDismissed] = useState | null>(null); + const showing = useRef(false); + const affected = useAtomValue( + useMemo( + () => + Atom.make((get) => + environments.filter(({ environmentId }) => { + const session = get(environmentSession.sessionStateAtom(environmentId)); + return ( + session._tag === "Success" && + !session.waiting && + sessionHasLegacyPermissions(session.value) + ); + }), + ), + [environments], + ), + ); + + useEffect(() => { + let active = true; + void SecureStore.getItemAsync(storageKey) + .then((raw) => { + const value: unknown = raw === null ? [] : JSON.parse(raw); + if (active) + setDismissed( + new Set( + Array.isArray(value) + ? value.filter((id): id is string => typeof id === "string") + : [], + ), + ); + }) + .catch(() => { + if (active) setDismissed(new Set()); + }); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + if (dismissed === null || showing.current) return; + const environment = affected.find( + ({ environmentId }) => !dismissed.has(environmentId) && !shownThisLaunch.has(environmentId), + ); + if (!environment) return; + showing.current = true; + shownThisLaunch.add(environment.environmentId); + const dismiss = () => { + dismissedThisLaunch.add(environment.environmentId); + const next = new Set([...dismissed, ...dismissedThisLaunch]); + // Keep notices serial when several environments have old grants. + showing.current = false; + setDismissed(next); + void SecureStore.setItemAsync(storageKey, JSON.stringify([...next])).catch(() => { + // Remember for this launch even if persistent storage is unavailable. + }); + }; + Alert.alert( + `Permissions have changed for ${environment.label}`, + "This connection still uses the old permissions, so some actions may no longer be available. Pair again using a new link with the permissions you need.", + [{ text: "Got it", onPress: dismiss }], + { cancelable: false }, + ); + }, [affected, dismissed]); + return null; +} diff --git a/apps/mobile/src/features/settings/environment-maintenance.test.ts b/apps/mobile/src/features/settings/environment-maintenance.test.ts index fb90d33b473f62fd7098d7b68de0ed19ba8d5216..e2e1ebdf99a4f7ef71c8cb64e838a49736e9e5d8 100644 --- a/apps/mobile/src/features/settings/environment-maintenance.test.ts +++ b/apps/mobile/src/features/settings/environment-maintenance.test.ts @@ -76,6 +76,33 @@ describe("environment maintenance access", () => { ).toBe(expected); }); + it.each([ + { permissions: ["environment:maintain"], expected: true }, + { permissions: ["providers:manage"], expected: false }, + { permissions: [], expected: false }, + ] as const)( + "honors exact permissions over legacy scopes: $permissions", + ({ permissions, expected }) => { + expect( + canMaintainEnvironment( + { + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "session", + serverUpdateScope: "environment:maintain", + }, + scopes: ["orchestration:operate"], + permissions, + }, + true, + ), + ).toBe(expected); + }, + ); + it("requires remote desktop update support for desktop hosts", () => { expect(supportsEnvironmentUpdate({})).toBe(false); expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true); diff --git a/apps/mobile/src/features/settings/environment-maintenance.ts b/apps/mobile/src/features/settings/environment-maintenance.ts index 02e149a42318eddfb557c4b2183f6c291c57acab..3dd1d559c71d4a5e139b3fea340d5584030e0af0 100644 --- a/apps/mobile/src/features/settings/environment-maintenance.ts +++ b/apps/mobile/src/features/settings/environment-maintenance.ts @@ -1,5 +1,6 @@ import { - AuthOrchestrationOperateScope, + AuthEnvironmentMaintainScope, + sessionGrantsScope, type AuthSessionState, type ExecutionEnvironmentCapabilities, type ServerProvider, @@ -16,8 +17,7 @@ export function canMaintainEnvironment(session: AuthSessionState | null, connect return ( connected && session?.authenticated === true && - session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) === - true + sessionGrantsScope(session, AuthEnvironmentMaintainScope) ); } diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 255fad6f4eaf81cfa136e3511a3244cc297fabc1..767a1fa1dc7f17e85c94acde1162379d883f236e 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -4,6 +4,7 @@ import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId, + sessionGrantsScope, } from "@t3tools/contracts"; import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { SymbolView } from "../../components/AppSymbol"; @@ -279,9 +280,13 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const isAuthenticated = terminalSession.error === null && terminalSession.data?.authenticated === true; const canOperateTerminal = - isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalOperateScope) === true; + isAuthenticated && + terminalSession.data !== null && + sessionGrantsScope(terminalSession.data, AuthTerminalOperateScope); const canReadTerminal = - isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalReadScope) === true; + isAuthenticated && + terminalSession.data !== null && + sessionGrantsScope(terminalSession.data, AuthTerminalReadScope); const environment = useEnvironmentPresentation(routeEnvironmentId); const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const requestedTerminalId = firstRouteParam(params.terminalId); diff --git a/apps/mobile/src/state/mediaActions.ts b/apps/mobile/src/state/mediaActions.ts index 48af952df2cf2fe0c500fcfe2e2ba9cce59f5c03..7658b8c46057a88eb4641afd72f790b0fe80f21c 100644 --- a/apps/mobile/src/state/mediaActions.ts +++ b/apps/mobile/src/state/mediaActions.ts @@ -3,8 +3,9 @@ import { useNavigation } from "@react-navigation/native"; import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; import { AuthFilesystemReadScope, - type AuthSessionState, type EnvironmentId, + sessionGrantsScope, + type SessionGrantInput, } from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; import * as Option from "effect/Option"; @@ -21,11 +22,8 @@ import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; /** An explicit action may ask the server while its grant is still unresolved. */ -function allowsHostMedia(session: Pick | null) { - return ( - session === null || - (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true) - ); +function allowsHostMedia(session: SessionGrantInput | null) { + return session === null || sessionGrantsScope(session, AuthFilesystemReadScope); } function canReadHostMedia(environmentId: EnvironmentId | null): boolean { diff --git a/apps/mobile/src/state/session.test.ts b/apps/mobile/src/state/session.test.ts index d1ee156235dae47f2c97a60e3164767ae780637d..aeb8f684e0f7bfe84b4d8da5c3ba0f6c0e8b721c 100644 --- a/apps/mobile/src/state/session.test.ts +++ b/apps/mobile/src/state/session.test.ts @@ -98,3 +98,18 @@ it("reacts to grant revocation, failure, and regrant without a connection list c expect(appAtomRegistry.get(observed)).toEqual(new Set([secondary])); expect(changes).not.toHaveLength(0); }); + +it("uses exact new-server permissions and keeps old-server grants usable", () => { + // A legacy representation must never override an explicitly narrowed grant. + appAtomRegistry.set(source(primary), AsyncResult.success({ ...session(true), permissions: [] })); + expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(false); + expect(readEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(false); + appAtomRegistry.set( + source(primary), + AsyncResult.success({ ...session(false), permissions: [AuthOrchestrationOperateScope] }), + ); + expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(true); + expect(readEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(true); + appAtomRegistry.set(source(primary), AsyncResult.success(session(true))); + expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(true); +}); diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index dffff631d8a000fc48b642e7e83d9c4415bb54e6..8d3fa134869c58ef6e75df73c2dd18b59d92ce09 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -1,6 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; -import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts"; +import { + type AuthEnvironmentScope, + type AuthSessionState, + type EnvironmentId, + sessionGrantsScope, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useMemo } from "react"; @@ -17,11 +22,7 @@ function sessionHasScope( scope: AuthEnvironmentScope, ): boolean { const session = Option.getOrNull(AsyncResult.value(result)); - return ( - result._tag !== "Failure" && - session?.authenticated === true && - session.scopes?.includes(scope) === true - ); + return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope); } /** Uses the selected environment's grant, including cached scopes during a refresh. */ diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 71ee138ddc3a60f6abc1fd37f73452a4afca5e36..a85db0fd14923a4c0803eb0b9694e3cace684b51 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -1,5 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; +import { + authScopeResponse, + AuthAdministrativeScopes, + AuthStandardClientScopes, +} from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -102,6 +106,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { const authenticated = yield* serverAuth.authenticateHttpRequest(request); expect(devExchange.cookieName).toMatch(/^t3_dev_session_/); expect(devExchange.expireNormalCookie).toBe(true); + expect(devExchange.response).toMatchObject(authScopeResponse(AuthAdministrativeScopes)); expect(authenticated.scopes).toEqual(["orchestration:read"]); }).pipe( Effect.provide( @@ -214,6 +219,16 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { expect(firstSession.subject).toBe("reusable-dev-token-child"); expect(secondSession.subject).toBe("reusable-dev-token-child"); expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token"); + const before = yield* serverAuth.listClientSessions(firstSession.sessionId); + const denied = yield* serverAuth + .exchangeBootstrapCredentialForAccessToken(token, ["review:write"], requestMetadata) + .pipe(Effect.flip); + expect(denied._tag).toBe("ServerAuthScopeNotGrantedError"); + const empty = yield* serverAuth + .exchangeBootstrapCredentialForAccessToken(token, [], requestMetadata) + .pipe(Effect.flip); + expect(empty._tag).toBe("ServerAuthScopeNotGrantedError"); + expect(yield* serverAuth.listClientSessions(firstSession.sessionId)).toEqual(before); }).pipe( Effect.provide( layerEnvironmentAuth({ @@ -241,7 +256,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError"); expect( (yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response, - ).toMatchObject({ authenticated: true, scopes: AuthAdministrativeScopes }); + ).toMatchObject({ authenticated: true, ...authScopeResponse(AuthAdministrativeScopes) }); }).pipe( Effect.provide( layerEnvironmentAuth({ @@ -355,7 +370,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { const error = yield* serverAuth .exchangeBootstrapCredentialForAccessToken( pairingCredential.credential, - ["orchestration:read", "access:write"], + ["access:write"], requestMetadata, ) .pipe(Effect.flip); @@ -393,7 +408,10 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { it.effect.each([ { label: "omits scope", requestedScopes: undefined }, - { label: "requests no scopes", requestedScopes: [] }, + { + label: "requests unsupported permissions alongside a granted one", + requestedScopes: ["orchestration:read", "access:write"] as const, + }, ])("inherits a constrained pairing grant when token exchange $label", ({ requestedScopes }) => 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 dae46d1351cbccb3fa9b55e3148c04a9073960e1..ac90e67b6f26df3d2d93d5e7484466f6d7a167ba 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -16,6 +16,7 @@ import { type AuthPairingCredentialResult, type AuthSessionId, type AuthSessionState, + authScopeResponse, type ServerAuthDescriptor, type ServerAuthSessionMethod, type AuthWebSocketTicketResult, @@ -773,7 +774,7 @@ export const make = Effect.gen(function* () { ({ authenticated: true, auth: descriptor, - scopes: session.scopes, + ...authScopeResponse(session.scopes), sessionMethod: session.method, ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), }) satisfies AuthSessionState, @@ -800,7 +801,7 @@ export const make = Effect.gen(function* () { ({ response: { authenticated: true, - scopes: session.scopes, + ...authScopeResponse(session.scopes), sessionMethod: session.method, expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })), } satisfies AuthBrowserSessionResult, @@ -841,7 +842,7 @@ export const make = Effect.gen(function* () { return { response: { authenticated: true, - scopes: session.scopes, + ...authScopeResponse(session.scopes), sessionMethod: session.method, expiresAt: DateTime.toUtc(session.expiresAt), } satisfies AuthBrowserSessionResult, @@ -890,15 +891,20 @@ export const make = Effect.gen(function* () { }; const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = - (credential, requestedScopesInput, requestMetadata, input) => { - const requestedScopes = requestedScopesInput?.length ? requestedScopesInput : undefined; + (credential, requestedScopes, requestMetadata, input) => { return resolveBootstrapGrant(credential, { ...input, ...(requestedScopes !== undefined ? { requestedScopes } : {}), }).pipe( Effect.flatMap((grant) => Effect.gen(function* () { - const grantedScopes = requestedScopes ?? grant.scopes; + const grantedScopes = + requestedScopes === undefined + ? grant.scopes + : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope)); + if (grantedScopes.length === 0) { + return yield* new ServerAuthScopeNotGrantedError({}); + } return yield* sessions .issue({ method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", @@ -1005,6 +1011,7 @@ export const make = Effect.gen(function* () { ]; return pairingLinks .filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject)) + .map((link) => ({ ...link, ...authScopeResponse(link.scopes) })) .toSorted( (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, ); @@ -1110,6 +1117,7 @@ export const make = Effect.gen(function* () { Effect.map((clientSessions) => clientSessions.map((clientSession): AuthClientSession => ({ ...clientSession, + ...authScopeResponse(clientSession.scopes), current: clientSession.sessionId === currentSessionId, })), ), diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 6fcc087400d9a4fc138a5841b9e07b08660511eb..d3f5bf7ef8e5e126b9d80dd138166f34b5c741e5 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -504,7 +504,10 @@ export const make = Effect.gen(function* () { ]; } - if (input?.requestedScopes?.some((scope) => !grant.scopes.includes(scope))) { + if ( + input?.requestedScopes !== undefined && + !input.requestedScopes.some((scope) => grant.scopes.includes(scope)) + ) { return [ { _tag: "error", @@ -608,7 +611,10 @@ export const make = Effect.gen(function* () { return yield* new BootstrapCredentialProofKeyMismatchError({}); } - if (input?.requestedScopes?.some((scope) => !matching.value.scopes.includes(scope))) { + if ( + input?.requestedScopes !== undefined && + !input.requestedScopes.some((scope) => matching.value.scopes.includes(scope)) + ) { return yield* new BootstrapCredentialScopeNotGrantedError({}); } diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 70cd809b70ac938d605cb644f4f5ede2a8384b41..97a8f8dc0424026e34942f9c4826d9e458a384ee 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -247,7 +247,7 @@ describe("RPC scope middleware", () => { yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip), ).toMatchObject({ _tag: "EnvironmentAuthorizationError", - requiredScope: missing, + requiredPermission: missing, }); expect(handled).toEqual([]); }).pipe(Effect.scoped), @@ -291,7 +291,7 @@ describe("settings mutation authorization", () => { patch: { defaultRuntimeMode: "full-access" }, providerInstanceMutation, }).pipe(Effect.flip), - ).toMatchObject({ requiredScope: AuthSettingsWriteScope }); + ).toMatchObject({ requiredPermission: AuthSettingsWriteScope }); expect(handled).toBe(1); }).pipe(Effect.scoped), ); @@ -317,7 +317,7 @@ describe("settings mutation authorization", () => { patch: {}, providerInstanceMutation, }).pipe(Effect.flip), - ).toMatchObject({ requiredScope: AuthProvidersManageScope }); + ).toMatchObject({ requiredPermission: AuthProvidersManageScope }); expect(handled).toBe(false); }).pipe(Effect.scoped), ); @@ -355,7 +355,7 @@ it.effect("requires task permission before attaching a prepared worktree to a th mode: "worktree", threadId: ThreadId.make("thread"), }).pipe(Effect.flip), - ).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); + ).toMatchObject({ requiredPermission: AuthOrchestrationOperateScope }); expect(handled).toBe(false); }).pipe(Effect.scoped), ); @@ -394,7 +394,10 @@ it.effect("separates host file URLs from readable attachment URLs", () => ] as const) { expect( yield* client[WS_METHODS.assetsCreateUrl]({ resource }).pipe(Effect.flip), - ).toMatchObject({ requiredScope: AuthFilesystemReadScope }); + ).toMatchObject({ + requiredScope: AuthOrchestrationReadScope, + requiredPermission: AuthFilesystemReadScope, + }); } expect(handled).toBe(1); }).pipe(Effect.scoped), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index b4b96f2f57cb360be51c152c6c6319f3c148b800..ee0d24e44d5a49f978ad5b062f49fe8a5ce0531c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -2,6 +2,7 @@ import { CLIENT_GUARDED_RPC_SCOPES, type DeviceListInput, clientRpcRequiredScopes, + authScopeRequiredResponse, AssetCreateUrlInput, AuthAccessReadScope, ServerSettingsPatch, @@ -213,7 +214,7 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, - requiredScope, + ...authScopeRequiredResponse(requiredScope), }); const SettingsUpdate = Schema.Struct({ diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index f7d3e7e1f5ace25f662b9bc4b53b20a7a0951769..374a51be68722a9983be0cd9c5aaa0b5efbc5c30 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -20,6 +20,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { base64UrlDecodeUtf8, base64UrlEncode, signPayload } from "./utils.ts"; const layerServerConfig = (overrides?: Partial) => Layer.effect( @@ -290,6 +291,34 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined(); }).pipe(Effect.provide(layerSessionStore())), ); + it.effect("keeps recorded scopes unchanged for both token versions", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const legacyScopes = ["orchestration:read", "terminal:operate", "review:write"] as const; + const issued = yield* sessions.issue({ subject: "one-time-token", scopes: legacyScopes }); + // Accept prerelease v2 credentials without widening their recorded grant. + const [encodedPayload] = issued.token.split("."); + const currentClaims = base64UrlDecodeUtf8(encodedPayload!); + expect(currentClaims).toContain('"v":1'); + const legacyPayload = base64UrlEncode(currentClaims.replace('"v":1', '"v":2')); + const secret = yield* secrets.getOrCreateRandom("server-signing-key", 32); + const legacyToken = `${legacyPayload}.${signPayload(legacyPayload, secret)}`; + + expect((yield* sessions.verify(issued.token)).scopes).toEqual(legacyScopes); + expect((yield* sessions.verify(legacyToken)).scopes).toEqual(legacyScopes); + }).pipe( + Effect.provide( + SessionStore.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provide(SqlitePersistence.layerMemory), + Layer.provide(layerServerEnvironment(EnvironmentId.make("test-environment"))), + Layer.provide(layerServerConfig()), + ), + ), + ), + ); + it.effect("rejects malformed session tokens", () => 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 007106f25a39c432e3bacfef25cde9e64781abf4..4db7e95e3066198d3b078f622db16269adb6fbfa 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -428,8 +428,9 @@ export class SessionStore extends Context.Service< const SIGNING_SECRET_NAME = "server-signing-key"; const DEFAULT_SESSION_TTL = Duration.days(30); const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); + const SessionClaims = Schema.Struct({ - v: Schema.Literal(1), + v: Schema.Literals([1, 2]), kind: Schema.Literal("session"), sid: AuthSessionId, sub: Schema.String, diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts index e879f0a72ef397ac5e535bfaf36ab0ce98333ead..064578f727a01c28d65c5e1b383c1f0a4abc75fb 100644 --- a/apps/server/src/auth/http.test.ts +++ b/apps/server/src/auth/http.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { AuthSessionId, + AuthTokenExchangeGrantType, + AuthEnvironmentBootstrapTokenType, + AuthAccessTokenType, EnvironmentAuthenticatedAuth, EnvironmentHttpApi, } from "@t3tools/contracts"; @@ -100,6 +103,22 @@ it.effect("sets the selected browser session cookies through the HTTP route", () requestContext, ); expect(devResponse.status).toBe(200); + const retiredScopeResponse = await environmentA.handler( + new Request("http://127.0.0.1/oauth/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: AuthTokenExchangeGrantType, + subject_token: DEV_TOKEN, + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + scope: "review:write", + }), + }), + requestContext, + ); + expect(retiredScopeResponse.status).toBe(400); + expect(await retiredScopeResponse.json()).toMatchObject({ reason: "invalid_scope" }); + const devCookies = devResponse.headers.getSetCookie(); const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_")); expect(devCookie).toContain("HttpOnly"); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index eba89f0a66fa5442bd473b9c2b0b54581451a3f6..c0d2946813f914de325359b83c4a33cc444cabcc 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -1,4 +1,5 @@ import { + authScopeRequiredResponse, AuthAccessReadScope, AuthAccessWriteScope, AuthStandardClientScopes, @@ -18,11 +19,13 @@ import { EnvironmentAuthenticatedPrincipal, } from "@t3tools/contracts"; import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; -import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; +import { parseOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Cookies from "effect/http/Cookies"; import * as HttpEffect from "effect/http/HttpEffect"; @@ -122,7 +125,7 @@ export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope Effect.fail( new EnvironmentScopeRequiredError({ code: "insufficient_scope", - requiredScope, + ...authScopeRequiredResponse(requiredScope), traceId, }), ), @@ -356,11 +359,8 @@ export const layer = HttpApiBuilder.group( const requestedScopes = args.payload.scope === undefined ? undefined - : parseAllowedOAuthScope({ - value: args.payload.scope, - allowedScopes: new Set(AuthGrantScope.literals), - }); - if (requestedScopes === null) { + : (parseOAuthScope(args.payload.scope)?.filter(Schema.is(AuthGrantScope)) ?? null); + if (requestedScopes === null || requestedScopes?.length === 0) { return yield* failEnvironmentInvalidRequest("invalid_scope"); } const proofKeyThumbprint = args.headers.dpop diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index 2ef5ba10a80b2d78f8f0ab71220168ab06d69ce0..2af32d71c8de4e131a18d6308fdbf0f639e7b39c 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -77,7 +77,7 @@ export function formatPairingCredentialList( credentials.map((credential) => ({ id: credential.id, ...(credential.label ? { label: credential.label } : {}), - scopes: credential.scopes, + scopes: credential.permissions ?? credential.scopes, createdAt: toIsoString(credential.createdAt), expiresAt: toIsoString(credential.expiresAt), })), @@ -95,7 +95,7 @@ export function formatPairingCredentialList( .map((credential) => [ `${credential.id}${credential.label ? ` (${credential.label})` : ""}`, - ` scopes: ${credential.scopes.join(" ")}`, + ` scopes: ${(credential.permissions ?? credential.scopes).join(" ")}`, ` created: ${toIsoString(credential.createdAt)}`, ` expires: ${toIsoString(credential.expiresAt)}`, ].join(newline), diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index fa10dd7493c2996b5e8c27459d87f976d5c2df9c..ca18088b4bf70d01559b911af30ba1dc16a0a372 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -172,13 +172,13 @@ export const make = Effect.gen(function* () { proof_key_thumbprint IS NULL OR proof_key_thumbprint = ${proofKeyThumbprint} ) - AND NOT EXISTS ( + AND (${requestedScopes === undefined} OR EXISTS ( SELECT 1 FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested - WHERE requested.value NOT IN ( + WHERE requested.value IN ( SELECT value FROM json_each(auth_pairing_links.scopes) ) - ) + )) RETURNING id AS "id", credential AS "credential", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4cba3fca0197890c91e7104d004478fca42278d1..fab4edb444063b8518d9c78fb8eb2a7b491af2c7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -24,6 +24,7 @@ import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AcpRegistryOperationError, CommandId, + authScopeResponse, AuthAccessStreamError, type AuthAccessStreamEvent, AuthOrchestrationOperateScope, @@ -561,7 +562,7 @@ function toAuthAccessStreamEvent( version: 1, revision, type: "pairingLinkUpserted", - payload: change.pairingLink, + payload: { ...change.pairingLink, ...authScopeResponse(change.pairingLink.scopes) }, }; case "pairingLinkRemoved": return { @@ -577,6 +578,7 @@ function toAuthAccessStreamEvent( type: "clientUpserted", payload: { ...change.clientSession, + ...authScopeResponse(change.clientSession.scopes), current: change.clientSession.sessionId === currentSessionId, }, }; diff --git a/apps/web/src/components/PermissionUpdateNotice.test.tsx b/apps/web/src/components/PermissionUpdateNotice.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f96a2d410f16fbb3f137d4e1a79bdd54b8ca19a6 --- /dev/null +++ b/apps/web/src/components/PermissionUpdateNotice.test.tsx @@ -0,0 +1,111 @@ +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { beforeEach, afterEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + id: 0, + saved: false, + session: { + _tag: "Success", + waiting: false, + value: { authenticated: true, permissions: ["orchestration:operate"] }, + }, + add: vi.fn((_notice: unknown) => "notice"), + close: vi.fn(), + save: vi.fn(), + navigate: vi.fn(), +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.session })); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); +vi.mock("../state/environments", () => ({ + useEnvironments: () => ({ + environments: [{ environmentId: `env-${state.id}`, label: "Work laptop" }], + }), +})); +vi.mock("../state/session", () => ({ environmentSession: { sessionStateAtom: () => null } })); +vi.mock("../hooks/useLocalStorage", () => ({ + getLocalStorageItem: () => state.saved, + setLocalStorageItem: (...args: unknown[]) => state.save(...args), +})); +vi.mock("./ui/toast", () => ({ toastManager: { add: state.add, close: state.close } })); +import { PermissionUpdateNotice } from "./PermissionUpdateNotice"; + +let renderer: ReactTestRenderer; +beforeEach(() => { + state.id++; + state.saved = false; + state.session = { + _tag: "Success", + waiting: false, + value: { authenticated: true, permissions: ["orchestration:operate"] }, + }; + vi.clearAllMocks(); +}); +afterEach(async () => { + if (renderer) await act(async () => renderer.unmount()); +}); +async function render() { + await act(async () => { + renderer = create(); + }); +} + +it("keeps the notice visible and persists dismissal, with a route to pairing settings", async () => { + await render(); + const notice = state.add.mock.calls[0]![0] as unknown as { + timeout: number; + onClose: () => void; + actionProps: { onClick: () => void }; + }; + expect(notice.timeout).toBe(0); + expect(state.save).not.toHaveBeenCalled(); + notice.actionProps.onClick(); + expect(state.navigate).toHaveBeenCalledWith({ to: "/settings/connections" }); + expect(state.close).toHaveBeenCalledWith("notice"); + expect(state.save).toHaveBeenCalledWith( + `t3code:permission-update:v1:env-${state.id}`, + true, + expect.anything(), + ); +}); +it("does not repeat on session refresh or remount", async () => { + await render(); + await act(async () => renderer.unmount()); + state.session = { ...state.session }; + await render(); + expect(state.add).toHaveBeenCalledTimes(1); +}); +it("skips a notice dismissed on an earlier launch", async () => { + state.saved = true; + await render(); + expect(state.add).not.toHaveBeenCalled(); +}); +it("waits for a successful, settled session check", async () => { + state.session._tag = "Failure"; + await render(); + expect(state.add).not.toHaveBeenCalled(); + state.session = { ...state.session, _tag: "Success", waiting: true }; + await act(async () => renderer.update()); + expect(state.add).not.toHaveBeenCalled(); + state.session = { ...state.session, waiting: false }; + await act(async () => renderer.update()); + expect(state.add).toHaveBeenCalledTimes(1); +}); +it("does not warn for a granular grant", async () => { + state.session.value.permissions.push("filesystem:read"); + await render(); + expect(state.add).not.toHaveBeenCalled(); +}); + +it("persists the secondary dismissal without relying on the toast close callback", async () => { + await render(); + const notice = state.add.mock.calls[0]![0] as { + data: { secondaryActionProps: { onClick: () => void } }; + }; + notice.data.secondaryActionProps.onClick(); + expect(state.save).toHaveBeenCalledWith( + `t3code:permission-update:v1:env-${state.id}`, + true, + expect.anything(), + ); + expect(state.close).toHaveBeenCalledWith("notice"); +}); diff --git a/apps/web/src/components/PermissionUpdateNotice.tsx b/apps/web/src/components/PermissionUpdateNotice.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c01afe2ccfe7a75b5dc5dd9d9670a3e5c312723 --- /dev/null +++ b/apps/web/src/components/PermissionUpdateNotice.tsx @@ -0,0 +1,78 @@ +import { useAtomValue } from "@effect/atom-react"; +import { sessionHasLegacyPermissions, type EnvironmentId } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; +import { useEffect } from "react"; + +import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; +import { useEnvironments } from "../state/environments"; +import { environmentSession } from "../state/session"; +import { toastManager } from "./ui/toast"; + +// Keep an active toast across remounts, including Strict Mode effect replay. +const shown = new Set(); + +function EnvironmentPermissionNotice({ + environmentId, + label, +}: { + environmentId: EnvironmentId; + label: string; +}) { + const session = useAtomValue(environmentSession.sessionStateAtom(environmentId)); + const navigate = useNavigate(); + useEffect(() => { + if ( + session._tag !== "Success" || + session.waiting || + !sessionHasLegacyPermissions(session.value) + ) + return; + if (shown.has(environmentId)) return; + const key = `t3code:permission-update:v1:${environmentId}`; + try { + if (getLocalStorageItem(key, Schema.Boolean)) return; + } catch { + // An unavailable store must not prevent the notice. + } + shown.add(environmentId); + const persistDismissal = () => { + try { + setLocalStorageItem(key, true, Schema.Boolean); + } catch { + // The in-memory marker still prevents repeats during this launch. + } + }; + const dismiss = () => { + persistDismissal(); + toastManager.close(id); + }; + const id = toastManager.add({ + title: `Permissions have changed for ${label}`, + description: + "This connection still uses the old permissions, so some actions may no longer be available. Pair again using a new link with the permissions you need.", + timeout: 0, + onClose: persistDismissal, + actionProps: { + children: "Open Connections", + onClick: () => { + dismiss(); + void navigate({ to: "/settings/connections" }); + }, + }, + data: { + actionLayout: "stacked-end", + secondaryActionProps: { children: "Dismiss", onClick: dismiss }, + secondaryActionVariant: "ghost", + }, + }); + }, [environmentId, label, navigate, session]); + return null; +} + +export function PermissionUpdateNotice() { + const { environments } = useEnvironments(); + return environments.map(({ environmentId, label }) => ( + + )); +} diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 0133619157949ef258ff8c7305c43f06710dc016..e40d4f5d0792d2d1af1c39a6a210664328ceb76a 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -1,5 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; -import { AuthOrchestrationOperateScope, type AuthSessionState } from "@t3tools/contracts"; +import { + AuthEnvironmentMaintainScope, + type AuthSessionState, + sessionGrantsScope, +} from "@t3tools/contracts"; import type { AsyncResult } from "effect/reactivity"; import { environmentSession } from "~/state/session"; import type { @@ -162,13 +166,7 @@ export function ServerUpdatesAction({ function canUpdateServer(result: AsyncResult.AsyncResult): boolean { if (result._tag !== "Success" || !result.value.authenticated) return false; - const session = result.value; - // Only self-update bridges the old authorization protocol. Upgraded servers - // advertise the new scope even when this client's grant predates it. - return ( - session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) === - true - ); + return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope); } /** diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index 28e13b49443acc6189ab0e75c84325d87dcaee34..4e1c92f5ffaf23de5871636034b0950bd6224c4e 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -11,6 +11,8 @@ import { type AuthSessionState, type ContextMenuItem, type EnvironmentId, + sessionGrantsScope, + type SessionGrantInput, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; @@ -43,11 +45,8 @@ function mediaFileName(source: MediaActionSource): string { } /** An explicit action may ask the server while its grant is still unresolved. */ -function allowsHostMedia(session: Pick | null) { - return ( - session === null || - (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true) - ); +function allowsHostMedia(session: SessionGrantInput | null) { + return session === null || sessionGrantsScope(session, AuthFilesystemReadScope); } function canReadHostMedia(environmentId: EnvironmentId | null): boolean { diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 1520097eda7e4338cf4028fddbdd49d2087f67f5..5877f1bbda6c838828dc4df29ee38d9fa257eb6f 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -534,6 +534,7 @@ function sortDesktopClientSessions(sessions: ReadonlyArray { it("does not treat the old orchestration grant as provider management", () => { expect( resolveRemoteOperateAccess({ - session: { authenticated: true, scopes: ["orchestration:operate"] }, + session: { + authenticated: true, + scopes: ["orchestration:operate"], + auth: { serverUpdateScope: "environment:maintain" }, + }, isPending: false, hasError: false, }), ).toBe("denied"); }); + + it("accepts the orchestration grant from a server that predates providers:manage", () => { + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: ["orchestration:operate"], auth: {} }, + isPending: false, + hasError: false, + }), + ).toBe("granted"); + }); it("derives access from the environment session's granted scopes", () => { expect( resolveRemoteOperateAccess({ diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 4e7faab872d1c4d8379a94416590723791110990..6e05957c76b55013fc05eec00c79ec711f4fb4b9 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -3,6 +3,8 @@ import { AuthProvidersManageScope, type AuthSessionState, type EnvironmentId, + sessionGrantsScope, + type SessionGrantInput, } from "@t3tools/contracts"; export interface ProviderEnvironmentOptionLike { @@ -76,21 +78,19 @@ export type ProviderOperateAccess = "granted" | "denied" | "pending"; /** Cached grants remain usable during revalidation; unknown or failed lookups grant nothing. */ function resolveSessionOperateAccess(input: { - readonly session: Pick | null; + readonly session: SessionGrantInput | null; readonly isPending: boolean; readonly hasError: boolean; }): ProviderOperateAccess { if (input.hasError) return "denied"; if (input.session === null) return input.isPending ? "pending" : "denied"; - return input.session.authenticated && input.session.scopes?.includes(AuthProvidersManageScope) - ? "granted" - : "denied"; + return sessionGrantsScope(input.session, AuthProvidersManageScope) ? "granted" : "denied"; } export function resolvePrimaryOperateAccess(input: { readonly isPrimary: boolean; readonly hasDesktopBridge: boolean; - readonly session: Pick | null; + readonly session: SessionGrantInput | null; readonly isPending: boolean; readonly hasError: boolean; }): ProviderOperateAccess { @@ -98,7 +98,7 @@ export function resolvePrimaryOperateAccess(input: { } export function resolveRemoteOperateAccess(input: { - readonly session: Pick | null; + readonly session: SessionGrantInput | null; readonly isPending: boolean; readonly hasError: boolean; }): ProviderOperateAccess { diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index 50d0953ce8424e2a7f5ef0a71c26f6a32fd660d5..4fe68f66c8a3a6e48fd51782f9fa4c17685aa311 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { AuthSettingsWriteScope, type EnvironmentId } from "@t3tools/contracts"; +import { AuthSettingsWriteScope, type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -54,7 +54,7 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => ? session.waiting ? "pending" : "denied" - : sessionData.authenticated && sessionData.scopes?.includes(AuthSettingsWriteScope) + : sessionGrantsScope(sessionData, AuthSettingsWriteScope) ? "granted" : "denied"; return { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 2e089668ccc992dfef7379c8dc5b32e313f3b565..04fab1c1d1c0bdad54558851e078e5f32ce05be7 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -19,6 +19,7 @@ import { type ProviderInstanceMutation, ServerSettings, type ServerSettingsPatch, + sessionGrantsScope, } from "@t3tools/contracts"; import { type ClientSettingsPatch, @@ -446,7 +447,7 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray ({ : AsyncResult.success({ authenticated: true, scopes: [...(state.scopes.get(environmentId) ?? [])], + auth: { serverUpdateScope: "environment:maintain" }, }), })); vi.mock("../state/threads", () => ({ diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 6592a48a1f4c06a32a0feaa4d325eba3693852a0..cab4dde3dd204ae6ab7c11474c62d5a59373644e 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -14,6 +14,7 @@ import { EnvironmentId, type ScopedThreadRef, ThreadId, + sessionGrantsScope, } from "@t3tools/contracts"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; @@ -496,8 +497,7 @@ export function useThreadActions() { if (permissionFailure) return permissionFailure; canDeleteWorktree = sessionResult._tag === "Success" && - sessionResult.value.authenticated && - sessionResult.value.scopes?.includes(AuthSourceControlWriteScope) === true; + sessionGrantsScope(sessionResult.value, AuthSourceControlWriteScope); } let shouldDeleteWorktree = false; const environmentSettings = environmentConfig?.settings; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2418fb1132fa111e9b67390358ccaf799629fa4e..d1c4ef730c86ac1ca6d33bd89672e32afe390306 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,3 +1,4 @@ +import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice"; import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { @@ -237,6 +238,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index a2c00df4f26f6b5fa465c1f56c15a383ee70f44b..883a899ef2c0b328c9b1fc7ec8721b5366fdd8f6 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -1,6 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; -import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts"; +import { + type AuthEnvironmentScope, + type AuthSessionState, + type EnvironmentId, + sessionGrantsScope, +} from "@t3tools/contracts"; import { useMemo } from "react"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -22,12 +27,15 @@ export function useEnvironmentScope( ? EMPTY_SESSION_STATE_ATOM : environmentSession.sessionStateAtom(environmentId), ); + return sessionHasScope(result, scope); +} + +function sessionHasScope( + result: AsyncResult.AsyncResult, + scope: AuthEnvironmentScope, +): boolean { const session = Option.getOrNull(AsyncResult.value(result)); - return ( - result._tag !== "Failure" && - session?.authenticated === true && - session.scopes?.includes(scope) === true - ); + return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope); } /** Subscribe to the grants of every selected environment. */ @@ -41,12 +49,7 @@ export function useEnvironmentsWithScope( const ids = new Set(); for (const { environmentId } of environments) { const result = get(environmentSession.sessionStateAtom(environmentId)); - const session = Option.getOrNull(AsyncResult.value(result)); - if ( - result._tag !== "Failure" && - session?.authenticated === true && - session.scopes?.includes(scope) - ) { + if (sessionHasScope(result, scope)) { ids.add(environmentId); } } @@ -61,12 +64,9 @@ export function readEnvironmentScope( environmentId: EnvironmentId, scope: AuthEnvironmentScope, ): boolean { - const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); - const session = Option.getOrNull(AsyncResult.value(result)); - return ( - result._tag !== "Failure" && - session?.authenticated === true && - session.scopes?.includes(scope) === true + return sessionHasScope( + appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)), + scope, ); } diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index d83ad25ad7a45087fbef88941ddfb6d784e71d5e..2526ee69caf9b5302bb3691761d989561d1b81fa 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -58,10 +58,18 @@ extra authority: [every RPC declares a required scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC group's `RpcScopeAuthorization` middleware checks it before any handler runs. -Self-update must work across authorization protocol changes. New servers advertise -`auth.serverUpdateScope`; only an older server that omits it uses -`orchestration:operate` for updates. An unchanged grant on an upgraded server must -still include `environment:maintain`. +Scope changes must not prevent older clients from connecting. Token exchange +intersects recognized requests with the pairing grant; retired and unknown names +are dropped. A request with no granted scopes fails before consuming the link. +Stored credentials are never expanded when scopes split. + +Auth responses keep `scopes` within the original wire vocabulary and include +`permissions` for the exact grant. New clients use `permissions` when present, +even if empty. Older servers omit it, so clients use legacy parent checks for +features those servers already support. These client checks never change server +authorization. Permission errors likewise retain a legacy `requiredScope` and +add the exact `requiredPermission`, so a denied RPC stays decodable by old clients. +Unknown response permissions are ignored; grant inputs stay strict. Desktop restarts forget the previous local bearer token, so its reusable bootstrap grant replaces earlier sessions for the same subject and method. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 8f9544a324189dcddb43bba53bb12b2a26dac477..f4f36d929d4f2bb9c375cc7fd9fb8ff79aa9dd0b 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -289,9 +289,10 @@ and the agent it runs can use Git however the environment allows. Settings changes, provider management, and environment maintenance can be granted separately from access administration. New standard pairings include these -permissions. Existing clients keep their original grants after an update; to -receive newly separated permissions, pair the client again with the scopes it -needs. Reconnecting or refreshing a session does not expand its grant. +permissions. Existing clients can stay connected after an update, but newly separated +features may require pairing again with the permissions they need. Older clients +may show controls that the server denies. Create a fresh pairing link to change +a client's permissions. `filesystem:read` allows browsing host files, opening workspace files, and viewing local changes. Add `filesystem:write` to allow editing files or saving plans to diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 1bbd507bb7571eac89c291054bc68d546d872814..483dff47a4e574d4d259a254102e4e35e1d33a91 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -1,6 +1,7 @@ import { EnvironmentAuthorizationError, clientRpcRequiredScopes, + authScopeRequiredResponse, type EnvironmentId, type ClientGuardedRpcTag, ORCHESTRATION_V2_WS_METHODS, @@ -165,7 +166,7 @@ export class RpcPermissionGuard extends Context.Reference<{ ? Effect.void : Effect.fail( new EnvironmentAuthorizationError({ - requiredScope: scope, + ...authScopeRequiredResponse(scope), message: `This connection requires ${scope}.`, }), ); diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 3ae08c2e38c1289397ba37a42f9521fcb191c0ac..b9d1ebf6b73884be50dd237a79b62ea087117345 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -1,3 +1,9 @@ +import { requestGuarded, runStreamGuarded } from "../rpc/client.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import type { RpcSession } from "../rpc/session.ts"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { @@ -36,6 +42,7 @@ const grant = (allowed: boolean): AuthSessionState => ({ sessionCookieName: "test", }, scopes: allowed ? [AuthOrchestrationOperateScope] : [], + permissions: allowed ? [AuthOrchestrationOperateScope] : [], }); const runtime = Atom.runtime( Layer.succeed(EnvironmentRegistry, { @@ -161,12 +168,16 @@ it.effect( const registry = yield* setup; registry.set(sessions(env), AsyncResult.success(grant(true))); const git = createCommandPermissions(runtime, WS_METHODS.vcsInit); - expect((yield* git.authorize(registry, env).pipe(Effect.flip)).requiredScope).toBe( + expect((yield* git.authorize(registry, env).pipe(Effect.flip)).requiredPermission).toBe( AuthSourceControlWriteScope, ); registry.set( sessions(env), - AsyncResult.success({ ...grant(false), scopes: [AuthSourceControlWriteScope] }), + AsyncResult.success({ + ...grant(false), + scopes: [AuthSourceControlWriteScope], + permissions: [AuthSourceControlWriteScope], + }), ); yield* git.authorize(registry, env); const prepare = createCommandPermissions(runtime, WS_METHODS.gitPreparePullRequestThread); @@ -186,6 +197,7 @@ it.effect( AsyncResult.success({ ...grant(true), scopes: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope], + permissions: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope], }), ); expect(registry.get(prepare.permissionAtom(env, input))).toBe(true); @@ -193,3 +205,59 @@ it.effect( }), ), ); + +it.effect("honors exact empty permissions and preserves legacy parent grants", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const git = createCommandPermissions(runtime, WS_METHODS.vcsInit); + registry.set(sessions(env), AsyncResult.success({ ...grant(true), permissions: [] })); + expect(registry.get(git.permissionAtom(env))).toBe(false); + const denied = yield* git.authorize(registry, env).pipe(Effect.flip); + expect(denied).toMatchObject({ + requiredPermission: AuthSourceControlWriteScope, + requiredScope: AuthOrchestrationOperateScope, + }); + const { permissions: _exact, ...legacy } = grant(true); + registry.set(sessions(env), AsyncResult.success(legacy)); + expect(registry.get(git.permissionAtom(env))).toBe(true); + yield* git.authorize(registry, env); + }), + ), +); + +it.effect("rejects protected unary and streamed RPCs outside a guarded command", () => + Effect.gen(function* () { + let writes = 0; + const session = { + client: { + [WS_METHODS.scheduledTasksDelete]: () => + Effect.sync(() => { + writes++; + return { id: ScheduledTaskId.make("task") }; + }), + [WS_METHODS.gitRunStackedAction]: () => + Stream.fromEffect( + Effect.sync(() => { + writes++; + }), + ), + }, + } as unknown as RpcSession; + const supervisor = { + target: { environmentId: env, label: "target" }, + session: yield* SubscriptionRef.make(Option.some(session)), + } as unknown as EnvironmentSupervisor["Service"]; + const unary = yield* requestGuarded(WS_METHODS.scheduledTasksDelete, { + id: ScheduledTaskId.make("task"), + }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor), Effect.flip); + expect(unary._tag).toBe("EnvironmentAuthorizationError"); + const streamed = yield* runStreamGuarded(WS_METHODS.gitRunStackedAction, { + actionId: "test-action", + cwd: "/repo", + action: "commit", + }).pipe(Stream.runDrain, Effect.provideService(EnvironmentSupervisor, supervisor), Effect.flip); + expect(streamed._tag).toBe("EnvironmentAuthorizationError"); + expect(writes).toBe(0); + }), +); diff --git a/packages/client-runtime/src/state/commandPermissions.ts b/packages/client-runtime/src/state/commandPermissions.ts index c6dccd2908174ad72c5690e2db080e7041ca55ba..289757bc6ab560d02cb569cf646e97b9a416fce8 100644 --- a/packages/client-runtime/src/state/commandPermissions.ts +++ b/packages/client-runtime/src/state/commandPermissions.ts @@ -1,5 +1,7 @@ import { clientRpcRequiredScopes, + sessionGrantsScope, + authScopeRequiredResponse, EnvironmentAuthorizationError, type EnvironmentId, type AuthSessionState, @@ -11,10 +13,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { createEnvironmentSessionAtoms } from "./session.ts"; -function grants(session: AuthSessionState, scope: AuthEnvironmentScope) { - return session.authenticated && session.scopes?.includes(scope) === true; -} - /** UI availability and dispatch use the same target session and method policy. */ function makeCommandPermissions( runtime: Atom.AtomRuntime, @@ -33,7 +31,7 @@ function makeCommandPermissions( return ( result._tag !== "Failure" && session !== null && - scopes.every((scope) => grants(session, scope)) + scopes.every((scope) => sessionGrantsScope(session, scope)) ); }), ), @@ -59,12 +57,12 @@ function makeCommandPermissions( Effect.catch(() => Effect.succeed(Option.none())), ); const missing = scopes.find( - (scope) => Option.isNone(session) || !grants(session.value, scope), + (scope) => Option.isNone(session) || !sessionGrantsScope(session.value, scope), ); if (missing !== undefined) return yield* Effect.fail( new EnvironmentAuthorizationError({ - requiredScope: missing, + ...authScopeRequiredResponse(missing), message: `This connection requires ${missing}.`, }), ); diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index 2fc46a760e17f35b42950eb208218b816e521dff..b674b89532495ed63bbb256b93147a92b686fe45 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -9,6 +9,9 @@ import { resolveFilesystemReadAccess, } from "./filesystem.ts"; +/** A server that already splits scopes; it never falls back to a parent grant. */ +const SPLIT_SCOPES_SERVER = { serverUpdateScope: "environment:maintain" } as const; + describe("filesystem read access", () => { it("waits for the initial catalog before declaring a missing environment disconnected", () => { expect( @@ -100,8 +103,8 @@ describe("filesystem read access", () => { ); it.each([ - { authenticated: true, scopes: [AuthOrchestrationReadScope] }, - { authenticated: false, scopes: [AuthFilesystemReadScope] }, + { authenticated: true, scopes: [AuthOrchestrationReadScope], auth: SPLIT_SCOPES_SERVER }, + { authenticated: false, scopes: [AuthFilesystemReadScope], auth: SPLIT_SCOPES_SERVER }, ] as const)("does not infer file access from an ungranted session", (session) => { expect( resolveFilesystemReadAccess({ @@ -112,6 +115,17 @@ describe("filesystem read access", () => { }), ).toEqual({ canReadFiles: false, isPending: false, error: null }); }); + + it("falls back to the orchestration grant on a server that predates filesystem:read", () => { + expect( + resolveFilesystemReadAccess({ + isCatalogReady: true, + connection: { phase: "connected", error: null }, + session: { authenticated: true, scopes: [AuthOrchestrationReadScope], auth: {} }, + sessionError: null, + }), + ).toEqual({ canReadFiles: true, isPending: false, error: null }); + }); }); describe("filesystem browse model", () => { diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index 293b66b3b3b081426f0605f46bb22212f6685b7f..b9ceb665d9a470bb1ab03ae1c64a574518265882 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -3,6 +3,8 @@ import { type AuthSessionState, type FilesystemBrowseEntry, WS_METHODS, + sessionGrantsScope, + type SessionGrantInput, } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; @@ -24,7 +26,7 @@ import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; export function resolveFilesystemReadAccess(input: { readonly isCatalogReady: boolean; readonly connection: Pick | null; - readonly session: Pick | null; + readonly session: SessionGrantInput | null; readonly sessionError: string | null; }) { if (input.sessionError !== null) { @@ -45,9 +47,7 @@ export function resolveFilesystemReadAccess(input: { }; } return { - canReadFiles: - input.session.authenticated && - input.session.scopes?.includes(AuthFilesystemReadScope) === true, + canReadFiles: sessionGrantsScope(input.session, AuthFilesystemReadScope), isPending: false, error: null, }; diff --git a/packages/client-runtime/src/state/usageAccess.test.ts b/packages/client-runtime/src/state/usageAccess.test.ts index 1280a61fe943b931f6a6b5ef3003f1f0a05a8bf1..82dee473360f2858fa5232b0f9d13b9a485454d2 100644 --- a/packages/client-runtime/src/state/usageAccess.test.ts +++ b/packages/client-runtime/src/state/usageAccess.test.ts @@ -39,7 +39,11 @@ describe("resolveUsageAccess", () => { it("distinguishes a failed check from a resolved grant without diagnostics access", () => { const denied = resolveUsageAccess({ connectionPhase: "connected", - session: { authenticated: true, scopes: [AuthOrchestrationReadScope] }, + session: { + authenticated: true, + scopes: [AuthOrchestrationReadScope], + auth: { serverUpdateScope: "environment:maintain" }, + }, hasSessionError: false, }); const failed = resolveUsageAccess({ diff --git a/packages/client-runtime/src/state/usageAccess.ts b/packages/client-runtime/src/state/usageAccess.ts index 66ddf49441431875b72387b58d35295355667166..19d497f3f191260b65894c0b17b9941104714f48 100644 --- a/packages/client-runtime/src/state/usageAccess.ts +++ b/packages/client-runtime/src/state/usageAccess.ts @@ -1,10 +1,15 @@ -import { AuthDiagnosticsReadScope, type AuthSessionState } from "@t3tools/contracts"; +import { + AuthDiagnosticsReadScope, + type AuthSessionState, + sessionGrantsScope, + type SessionGrantInput, +} from "@t3tools/contracts"; import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; export function resolveUsageAccess(input: { readonly connectionPhase: EnvironmentConnectionPhase; - readonly session: Pick | null; + readonly session: SessionGrantInput | null; readonly hasSessionError: boolean; }) { if (input.hasSessionError) { @@ -27,9 +32,7 @@ export function resolveUsageAccess(input: { error: isPending ? null : "This environment is not connected.", }; } - const canReadDiagnostics = - input.session.authenticated && - input.session.scopes?.includes(AuthDiagnosticsReadScope) === true; + const canReadDiagnostics = sessionGrantsScope(input.session, AuthDiagnosticsReadScope); return { canReadDiagnostics, isPending: false, diff --git a/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts index 12cf77411c7322b7952ab86d53eed8c5171bba82..5d8fb43afe615caafc086adfdab5c000f728e748 100644 --- a/packages/contracts/src/auth.test.ts +++ b/packages/contracts/src/auth.test.ts @@ -1,7 +1,17 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; -import { AuthEnvironmentScopes, AuthGrantScopes, AuthStandardClientScopes } from "./auth.ts"; +import { + AuthEnvironmentScopes, + AuthEnvironmentScope, + authScopeRequiredResponse, + AuthGrantScopes, + AuthStandardClientScopes, + authScopeResponse, + AuthSessionState, + sessionGrantsScope, + sessionHasLegacyPermissions, +} from "./auth.ts"; describe("authorization grants", () => { it("decodes legacy review credentials without offering them in new grants", () => { @@ -11,4 +21,136 @@ describe("authorization grants", () => { expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow(); expect(AuthStandardClientScopes).not.toContain("review:write"); }); + + // Frozen vocabulary from the client before granular scopes shipped. Do not + // derive it from the current enum: that would hide compatibility regressions. + const oldScopes = Schema.Array( + Schema.Literals([ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "access:read", + "access:write", + "relay:read", + "relay:write", + ]), + ); + + const decodeOldScopes = Schema.decodeUnknownSync(oldScopes); + + it.each(AuthEnvironmentScope.literals)( + "keeps %s permission errors decodable by old clients", + (scope) => { + const response = authScopeRequiredResponse(scope); + expect(decodeOldScopes([response.requiredScope])).toEqual([response.requiredScope]); + expect(response.requiredPermission).toBe(scope); + }, + ); + + it("keeps old clients able to decode grants with new permissions", () => { + const response = authScopeResponse(AuthStandardClientScopes); + expect(decodeOldScopes(response.scopes)).toEqual(response.scopes); + expect(response.permissions).toEqual(AuthStandardClientScopes); + expect(response.scopes).not.toContain("filesystem:read"); + }); + + it("ignores unknown response permissions without falling back to broader scopes", () => { + const session = Schema.decodeUnknownSync(AuthSessionState)({ + authenticated: true, + auth: { + policy: "loopback-browser", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "session", + }, + scopes: ["orchestration:operate"], + permissions: ["future:permission"], + }); + expect(session.permissions).toEqual([]); + expect(sessionGrantsScope(session, "orchestration:operate")).toBe(false); + expect(sessionGrantsScope(session, "settings:write")).toBe(false); + }); + + it.each([ + { + label: "exact permissions over the legacy presentation", + session: { + authenticated: true, + scopes: ["orchestration:operate"], + permissions: ["filesystem:read"], + }, + scope: "settings:write", + expected: false, + }, + { + label: "a permission absent from the legacy presentation", + session: { authenticated: true, scopes: [], permissions: ["filesystem:read"] }, + scope: "filesystem:read", + expected: true, + }, + + { + label: "the parent on a server that predates the split", + session: { authenticated: true, scopes: ["orchestration:operate"], auth: {} }, + scope: "settings:write", + expected: true, + }, + { + label: "only the exact scope on a server that knows the split", + session: { + authenticated: true, + scopes: ["orchestration:operate"], + auth: { serverUpdateScope: "environment:maintain" }, + }, + scope: "settings:write", + expected: false, + }, + { + label: "the exact scope regardless of server version", + session: { authenticated: true, scopes: ["settings:write"], auth: {} }, + scope: "settings:write", + expected: true, + }, + { + label: "nothing for an unauthenticated session", + session: { authenticated: false, scopes: ["orchestration:operate"], auth: {} }, + scope: "settings:write", + expected: false, + }, + { + label: "no parent for scopes that were never split", + session: { authenticated: true, scopes: ["orchestration:operate"], auth: {} }, + scope: "access:write", + expected: false, + }, + ] as const)("sessionGrantsScope accepts $label", ({ session, scope, expected }) => { + expect(sessionGrantsScope(session, scope)).toBe(expected); + }); +}); + +describe("legacy permission notice", () => { + it.each(["orchestration:read", "orchestration:operate", "terminal:operate"] as const)( + "recognizes an old %s grant on an upgraded server", + (scope) => + expect(sessionHasLegacyPermissions({ authenticated: true, permissions: [scope] })).toBe(true), + ); + it("waits for a new server and an authenticated session", () => { + expect( + sessionHasLegacyPermissions({ authenticated: true, scopes: ["orchestration:operate"] }), + ).toBe(false); + expect( + sessionHasLegacyPermissions({ authenticated: false, permissions: ["orchestration:operate"] }), + ).toBe(false); + }); + it("skips new grants and old grants that lost no implied permissions", () => { + for (const permissions of [ + AuthStandardClientScopes, + ["orchestration:read", "filesystem:read"] as const, + ["access:read"] as const, + [], + ]) { + expect(sessionHasLegacyPermissions({ authenticated: true, permissions })).toBe(false); + } + }); }); diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 5df81853adbf6296b9c7a91667cc31f7655948ab..f0478da0c8aa1f6fa5c44cab4ddb6b2a57031bb9 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -3,6 +3,7 @@ import * as HttpApiSchema from "effect/http-api/HttpApiSchema"; import { AuthSessionId, + ForwardCompatibleArray, ClientSurface, ClientWebDeployment, TrimmedNonEmptyString, @@ -126,6 +127,81 @@ export type AuthGrantScope = typeof AuthGrantScope.Type; export const AuthGrantScopes = Schema.Array(AuthGrantScope); export type AuthGrantScopes = typeof AuthGrantScopes.Type; +// Frozen wire vocabulary for clients released before granular permissions. +const legacyScopes = new Set([ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + AuthTerminalOperateScope, + AuthReviewWriteScope, + AuthAccessReadScope, + AuthAccessWriteScope, + AuthRelayReadScope, + AuthRelayWriteScope, +]); + +/** Format public auth metadata without changing the server's authorization grant. */ +export function authScopeResponse(scopes: ReadonlyArray) { + return { scopes: scopes.filter((scope) => legacyScopes.has(scope)), permissions: scopes }; +} + +const authScopeResponseFields = { + scopes: AuthEnvironmentScopes, + permissions: Schema.optionalKey(ForwardCompatibleArray(AuthEnvironmentScope)), +}; + +// Only clients talking to an old server use these parent checks. Servers never +// expand stored grants, and an explicitly empty permissions array grants nothing. +const legacyParents: Partial> = { + [AuthFilesystemReadScope]: AuthOrchestrationReadScope, + [AuthDiagnosticsReadScope]: AuthOrchestrationReadScope, + [AuthSettingsWriteScope]: AuthOrchestrationOperateScope, + [AuthProvidersManageScope]: AuthOrchestrationOperateScope, + [AuthEnvironmentMaintainScope]: AuthOrchestrationOperateScope, + [AuthPreviewOperateScope]: AuthOrchestrationOperateScope, + [AuthSourceControlWriteScope]: AuthOrchestrationOperateScope, + [AuthFilesystemWriteScope]: AuthOrchestrationOperateScope, + [AuthTerminalReadScope]: AuthTerminalOperateScope, +}; + +/** Keep permission denials decodable by clients with the original scope enum. */ +export function authScopeRequiredResponse(requiredPermission: AuthEnvironmentScope) { + return { + requiredScope: legacyParents[requiredPermission] ?? requiredPermission, + requiredPermission, + }; +} + +export interface SessionGrantInput { + readonly authenticated: boolean; + readonly scopes?: ReadonlyArray | undefined; + readonly permissions?: ReadonlyArray | undefined; + readonly auth?: { readonly serverUpdateScope?: string | undefined } | undefined; +} + +export function sessionGrantsScope( + session: SessionGrantInput, + scope: AuthEnvironmentScope, +): boolean { + if (!session.authenticated) return false; + if (session.permissions !== undefined) return session.permissions.includes(scope); + if (session.scopes?.includes(scope)) return true; + // Also recognize servers from the first granular-scope release. + if (session.auth?.serverUpdateScope !== undefined) return false; + const parent = legacyParents[scope]; + return parent !== undefined && session.scopes?.includes(parent) === true; +} + +/** Old-only grants lost the child permissions formerly implied by their broad scopes. */ +export function sessionHasLegacyPermissions(session: SessionGrantInput): boolean { + const permissions = session.permissions; + return ( + session.authenticated && + permissions !== undefined && + permissions.every((scope) => legacyScopes.has(scope)) && + Object.values(legacyParents).some((parent) => permissions.includes(parent)) + ); +} + export const AuthStandardClientScopes = [ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, @@ -191,7 +267,7 @@ export type AuthBrowserSessionRequest = typeof AuthBrowserSessionRequest.Type; export const AuthBrowserSessionResult = Schema.Struct({ authenticated: Schema.Literal(true), - scopes: AuthEnvironmentScopes, + ...authScopeResponseFields, sessionMethod: ServerAuthSessionMethod, expiresAt: Schema.DateTimeUtc, }); @@ -257,7 +333,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ // Read models contain metadata only. Credentials are returned by creation alone. export const AuthPairingLink = Schema.Struct({ id: TrimmedNonEmptyString, - scopes: AuthEnvironmentScopes, + ...authScopeResponseFields, subject: TrimmedNonEmptyString, label: Schema.optionalKey(TrimmedNonEmptyString), createdAt: Schema.DateTimeUtc, @@ -278,7 +354,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type; export const AuthClientSession = Schema.Struct({ sessionId: AuthSessionId, subject: TrimmedNonEmptyString, - scopes: AuthEnvironmentScopes, + ...authScopeResponseFields, method: ServerAuthSessionMethod, client: AuthClientMetadata, issuedAt: Schema.DateTimeUtc, @@ -335,6 +411,7 @@ export class EnvironmentAuthorizationError extends Schema.TaggedError