Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -75,6 +76,7 @@ function AppContent() {
<>
<SplashScreenCoordinator />
<SubscriptionUsageCoordinator />
<PermissionUpdateNotice />
<GestureHandlerRootView className="flex-1">
<KeyboardProvider statusBarTranslucent>
<SafeAreaProvider>
Expand Down
84 changes: 84 additions & 0 deletions apps/mobile/src/components/PermissionUpdateNotice.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
const shownThisLaunch = new Set<string>();

export function PermissionUpdateNotice() {
const { environments } = useEnvironments();
const [dismissed, setDismissed] = useState<ReadonlySet<string> | 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;
}
27 changes: 27 additions & 0 deletions apps/mobile/src/features/settings/environment-maintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/features/settings/environment-maintenance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
AuthOrchestrationOperateScope,
AuthEnvironmentMaintainScope,
sessionGrantsScope,
type AuthSessionState,
type ExecutionEnvironmentCapabilities,
type ServerProvider,
Expand All @@ -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)
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 4 additions & 6 deletions apps/mobile/src/state/mediaActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<AuthSessionState, "authenticated" | "scopes"> | null) {
return (
session === null ||
(session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true)
);
function allowsHostMedia(session: SessionGrantInput | null) {
return session === null || sessionGrantsScope(session, AuthFilesystemReadScope);
Comment thread
juliusmarminge marked this conversation as resolved.
}

function canReadHostMedia(environmentId: EnvironmentId | null): boolean {
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/state/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
13 changes: 7 additions & 6 deletions apps/mobile/src/state/session.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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. */
Expand Down
26 changes: 22 additions & 4 deletions apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 14 additions & 6 deletions apps/server/src/auth/EnvironmentAuth.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type AuthPairingCredentialResult,
type AuthSessionId,
type AuthSessionState,
authScopeResponse,
type ServerAuthDescriptor,
type ServerAuthSessionMethod,
type AuthWebSocketTicketResult,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
})),
),
Expand Down
Loading
Loading