authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 17:17:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 17:17:38-07:00
log9d28c21a26aeef198cb064fe466e49cbeabfe09c
treed108d705c88e93c182d6150ce8c903dacde07ec7
parent44701efd6790c39dc82cb08104f7295e1780210f
signature Signed by PGP key B5690EEEBB952194

fix(auth): keep pairing credentials out of access read models (#9523)


11 files changed, 243 insertions(+), 37 deletions(-)

apps/server/src/auth/EnvironmentAuthAdmin.test.ts+1-1
......@@ -59,7 +59,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) =
5959 expect(listedBeforeRevoke).toHaveLength(1);
6060 expect(listedBeforeRevoke[0]?.id).toBe(created.id);
6161 expect(listedBeforeRevoke[0]?.label).toBe("CI phone");
62 expect(listedBeforeRevoke[0]?.credential).toBe(created.credential);
62 expect(listedBeforeRevoke[0]).not.toHaveProperty("credential");
6363 expect(revoked).toBe(true);
6464 expect(listedAfterRevoke).toHaveLength(0);
6565 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
apps/server/src/auth/PairingGrantStore.test.ts+30
......@@ -4,6 +4,8 @@ import * as Duration from "effect/Duration";
44import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
7import * as Stream from "effect/Stream";
8import * as Queue from "effect/Queue";
79import * as TestClock from "effect/testing/TestClock";
810
911import * as ServerConfig from "../config.ts";
......@@ -194,6 +196,31 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
194196 ),
195197 );
196198
199 it.effect("keeps credentials out of pairing lists and change events", () =>
200 Effect.gen(function* () {
201 const grants = yield* PairingGrantStore.PairingGrantStore;
202 const changes = yield* Queue.unbounded<PairingGrantStore.BootstrapCredentialChange>();
203 yield* grants.streamChanges.pipe(
204 Stream.runForEach((change) => Queue.offer(changes, change)),
205 Effect.forkScoped({ startImmediately: true }),
206 );
207 for (const input of [{}, { label: "Synthetic phone" }]) {
208 const issued = yield* grants.issueOneTimeToken(input);
209 const change = yield* Queue.take(changes);
210 expect(change?.type).toBe("pairingLinkUpserted");
211 if (change?.type !== "pairingLinkUpserted")
212 throw new Error("Expected a pairing link update");
213 expect(change.pairingLink.id).toBe(issued.id);
214 expect(change.pairingLink).not.toHaveProperty("credential");
215 const listed = (yield* grants.listActive()).find((link) => link.id === issued.id);
216 expect(listed).toEqual(change.pairingLink);
217 const consumed = yield* grants.consume(issued.credential);
218 expect(consumed.scopes).toEqual(change.pairingLink.scopes);
219 expect(yield* Queue.take(changes)).toEqual({ type: "pairingLinkRemoved", id: issued.id });
220 }
221 }).pipe(Effect.scoped, Effect.provide(makePairingGrantStoreLayer())),
222 );
223
197224 it.effect("lists and revokes active pairing links", () =>
198225 Effect.gen(function* () {
199226 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
......@@ -205,6 +232,9 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
205232 const activeBeforeRevoke = yield* bootstrapCredentials.listActive();
206233 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id);
207234 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id);
235 for (const entry of activeBeforeRevoke) {
236 expect(entry).not.toHaveProperty("credential");
237 }
208238
209239 const revoked = yield* bootstrapCredentials.revoke(first.id);
210240 const activeAfterRevoke = yield* bootstrapCredentials.listActive();
apps/server/src/auth/PairingGrantStore.ts-3
......@@ -340,7 +340,6 @@ export const make = Effect.gen(function* () {
340340 row.label
341341 ? ({
342342 id: row.id,
343 credential: row.credential,
344343 scopes: row.scopes,
345344 subject: row.subject,
346345 label: row.label,
......@@ -349,7 +348,6 @@ export const make = Effect.gen(function* () {
349348 } satisfies AuthPairingLink)
350349 : ({
351350 id: row.id,
352 credential: row.credential,
353351 scopes: row.scopes,
354352 subject: row.subject,
355353 createdAt: row.createdAt,
......@@ -424,7 +422,6 @@ export const make = Effect.gen(function* () {
424422 );
425423 yield* emitUpsert({
426424 id,
427 credential,
428425 scopes: input?.scopes ?? AuthStandardClientScopes,
429426 subject: input?.subject ?? "one-time-token",
430427 ...(input?.label ? { label: input.label } : {}),
apps/server/src/cliAuthFormat.test.ts-1
......@@ -30,7 +30,6 @@ it("formats pairing listings without exposing the secret token", () => {
3030 [
3131 {
3232 id: "pairing-1",
33 credential: "secret-pairing-token",
3433 subject: "one-time-token",
3534 label: "Phone",
3635 scopes: ["orchestration:read"],
apps/server/src/server.test.ts+153-6
......@@ -6,6 +6,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos
66
77import {
88 AuthAccessTokenType,
9 AuthStandardClientScopes,
910 AuthEnvironmentBootstrapTokenType,
1011 AuthTokenExchangeGrantType,
1112 CommandId,
......@@ -63,6 +64,7 @@ import * as Option from "effect/Option";
6364import * as Path from "effect/Path";
6465import * as PubSub from "effect/PubSub";
6566import * as Ref from "effect/Ref";
67import * as Queue from "effect/Queue";
6668import * as Schema from "effect/Schema";
6769import * as Stream from "effect/Stream";
6870import * as TestClock from "effect/testing/TestClock";
......@@ -152,6 +154,7 @@ import * as ReviewService from "./review/ReviewService.ts";
152154import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
153155import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
154156import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
157import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
155158import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
156159import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
157160import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
......@@ -482,6 +485,7 @@ const makeBrowserOtlpPayload = (spanName: string) =>
482485 });
483486
484487const buildAppUnderTest = (options?: {
488 onPairingChangesSubscribed?: Effect.Effect<void>;
485489 config?: Partial<ServerConfig.ServerConfig["Service"]>;
486490 layers?: {
487491 keybindings?: Partial<Keybindings.Keybindings["Service"]>;
......@@ -1108,6 +1112,24 @@ const buildAppUnderTest = (options?: {
11081112 ...options?.layers?.cloudCliTokenManager,
11091113 }),
11101114 ),
1115 Layer.updateService(PairingGrantStore.PairingGrantStore, (grants) => {
1116 const subscribed = options?.onPairingChangesSubscribed;
1117 if (!subscribed) return grants;
1118 return {
1119 ...grants,
1120 streamChanges: Stream.unwrap(
1121 Effect.gen(function* () {
1122 const changes = yield* Queue.unbounded<PairingGrantStore.BootstrapCredentialChange>();
1123 yield* grants.streamChanges.pipe(
1124 Stream.runForEach((change) => Queue.offer(changes, change)),
1125 Effect.forkScoped({ startImmediately: true }),
1126 );
1127 yield* subscribed;
1128 return Stream.fromQueue(changes);
1129 }),
1130 ),
1131 };
1132 }),
11111133 Layer.provideMerge(makeAuthTestLayer()),
11121134 Layer.provideMerge(ServerSecretStore.layer),
11131135 Layer.provide(workspaceAndProjectServicesLayer),
......@@ -1134,16 +1156,19 @@ const parseSessionCookieFromWsUrl = (
11341156 };
11351157};
11361158
1137const wsRpcProtocolLayer = (wsUrl: string) => {
1159const wsRpcProtocolLayer = (wsUrl: string, onMessage?: (message: string) => void) => {
11381160 const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl);
11391161 const webSocketConstructorLayer = Layer.succeed(
11401162 Socket.WebSocketConstructor,
1141 (socketUrl, protocols) =>
1142 new NodeSocket.NodeWS.WebSocket(
1163 (socketUrl, protocols) => {
1164 const socket = new NodeSocket.NodeWS.WebSocket(
11431165 socketUrl,
11441166 protocols,
11451167 cookie ? { headers: { cookie } } : undefined,
1146 ) as unknown as globalThis.WebSocket,
1168 );
1169 if (onMessage) socket.on("message", (data) => onMessage(data.toString()));
1170 return socket as unknown as globalThis.WebSocket;
1171 },
11471172 );
11481173
11491174 return RpcClient.layerProtocolSocket().pipe(
......@@ -1159,7 +1184,8 @@ type WsRpcClient =
11591184const withWsRpcClient = <A, E, R>(
11601185 wsUrl: string,
11611186 f: (client: WsRpcClient) => Effect.Effect<A, E, R>,
1162) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl)));
1187 onMessage?: (message: string) => void,
1188) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl, onMessage)));
11631189
11641190const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => {
11651191 const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url);
......@@ -3935,6 +3961,128 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
39353961 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
39363962 );
39373963
3964 it.effect("returns only pairing metadata to access-read HTTP sessions", () =>
3965 Effect.gen(function* () {
3966 yield* buildAppUnderTest();
3967 const reader = yield* exchangeAccessToken(defaultDesktopBootstrapToken, {
3968 scope: "access:read",
3969 });
3970 assert.equal(reader.response.status, 200);
3971 assert.equal(reader.body.scope, "access:read");
3972 const createdResponse = yield* HttpClient.post("/api/auth/pairing-token", {
3973 headers: { cookie: yield* getAuthenticatedSessionCookieHeader() },
3974 body: yield* HttpBody.json({ label: "Synthetic phone" }),
3975 });
3976 const created = (yield* createdResponse.json) as { id: string; credential: string };
3977 assert.equal(createdResponse.status, 200);
3978 const response = yield* HttpClient.get("/api/auth/pairing-links", {
3979 headers: { authorization: `Bearer ${reader.body.access_token ?? ""}` },
3980 });
3981 assert.equal(response.status, 200);
3982 const responseText = yield* response.text;
3983 assert.notInclude(responseText, '"credential"');
3984 assert.notInclude(responseText, created.credential);
3985 const links = yield* responseJsonEffect<
3986 ReadonlyArray<{
3987 readonly id: string;
3988 readonly label?: string;
3989 readonly scopes: ReadonlyArray<string>;
3990 }>
3991 >(response);
3992 const listed = links.find((link) => link.id === created.id);
3993 assert.isDefined(listed);
3994 assert.deepInclude(listed, {
3995 label: "Synthetic phone",
3996 scopes: [...AuthStandardClientScopes],
3997 });
3998
3999 const unauthorizedCreate = yield* HttpClient.post("/api/auth/pairing-token", {
4000 headers: { authorization: `Bearer ${reader.body.access_token ?? ""}` },
4001 body: yield* HttpBody.json({}),
4002 });
4003 assert.equal(unauthorizedCreate.status, 403);
4004 const idExchange = yield* exchangeAccessToken(created.id, { scope: "terminal:operate" });
4005 assert.equal(idExchange.response.status, 401);
4006 const authorized = yield* exchangeAccessToken(created.credential, {
4007 scope: AuthStandardClientScopes.join(" "),
4008 });
4009 assert.equal(authorized.response.status, 200);
4010 assert.equal(authorized.body.scope, AuthStandardClientScopes.join(" "));
4011 const reused = yield* exchangeAccessToken(created.credential, { scope: "terminal:operate" });
4012 assert.equal(reused.response.status, 401);
4013 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4014 );
4015
4016 it.effect("returns only pairing metadata in access-read WebSocket snapshots and updates", () =>
4017 Effect.gen(function* () {
4018 const changesSubscribed = yield* Deferred.make<void>();
4019 yield* buildAppUnderTest({
4020 onPairingChangesSubscribed: Deferred.succeed(changesSubscribed, undefined).pipe(
4021 Effect.asVoid,
4022 ),
4023 });
4024 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
4025 const createLink = Effect.gen(function* () {
4026 const response = yield* HttpClient.post("/api/auth/pairing-token", {
4027 headers: { cookie: ownerCookie },
4028 body: yield* HttpBody.json({}),
4029 });
4030 assert.equal(response.status, 200);
4031 return (yield* response.json) as { id: string; credential: string };
4032 });
4033 const initialLink = yield* createLink;
4034 const reader = yield* exchangeAccessToken(defaultDesktopBootstrapToken, {
4035 scope: "access:read",
4036 });
4037 assert.equal(reader.body.scope, "access:read");
4038 const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
4039 headers: { authorization: `Bearer ${reader.body.access_token ?? ""}` },
4040 });
4041 assert.equal(ticketResponse.status, 200);
4042 const { ticket } = (yield* ticketResponse.json) as { ticket: string };
4043 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`;
4044 const frames: string[] = [];
4045 yield* withWsRpcClient(
4046 wsUrl,
4047 (client) =>
4048 Effect.gen(function* () {
4049 const snapshotReceived = yield* Deferred.make<void>();
4050 const eventsFiber = yield* client.subscribeAuthAccess({}).pipe(
4051 Stream.tap((event) =>
4052 event.type === "snapshot"
4053 ? Deferred.succeed(snapshotReceived, undefined)
4054 : Effect.void,
4055 ),
4056 Stream.takeUntil((event) => event.type === "pairingLinkUpserted"),
4057 Stream.runCollect,
4058 Effect.forkChild,
4059 );
4060 yield* Deferred.await(snapshotReceived);
4061 yield* Deferred.await(changesSubscribed);
4062 const liveLink = yield* createLink;
4063 const events = yield* Fiber.join(eventsFiber);
4064 const snapshot = events.find((event) => event.type === "snapshot");
4065 const update = events.find((event) => event.type === "pairingLinkUpserted");
4066 assert.isDefined(snapshot);
4067 assert.isDefined(update);
4068 assert.isTrue(
4069 snapshot?.payload.pairingLinks.some((link) => link.id === initialLink.id),
4070 );
4071 assert.equal(update?.payload.id, liveLink.id);
4072 // Inspect the wire frames so client schema decoding cannot hide a leak.
4073 assert.notInclude(frames.join(""), '"credential"');
4074 assert.notInclude(frames.join(""), initialLink.credential);
4075 assert.notInclude(frames.join(""), liveLink.credential);
4076 const paired = yield* exchangeAccessToken(liveLink.credential, {
4077 scope: AuthStandardClientScopes.join(" "),
4078 });
4079 assert.equal(paired.response.status, 200);
4080 }),
4081 (frame) => frames.push(frame),
4082 );
4083 }).pipe(Effect.scoped, Effect.provide(NodeHttpServer.layerTest)),
4084 );
4085
39384086 it.effect("lists and revokes pairing links for access management sessions", () =>
39394087 Effect.gen(function* () {
39404088 yield* buildAppUnderTest({
......@@ -3962,7 +4110,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
39624110 });
39634111 const listedLinks = (yield* listResponse.json) as ReadonlyArray<{
39644112 readonly id: string;
3965 readonly credential: string;
39664113 }>;
39674114
39684115 const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", {
apps/web/src/components/settings/ConnectionsSettings.tsx+48-20
......@@ -25,6 +25,7 @@ import {
2525 type AuthClientSession,
2626 type AuthEnvironmentScope,
2727 type AuthPairingLink,
28 type AuthPairingCredentialResult,
2829 type AdvertisedEndpoint,
2930 type DesktopDiscoveredSshHost,
3031 type DesktopSshEnvironmentTarget,
......@@ -541,6 +542,7 @@ function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string {
541542
542543type PairingLinkListRowProps = {
543544 pairingLink: ServerPairingLinkRecord;
545 credential: string | undefined;
544546 endpointUrl: string | null | undefined;
545547 endpoints: ReadonlyArray<AdvertisedEndpoint>;
546548 defaultEndpointKey: string | null;
......@@ -551,6 +553,7 @@ type PairingLinkListRowProps = {
551553
552554const PairingLinkListRow = memo(function PairingLinkListRow({
553555 pairingLink,
556 credential,
554557 endpointUrl,
555558 endpoints,
556559 defaultEndpointKey,
......@@ -571,20 +574,22 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
571574 const qrPanelId = useId();
572575
573576 const currentOriginPairingUrl = useMemo(
574 () => resolveCurrentOriginPairingUrl(pairingLink.credential),
575 [pairingLink.credential],
577 () => (credential ? resolveCurrentOriginPairingUrl(credential) : null),
578 [credential],
576579 );
577580 const hostedPairingUrl = useMemo(
578581 () =>
579 endpointUrl != null && endpointUrl !== ""
580 ? resolveHostedPairingUrl(endpointUrl, pairingLink.credential)
582 credential && endpointUrl != null && endpointUrl !== ""
583 ? resolveHostedPairingUrl(endpointUrl, credential)
581584 : null,
582 [endpointUrl, pairingLink.credential],
585 [endpointUrl, credential],
583586 );
584587 const endpointPairingUrl = useMemo(() => {
585588 const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey);
586 return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null;
587 }, [defaultEndpointKey, endpoints, pairingLink.credential]);
589 return endpoint && credential
590 ? resolveAdvertisedEndpointPairingUrl(endpoint, credential)
591 : null;
592 }, [defaultEndpointKey, endpoints, credential]);
588593 const endpointCopyOptions = useMemo(() => {
589594 const options: Array<{
590595 readonly id: string;
......@@ -594,11 +599,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
594599 readonly detail: string;
595600 readonly qrShareable: boolean;
596601 }> = [];
602 if (!credential) return options;
597603 for (const endpoint of endpoints) {
598604 if (endpoint.status === "unavailable") {
599605 continue;
600606 }
601 const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential);
607 const url = resolveAdvertisedEndpointPairingUrl(endpoint, credential);
602608 options.push({
603609 id: endpoint.id,
604610 preferenceKey: endpointDefaultPreferenceKey(endpoint),
......@@ -609,19 +615,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
609615 });
610616 }
611617 return options;
612 }, [endpoints, pairingLink.credential]);
618 }, [endpoints, credential]);
613619 const shareablePairingUrl =
614620 endpointPairingUrl ??
615 (endpointUrl != null && endpointUrl !== ""
616 ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential))
621 (credential && endpointUrl != null && endpointUrl !== ""
622 ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, credential))
617623 : isLoopbackHostname(window.location.hostname)
618624 ? null
619625 : currentOriginPairingUrl);
620626 // Value of the copy attempt that last failed. The clipboard-failure reveal
621627 // dialog must show exactly what failed to copy, not the row's default URL.
622628 const [failedCopyValue, setFailedCopyValue] = useState<string | null>(null);
623 const revealValue = failedCopyValue ?? shareablePairingUrl ?? pairingLink.credential;
624 const isRevealValueUrl = revealValue !== pairingLink.credential;
629 const revealValue = failedCopyValue ?? shareablePairingUrl ?? credential ?? "";
630 const isRevealValueUrl = revealValue !== credential;
625631 const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue);
626632 // Never render a QR for a loopback URL, even in the manual-copy fallback.
627633 const isRevealValueQrShareable =
......@@ -686,8 +692,8 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
686692 );
687693
688694 const handleCopyCode = useCallback(() => {
689 copyPairingValue(pairingLink.credential, "code");
690 }, [copyPairingValue, pairingLink.credential]);
695 if (credential) copyPairingValue(credential, "code");
696 }, [copyPairingValue, credential]);
691697
692698 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);
693699
......@@ -727,7 +733,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
727733 <span aria-hidden> · </span>
728734 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />
729735 </p>
730 {shareablePairingUrl === null ? (
736 {!credential ? (
737 <p className="text-[11px] text-muted-foreground/70">
738 Create a new link to share from this client.
739 </p>
740 ) : shareablePairingUrl === null ? (
731741 <p className="text-[11px] text-muted-foreground/70">
732742 Copy the token and pair from another client using this backend&apos;s reachable host.
733743 </p>
......@@ -747,13 +757,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
747757 </Button>
748758 ) : null}
749759 <Dialog
750 open={isRevealDialogOpen}
760 open={credential !== undefined && isRevealDialogOpen}
751761 onOpenChange={(open) => {
752762 setIsRevealDialogOpen(open);
753763 if (!open) setFailedCopyValue(null);
754764 }}
755765 >
756 {canCopyToClipboard ? (
766 {!credential ? null : canCopyToClipboard ? (
757767 shareablePairingUrl ? null : (
758768 <Button size="xs" variant="outline" onClick={handleCopyCode}>
759769 Copy code
......@@ -1000,12 +1010,14 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
10001010});
10011011
10021012type AuthorizedClientsHeaderActionProps = {
1013 onPairingLinkCreated: (result: AuthPairingCredentialResult) => void;
10031014 clientSessions: ReadonlyArray<ServerClientSessionRecord>;
10041015 isRevokingOtherClients: boolean;
10051016 onRevokeOtherClients: () => void;
10061017};
10071018
10081019const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({
1020 onPairingLinkCreated,
10091021 clientSessions,
10101022 isRevokingOtherClients,
10111023 onRevokeOtherClients,
......@@ -1020,7 +1032,11 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
10201032 const handleCreatePairingLink = useCallback(async () => {
10211033 setIsCreatingPairingLink(true);
10221034 try {
1023 await createServerPairingCredential({ label: pairingLabel, scopes: pairingScopes });
1035 const created = await createServerPairingCredential({
1036 label: pairingLabel,
1037 scopes: pairingScopes,
1038 });
1039 onPairingLinkCreated(created);
10241040 setPairingLabel("");
10251041 setPairingScopes([...AuthStandardClientScopes]);
10261042 setDialogOpen(false);
......@@ -1036,7 +1052,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
10361052 } finally {
10371053 setIsCreatingPairingLink(false);
10381054 }
1039 }, [pairingLabel, pairingScopes]);
1055 }, [onPairingLinkCreated, pairingLabel, pairingScopes]);
10401056
10411057 const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => {
10421058 setPairingScopes((current) =>
......@@ -1180,6 +1196,7 @@ type PairingClientsListProps = {
11801196 presentation?: AccessSectionPresentation;
11811197 isLoading: boolean;
11821198 pairingLinks: ReadonlyArray<ServerPairingLinkRecord>;
1199 createdPairingCredentials: ReadonlyMap<string, string>;
11831200 clientSessions: ReadonlyArray<ServerClientSessionRecord>;
11841201 revokingPairingLinkId: string | null;
11851202 revokingClientSessionId: string | null;
......@@ -1194,6 +1211,7 @@ const PairingClientsList = memo(function PairingClientsList({
11941211 presentation = "current",
11951212 isLoading,
11961213 pairingLinks,
1214 createdPairingCredentials,
11971215 clientSessions,
11981216 revokingPairingLinkId,
11991217 revokingClientSessionId,
......@@ -1206,6 +1224,7 @@ const PairingClientsList = memo(function PairingClientsList({
12061224 <PairingLinkListRow
12071225 key={pairingLink.id}
12081226 pairingLink={pairingLink}
1227 credential={createdPairingCredentials.get(pairingLink.id)}
12091228 endpointUrl={endpointUrl}
12101229 endpoints={endpoints}
12111230 defaultEndpointKey={defaultEndpointKey}
......@@ -1795,6 +1814,13 @@ export function ConnectionsSettings() {
17951814 const [desktopAccessManagementMutationError, setDesktopAccessManagementMutationError] = useState<
17961815 string | null
17971816 >(null);
1817 // Only this client's creation response can supply a shareable credential.
1818 const [createdPairingCredentials, setCreatedPairingCredentials] = useState<
1819 ReadonlyMap<string, string>
1820 >(() => new Map());
1821 const handlePairingLinkCreated = useCallback((created: AuthPairingCredentialResult) => {
1822 setCreatedPairingCredentials((current) => new Map(current).set(created.id, created.credential));
1823 }, []);
17981824 const [revokingDesktopPairingLinkId, setRevokingDesktopPairingLinkId] = useState<string | null>(
17991825 null,
18001826 );
......@@ -3026,6 +3052,7 @@ export function ConnectionsSettings() {
30263052 presentation={presentation}
30273053 isLoading={isLoadingDesktopAccessManagement}
30283054 pairingLinks={visibleDesktopPairingLinks}
3055 createdPairingCredentials={createdPairingCredentials}
30293056 clientSessions={desktopClientSessions}
30303057 revokingPairingLinkId={revokingDesktopPairingLinkId}
30313058 revokingClientSessionId={revokingDesktopClientSessionId}
......@@ -3184,6 +3211,7 @@ export function ConnectionsSettings() {
31843211 title="Authorized clients"
31853212 headerAction={
31863213 <AuthorizedClientsHeaderAction
3214 onPairingLinkCreated={handlePairingLinkCreated}
31873215 clientSessions={desktopClientSessions}
31883216 isRevokingOtherClients={isRevokingOtherDesktopClients}
31893217 onRevokeOtherClients={handleRevokeOtherDesktopClients}
apps/web/src/environments/primary/auth.ts-3
......@@ -119,7 +119,6 @@ const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
119119
120120export interface ServerPairingLinkRecord {
121121 readonly id: string;
122 readonly credential: string;
123122 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
124123 readonly subject: string;
125124 readonly label?: string;
......@@ -400,7 +399,6 @@ export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPair
400399 if (pairingLink.label === undefined) {
401400 return {
402401 id: pairingLink.id,
403 credential: pairingLink.credential,
404402 scopes: pairingLink.scopes,
405403 subject: pairingLink.subject,
406404 createdAt: timestamps.createdAt,
......@@ -409,7 +407,6 @@ export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPair
409407 }
410408 return {
411409 id: pairingLink.id,
412 credential: pairingLink.credential,
413410 scopes: pairingLink.scopes,
414411 subject: pairingLink.subject,
415412 label: pairingLink.label,
docs/internals/environment-auth.md+5
......@@ -22,6 +22,11 @@ OAuth-style scope strings:
2222| `relay:read` | Inspect managed relay connectivity. |
2323| `relay:write` | Link, configure, or unlink managed relay connectivity. |
2424
25Pairing-link lists and access-stream snapshots and updates contain metadata only.
26The raw credential is returned only by the creation request, after the server checks
27`access:write` and the delegated scopes. Web and desktop clients keep that response
28in memory for sharing. They do not recover credentials from access read models.
29
2530Ordinary pairing links grant the four client-operation scopes and read access to
2631managed relay connectivity:
2732`orchestration:read orchestration:operate terminal:operate review:write relay:read`.
docs/user/remote-access.md+5
......@@ -46,6 +46,11 @@ If you are already running the desktop app and want to make it reachable from ot
46463. The settings panel will show the default reachable endpoint, with a `+N` control when more endpoints are available. Expand it to inspect alternatives such as loopback, LAN, private-network, or HTTPS endpoints.
47474. Use **Create Link** to generate a pairing link you can share with another device.
4848
49Pairing codes and share links are available only in the client that created them,
50while its Connections page remains open. After you leave the page or reload it,
51create a new link to share. Other clients can see the active link's name, scopes,
52and expiry, and can revoke it if they have access management permission.
53
4954The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks.
5055
5156When no user default is saved, the app uses the built-in LAN endpoint for pairing links when
packages/client-runtime/src/state/auth.test.ts-2
......@@ -8,7 +8,6 @@ describe("applyAuthAccessStreamEvent", () => {
88 it("accumulates rapid pairing-link and client updates into one snapshot", () => {
99 const pairingLink = {
1010 id: "pairing-link",
11 credential: "credential",
1211 scopes: ["orchestration:read"],
1312 subject: "subject",
1413 label: "Phone",
......@@ -56,7 +55,6 @@ describe("applyAuthAccessStreamEvent", () => {
5655 pairingLinks: [
5756 {
5857 id: "pairing-link",
59 credential: "credential",
6058 scopes: ["orchestration:read"],
6159 subject: "subject",
6260 label: "Phone",
packages/contracts/src/auth.ts+1-1
......@@ -218,9 +218,9 @@ export const AuthPairingCredentialResult = Schema.Struct({
218218});
219219export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Type;
220220
221// Read models contain metadata only. Credentials are returned by creation alone.
221222export const AuthPairingLink = Schema.Struct({
222223 id: TrimmedNonEmptyString,
223 credential: TrimmedNonEmptyString,
224224 scopes: AuthEnvironmentScopes,
225225 subject: TrimmedNonEmptyString,
226226 label: Schema.optionalKey(TrimmedNonEmptyString),