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) =...@@ -59,7 +59,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) =
59 expect(listedBeforeRevoke).toHaveLength(1);59 expect(listedBeforeRevoke).toHaveLength(1);
60 expect(listedBeforeRevoke[0]?.id).toBe(created.id);60 expect(listedBeforeRevoke[0]?.id).toBe(created.id);
61 expect(listedBeforeRevoke[0]?.label).toBe("CI phone");61 expect(listedBeforeRevoke[0]?.label).toBe("CI phone");
62 expect(listedBeforeRevoke[0]?.credential).toBe(created.credential);62 expect(listedBeforeRevoke[0]).not.toHaveProperty("credential");
63 expect(revoked).toBe(true);63 expect(revoked).toBe(true);
64 expect(listedAfterRevoke).toHaveLength(0);64 expect(listedAfterRevoke).toHaveLength(0);
65 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),65 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
apps/server/src/auth/PairingGrantStore.test.ts+30
...@@ -4,6 +4,8 @@ import * as Duration from "effect/Duration";...@@ -4,6 +4,8 @@ import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Stream from "effect/Stream";
8import * as Queue from "effect/Queue";
7import * as TestClock from "effect/testing/TestClock";9import * as TestClock from "effect/testing/TestClock";
810
9import * as ServerConfig from "../config.ts";11import * as ServerConfig from "../config.ts";
...@@ -194,6 +196,31 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {...@@ -194,6 +196,31 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
194 ),196 ),
195 );197 );
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
197 it.effect("lists and revokes active pairing links", () =>224 it.effect("lists and revokes active pairing links", () =>
198 Effect.gen(function* () {225 Effect.gen(function* () {
199 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;226 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
...@@ -205,6 +232,9 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {...@@ -205,6 +232,9 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
205 const activeBeforeRevoke = yield* bootstrapCredentials.listActive();232 const activeBeforeRevoke = yield* bootstrapCredentials.listActive();
206 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id);233 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id);
207 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id);234 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id);
235 for (const entry of activeBeforeRevoke) {
236 expect(entry).not.toHaveProperty("credential");
237 }
208238
209 const revoked = yield* bootstrapCredentials.revoke(first.id);239 const revoked = yield* bootstrapCredentials.revoke(first.id);
210 const activeAfterRevoke = yield* bootstrapCredentials.listActive();240 const activeAfterRevoke = yield* bootstrapCredentials.listActive();
apps/server/src/auth/PairingGrantStore.ts-3
...@@ -340,7 +340,6 @@ export const make = Effect.gen(function* () {...@@ -340,7 +340,6 @@ export const make = Effect.gen(function* () {
340 row.label340 row.label
341 ? ({341 ? ({
342 id: row.id,342 id: row.id,
343 credential: row.credential,
344 scopes: row.scopes,343 scopes: row.scopes,
345 subject: row.subject,344 subject: row.subject,
346 label: row.label,345 label: row.label,
...@@ -349,7 +348,6 @@ export const make = Effect.gen(function* () {...@@ -349,7 +348,6 @@ export const make = Effect.gen(function* () {
349 } satisfies AuthPairingLink)348 } satisfies AuthPairingLink)
350 : ({349 : ({
351 id: row.id,350 id: row.id,
352 credential: row.credential,
353 scopes: row.scopes,351 scopes: row.scopes,
354 subject: row.subject,352 subject: row.subject,
355 createdAt: row.createdAt,353 createdAt: row.createdAt,
...@@ -424,7 +422,6 @@ export const make = Effect.gen(function* () {...@@ -424,7 +422,6 @@ export const make = Effect.gen(function* () {
424 );422 );
425 yield* emitUpsert({423 yield* emitUpsert({
426 id,424 id,
427 credential,
428 scopes: input?.scopes ?? AuthStandardClientScopes,425 scopes: input?.scopes ?? AuthStandardClientScopes,
429 subject: input?.subject ?? "one-time-token",426 subject: input?.subject ?? "one-time-token",
430 ...(input?.label ? { label: input.label } : {}),427 ...(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", () => {...@@ -30,7 +30,6 @@ it("formats pairing listings without exposing the secret token", () => {
30 [30 [
31 {31 {
32 id: "pairing-1",32 id: "pairing-1",
33 credential: "secret-pairing-token",
34 subject: "one-time-token",33 subject: "one-time-token",
35 label: "Phone",34 label: "Phone",
36 scopes: ["orchestration:read"],35 scopes: ["orchestration:read"],
apps/server/src/server.test.ts+153-6
...@@ -6,6 +6,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos...@@ -6,6 +6,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos
66
7import {7import {
8 AuthAccessTokenType,8 AuthAccessTokenType,
9 AuthStandardClientScopes,
9 AuthEnvironmentBootstrapTokenType,10 AuthEnvironmentBootstrapTokenType,
10 AuthTokenExchangeGrantType,11 AuthTokenExchangeGrantType,
11 CommandId,12 CommandId,
...@@ -63,6 +64,7 @@ import * as Option from "effect/Option";...@@ -63,6 +64,7 @@ import * as Option from "effect/Option";
63import * as Path from "effect/Path";64import * as Path from "effect/Path";
64import * as PubSub from "effect/PubSub";65import * as PubSub from "effect/PubSub";
65import * as Ref from "effect/Ref";66import * as Ref from "effect/Ref";
67import * as Queue from "effect/Queue";
66import * as Schema from "effect/Schema";68import * as Schema from "effect/Schema";
67import * as Stream from "effect/Stream";69import * as Stream from "effect/Stream";
68import * as TestClock from "effect/testing/TestClock";70import * as TestClock from "effect/testing/TestClock";
...@@ -152,6 +154,7 @@ import * as ReviewService from "./review/ReviewService.ts";...@@ -152,6 +154,7 @@ import * as ReviewService from "./review/ReviewService.ts";
152import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";154import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
153import * as ServerSecretStore from "./auth/ServerSecretStore.ts";155import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
154import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";156import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
157import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
155import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";158import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
156import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";159import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
157import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";160import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
...@@ -482,6 +485,7 @@ const makeBrowserOtlpPayload = (spanName: string) =>...@@ -482,6 +485,7 @@ const makeBrowserOtlpPayload = (spanName: string) =>
482 });485 });
483486
484const buildAppUnderTest = (options?: {487const buildAppUnderTest = (options?: {
488 onPairingChangesSubscribed?: Effect.Effect<void>;
485 config?: Partial<ServerConfig.ServerConfig["Service"]>;489 config?: Partial<ServerConfig.ServerConfig["Service"]>;
486 layers?: {490 layers?: {
487 keybindings?: Partial<Keybindings.Keybindings["Service"]>;491 keybindings?: Partial<Keybindings.Keybindings["Service"]>;
...@@ -1108,6 +1112,24 @@ const buildAppUnderTest = (options?: {...@@ -1108,6 +1112,24 @@ const buildAppUnderTest = (options?: {
1108 ...options?.layers?.cloudCliTokenManager,1112 ...options?.layers?.cloudCliTokenManager,
1109 }),1113 }),
1110 ),1114 ),
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 }),
1111 Layer.provideMerge(makeAuthTestLayer()),1133 Layer.provideMerge(makeAuthTestLayer()),
1112 Layer.provideMerge(ServerSecretStore.layer),1134 Layer.provideMerge(ServerSecretStore.layer),
1113 Layer.provide(workspaceAndProjectServicesLayer),1135 Layer.provide(workspaceAndProjectServicesLayer),
...@@ -1134,16 +1156,19 @@ const parseSessionCookieFromWsUrl = (...@@ -1134,16 +1156,19 @@ const parseSessionCookieFromWsUrl = (
1134 };1156 };
1135};1157};
11361158
1137const wsRpcProtocolLayer = (wsUrl: string) => {1159const wsRpcProtocolLayer = (wsUrl: string, onMessage?: (message: string) => void) => {
1138 const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl);1160 const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl);
1139 const webSocketConstructorLayer = Layer.succeed(1161 const webSocketConstructorLayer = Layer.succeed(
1140 Socket.WebSocketConstructor,1162 Socket.WebSocketConstructor,
1141 (socketUrl, protocols) =>1163 (socketUrl, protocols) => {
1142 new NodeSocket.NodeWS.WebSocket(1164 const socket = new NodeSocket.NodeWS.WebSocket(
1143 socketUrl,1165 socketUrl,
1144 protocols,1166 protocols,
1145 cookie ? { headers: { cookie } } : undefined,1167 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 },
1147 );1172 );
11481173
1149 return RpcClient.layerProtocolSocket().pipe(1174 return RpcClient.layerProtocolSocket().pipe(
...@@ -1159,7 +1184,8 @@ type WsRpcClient =...@@ -1159,7 +1184,8 @@ type WsRpcClient =
1159const withWsRpcClient = <A, E, R>(1184const withWsRpcClient = <A, E, R>(
1160 wsUrl: string,1185 wsUrl: string,
1161 f: (client: WsRpcClient) => Effect.Effect<A, E, R>,1186 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
1164const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => {1190const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => {
1165 const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url);1191 const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url);
...@@ -3935,6 +3961,128 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3935,6 +3961,128 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3935 }).pipe(Effect.provide(NodeHttpServer.layerTest)),3961 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3936 );3962 );
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
3938 it.effect("lists and revokes pairing links for access management sessions", () =>4086 it.effect("lists and revokes pairing links for access management sessions", () =>
3939 Effect.gen(function* () {4087 Effect.gen(function* () {
3940 yield* buildAppUnderTest({4088 yield* buildAppUnderTest({
...@@ -3962,7 +4110,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3962,7 +4110,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3962 });4110 });
3963 const listedLinks = (yield* listResponse.json) as ReadonlyArray<{4111 const listedLinks = (yield* listResponse.json) as ReadonlyArray<{
3964 readonly id: string;4112 readonly id: string;
3965 readonly credential: string;
3966 }>;4113 }>;
39674114
3968 const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", {4115 const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", {
apps/web/src/components/settings/ConnectionsSettings.tsx+48-20
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25 type AuthClientSession,25 type AuthClientSession,
26 type AuthEnvironmentScope,26 type AuthEnvironmentScope,
27 type AuthPairingLink,27 type AuthPairingLink,
28 type AuthPairingCredentialResult,
28 type AdvertisedEndpoint,29 type AdvertisedEndpoint,
29 type DesktopDiscoveredSshHost,30 type DesktopDiscoveredSshHost,
30 type DesktopSshEnvironmentTarget,31 type DesktopSshEnvironmentTarget,
...@@ -541,6 +542,7 @@ function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string {...@@ -541,6 +542,7 @@ function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string {
541542
542type PairingLinkListRowProps = {543type PairingLinkListRowProps = {
543 pairingLink: ServerPairingLinkRecord;544 pairingLink: ServerPairingLinkRecord;
545 credential: string | undefined;
544 endpointUrl: string | null | undefined;546 endpointUrl: string | null | undefined;
545 endpoints: ReadonlyArray<AdvertisedEndpoint>;547 endpoints: ReadonlyArray<AdvertisedEndpoint>;
546 defaultEndpointKey: string | null;548 defaultEndpointKey: string | null;
...@@ -551,6 +553,7 @@ type PairingLinkListRowProps = {...@@ -551,6 +553,7 @@ type PairingLinkListRowProps = {
551553
552const PairingLinkListRow = memo(function PairingLinkListRow({554const PairingLinkListRow = memo(function PairingLinkListRow({
553 pairingLink,555 pairingLink,
556 credential,
554 endpointUrl,557 endpointUrl,
555 endpoints,558 endpoints,
556 defaultEndpointKey,559 defaultEndpointKey,
...@@ -571,20 +574,22 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -571,20 +574,22 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
571 const qrPanelId = useId();574 const qrPanelId = useId();
572575
573 const currentOriginPairingUrl = useMemo(576 const currentOriginPairingUrl = useMemo(
574 () => resolveCurrentOriginPairingUrl(pairingLink.credential),577 () => (credential ? resolveCurrentOriginPairingUrl(credential) : null),
575 [pairingLink.credential],578 [credential],
576 );579 );
577 const hostedPairingUrl = useMemo(580 const hostedPairingUrl = useMemo(
578 () =>581 () =>
579 endpointUrl != null && endpointUrl !== ""582 credential && endpointUrl != null && endpointUrl !== ""
580 ? resolveHostedPairingUrl(endpointUrl, pairingLink.credential)583 ? resolveHostedPairingUrl(endpointUrl, credential)
581 : null,584 : null,
582 [endpointUrl, pairingLink.credential],585 [endpointUrl, credential],
583 );586 );
584 const endpointPairingUrl = useMemo(() => {587 const endpointPairingUrl = useMemo(() => {
585 const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey);588 const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey);
586 return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null;589 return endpoint && credential
587 }, [defaultEndpointKey, endpoints, pairingLink.credential]);590 ? resolveAdvertisedEndpointPairingUrl(endpoint, credential)
591 : null;
592 }, [defaultEndpointKey, endpoints, credential]);
588 const endpointCopyOptions = useMemo(() => {593 const endpointCopyOptions = useMemo(() => {
589 const options: Array<{594 const options: Array<{
590 readonly id: string;595 readonly id: string;
...@@ -594,11 +599,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -594,11 +599,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
594 readonly detail: string;599 readonly detail: string;
595 readonly qrShareable: boolean;600 readonly qrShareable: boolean;
596 }> = [];601 }> = [];
602 if (!credential) return options;
597 for (const endpoint of endpoints) {603 for (const endpoint of endpoints) {
598 if (endpoint.status === "unavailable") {604 if (endpoint.status === "unavailable") {
599 continue;605 continue;
600 }606 }
601 const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential);607 const url = resolveAdvertisedEndpointPairingUrl(endpoint, credential);
602 options.push({608 options.push({
603 id: endpoint.id,609 id: endpoint.id,
604 preferenceKey: endpointDefaultPreferenceKey(endpoint),610 preferenceKey: endpointDefaultPreferenceKey(endpoint),
...@@ -609,19 +615,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -609,19 +615,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
609 });615 });
610 }616 }
611 return options;617 return options;
612 }, [endpoints, pairingLink.credential]);618 }, [endpoints, credential]);
613 const shareablePairingUrl =619 const shareablePairingUrl =
614 endpointPairingUrl ??620 endpointPairingUrl ??
615 (endpointUrl != null && endpointUrl !== ""621 (credential && endpointUrl != null && endpointUrl !== ""
616 ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential))622 ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, credential))
617 : isLoopbackHostname(window.location.hostname)623 : isLoopbackHostname(window.location.hostname)
618 ? null624 ? null
619 : currentOriginPairingUrl);625 : currentOriginPairingUrl);
620 // Value of the copy attempt that last failed. The clipboard-failure reveal626 // Value of the copy attempt that last failed. The clipboard-failure reveal
621 // dialog must show exactly what failed to copy, not the row's default URL.627 // dialog must show exactly what failed to copy, not the row's default URL.
622 const [failedCopyValue, setFailedCopyValue] = useState<string | null>(null);628 const [failedCopyValue, setFailedCopyValue] = useState<string | null>(null);
623 const revealValue = failedCopyValue ?? shareablePairingUrl ?? pairingLink.credential;629 const revealValue = failedCopyValue ?? shareablePairingUrl ?? credential ?? "";
624 const isRevealValueUrl = revealValue !== pairingLink.credential;630 const isRevealValueUrl = revealValue !== credential;
625 const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue);631 const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue);
626 // Never render a QR for a loopback URL, even in the manual-copy fallback.632 // Never render a QR for a loopback URL, even in the manual-copy fallback.
627 const isRevealValueQrShareable =633 const isRevealValueQrShareable =
...@@ -686,8 +692,8 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -686,8 +692,8 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
686 );692 );
687693
688 const handleCopyCode = useCallback(() => {694 const handleCopyCode = useCallback(() => {
689 copyPairingValue(pairingLink.credential, "code");695 if (credential) copyPairingValue(credential, "code");
690 }, [copyPairingValue, pairingLink.credential]);696 }, [copyPairingValue, credential]);
691697
692 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);698 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);
693699
...@@ -727,7 +733,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -727,7 +733,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
727 <span aria-hidden> · </span>733 <span aria-hidden> · </span>
728 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />734 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />
729 </p>735 </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 ? (
731 <p className="text-[11px] text-muted-foreground/70">741 <p className="text-[11px] text-muted-foreground/70">
732 Copy the token and pair from another client using this backend&apos;s reachable host.742 Copy the token and pair from another client using this backend&apos;s reachable host.
733 </p>743 </p>
...@@ -747,13 +757,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -747,13 +757,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
747 </Button>757 </Button>
748 ) : null}758 ) : null}
749 <Dialog759 <Dialog
750 open={isRevealDialogOpen}760 open={credential !== undefined && isRevealDialogOpen}
751 onOpenChange={(open) => {761 onOpenChange={(open) => {
752 setIsRevealDialogOpen(open);762 setIsRevealDialogOpen(open);
753 if (!open) setFailedCopyValue(null);763 if (!open) setFailedCopyValue(null);
754 }}764 }}
755 >765 >
756 {canCopyToClipboard ? (766 {!credential ? null : canCopyToClipboard ? (
757 shareablePairingUrl ? null : (767 shareablePairingUrl ? null : (
758 <Button size="xs" variant="outline" onClick={handleCopyCode}>768 <Button size="xs" variant="outline" onClick={handleCopyCode}>
759 Copy code769 Copy code
...@@ -1000,12 +1010,14 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({...@@ -1000,12 +1010,14 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
1000});1010});
10011011
1002type AuthorizedClientsHeaderActionProps = {1012type AuthorizedClientsHeaderActionProps = {
1013 onPairingLinkCreated: (result: AuthPairingCredentialResult) => void;
1003 clientSessions: ReadonlyArray<ServerClientSessionRecord>;1014 clientSessions: ReadonlyArray<ServerClientSessionRecord>;
1004 isRevokingOtherClients: boolean;1015 isRevokingOtherClients: boolean;
1005 onRevokeOtherClients: () => void;1016 onRevokeOtherClients: () => void;
1006};1017};
10071018
1008const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({1019const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({
1020 onPairingLinkCreated,
1009 clientSessions,1021 clientSessions,
1010 isRevokingOtherClients,1022 isRevokingOtherClients,
1011 onRevokeOtherClients,1023 onRevokeOtherClients,
...@@ -1020,7 +1032,11 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1020,7 +1032,11 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1020 const handleCreatePairingLink = useCallback(async () => {1032 const handleCreatePairingLink = useCallback(async () => {
1021 setIsCreatingPairingLink(true);1033 setIsCreatingPairingLink(true);
1022 try {1034 try {
1023 await createServerPairingCredential({ label: pairingLabel, scopes: pairingScopes });1035 const created = await createServerPairingCredential({
1036 label: pairingLabel,
1037 scopes: pairingScopes,
1038 });
1039 onPairingLinkCreated(created);
1024 setPairingLabel("");1040 setPairingLabel("");
1025 setPairingScopes([...AuthStandardClientScopes]);1041 setPairingScopes([...AuthStandardClientScopes]);
1026 setDialogOpen(false);1042 setDialogOpen(false);
...@@ -1036,7 +1052,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1036,7 +1052,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1036 } finally {1052 } finally {
1037 setIsCreatingPairingLink(false);1053 setIsCreatingPairingLink(false);
1038 }1054 }
1039 }, [pairingLabel, pairingScopes]);1055 }, [onPairingLinkCreated, pairingLabel, pairingScopes]);
10401056
1041 const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => {1057 const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => {
1042 setPairingScopes((current) =>1058 setPairingScopes((current) =>
...@@ -1180,6 +1196,7 @@ type PairingClientsListProps = {...@@ -1180,6 +1196,7 @@ type PairingClientsListProps = {
1180 presentation?: AccessSectionPresentation;1196 presentation?: AccessSectionPresentation;
1181 isLoading: boolean;1197 isLoading: boolean;
1182 pairingLinks: ReadonlyArray<ServerPairingLinkRecord>;1198 pairingLinks: ReadonlyArray<ServerPairingLinkRecord>;
1199 createdPairingCredentials: ReadonlyMap<string, string>;
1183 clientSessions: ReadonlyArray<ServerClientSessionRecord>;1200 clientSessions: ReadonlyArray<ServerClientSessionRecord>;
1184 revokingPairingLinkId: string | null;1201 revokingPairingLinkId: string | null;
1185 revokingClientSessionId: string | null;1202 revokingClientSessionId: string | null;
...@@ -1194,6 +1211,7 @@ const PairingClientsList = memo(function PairingClientsList({...@@ -1194,6 +1211,7 @@ const PairingClientsList = memo(function PairingClientsList({
1194 presentation = "current",1211 presentation = "current",
1195 isLoading,1212 isLoading,
1196 pairingLinks,1213 pairingLinks,
1214 createdPairingCredentials,
1197 clientSessions,1215 clientSessions,
1198 revokingPairingLinkId,1216 revokingPairingLinkId,
1199 revokingClientSessionId,1217 revokingClientSessionId,
...@@ -1206,6 +1224,7 @@ const PairingClientsList = memo(function PairingClientsList({...@@ -1206,6 +1224,7 @@ const PairingClientsList = memo(function PairingClientsList({
1206 <PairingLinkListRow1224 <PairingLinkListRow
1207 key={pairingLink.id}1225 key={pairingLink.id}
1208 pairingLink={pairingLink}1226 pairingLink={pairingLink}
1227 credential={createdPairingCredentials.get(pairingLink.id)}
1209 endpointUrl={endpointUrl}1228 endpointUrl={endpointUrl}
1210 endpoints={endpoints}1229 endpoints={endpoints}
1211 defaultEndpointKey={defaultEndpointKey}1230 defaultEndpointKey={defaultEndpointKey}
...@@ -1795,6 +1814,13 @@ export function ConnectionsSettings() {...@@ -1795,6 +1814,13 @@ export function ConnectionsSettings() {
1795 const [desktopAccessManagementMutationError, setDesktopAccessManagementMutationError] = useState<1814 const [desktopAccessManagementMutationError, setDesktopAccessManagementMutationError] = useState<
1796 string | null1815 string | null
1797 >(null);1816 >(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 }, []);
1798 const [revokingDesktopPairingLinkId, setRevokingDesktopPairingLinkId] = useState<string | null>(1824 const [revokingDesktopPairingLinkId, setRevokingDesktopPairingLinkId] = useState<string | null>(
1799 null,1825 null,
1800 );1826 );
...@@ -3026,6 +3052,7 @@ export function ConnectionsSettings() {...@@ -3026,6 +3052,7 @@ export function ConnectionsSettings() {
3026 presentation={presentation}3052 presentation={presentation}
3027 isLoading={isLoadingDesktopAccessManagement}3053 isLoading={isLoadingDesktopAccessManagement}
3028 pairingLinks={visibleDesktopPairingLinks}3054 pairingLinks={visibleDesktopPairingLinks}
3055 createdPairingCredentials={createdPairingCredentials}
3029 clientSessions={desktopClientSessions}3056 clientSessions={desktopClientSessions}
3030 revokingPairingLinkId={revokingDesktopPairingLinkId}3057 revokingPairingLinkId={revokingDesktopPairingLinkId}
3031 revokingClientSessionId={revokingDesktopClientSessionId}3058 revokingClientSessionId={revokingDesktopClientSessionId}
...@@ -3184,6 +3211,7 @@ export function ConnectionsSettings() {...@@ -3184,6 +3211,7 @@ export function ConnectionsSettings() {
3184 title="Authorized clients"3211 title="Authorized clients"
3185 headerAction={3212 headerAction={
3186 <AuthorizedClientsHeaderAction3213 <AuthorizedClientsHeaderAction
3214 onPairingLinkCreated={handlePairingLinkCreated}
3187 clientSessions={desktopClientSessions}3215 clientSessions={desktopClientSessions}
3188 isRevokingOtherClients={isRevokingOtherDesktopClients}3216 isRevokingOtherClients={isRevokingOtherDesktopClients}
3189 onRevokeOtherClients={handleRevokeOtherDesktopClients}3217 onRevokeOtherClients={handleRevokeOtherDesktopClients}
apps/web/src/environments/primary/auth.ts-3
...@@ -119,7 +119,6 @@ const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);...@@ -119,7 +119,6 @@ const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
119119
120export interface ServerPairingLinkRecord {120export interface ServerPairingLinkRecord {
121 readonly id: string;121 readonly id: string;
122 readonly credential: string;
123 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;122 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
124 readonly subject: string;123 readonly subject: string;
125 readonly label?: string;124 readonly label?: string;
...@@ -400,7 +399,6 @@ export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPair...@@ -400,7 +399,6 @@ export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPair
400 if (pairingLink.label === undefined) {399 if (pairingLink.label === undefined) {
401 return {400 return {
402 id: pairingLink.id,401 id: pairingLink.id,
403 credential: pairingLink.credential,
404 scopes: pairingLink.scopes,402 scopes: pairingLink.scopes,
405 subject: pairingLink.subject,403 subject: pairingLink.subject,
406 createdAt: timestamps.createdAt,404 createdAt: timestamps.createdAt,
...@@ -409,7 +407,6 @@ export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPair...@@ -409,7 +407,6 @@ export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPair
409 }407 }
410 return {408 return {
411 id: pairingLink.id,409 id: pairingLink.id,
412 credential: pairingLink.credential,
413 scopes: pairingLink.scopes,410 scopes: pairingLink.scopes,
414 subject: pairingLink.subject,411 subject: pairingLink.subject,
415 label: pairingLink.label,412 label: pairingLink.label,
docs/internals/environment-auth.md+5
...@@ -22,6 +22,11 @@ OAuth-style scope strings:...@@ -22,6 +22,11 @@ OAuth-style scope strings:
22| `relay:read` | Inspect managed relay connectivity. |22| `relay:read` | Inspect managed relay connectivity. |
23| `relay:write` | Link, configure, or unlink managed relay connectivity. |23| `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
25Ordinary pairing links grant the four client-operation scopes and read access to30Ordinary pairing links grant the four client-operation scopes and read access to
26managed relay connectivity:31managed relay connectivity:
27`orchestration:read orchestration:operate terminal:operate review:write relay:read`.32`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...@@ -46,6 +46,11 @@ If you are already running the desktop app and want to make it reachable from ot
463. 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.463. 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.
474. Use **Create Link** to generate a pairing link you can share with another device.474. 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
49The 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.54The 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
51When no user default is saved, the app uses the built-in LAN endpoint for pairing links when56When 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", () => {...@@ -8,7 +8,6 @@ describe("applyAuthAccessStreamEvent", () => {
8 it("accumulates rapid pairing-link and client updates into one snapshot", () => {8 it("accumulates rapid pairing-link and client updates into one snapshot", () => {
9 const pairingLink = {9 const pairingLink = {
10 id: "pairing-link",10 id: "pairing-link",
11 credential: "credential",
12 scopes: ["orchestration:read"],11 scopes: ["orchestration:read"],
13 subject: "subject",12 subject: "subject",
14 label: "Phone",13 label: "Phone",
...@@ -56,7 +55,6 @@ describe("applyAuthAccessStreamEvent", () => {...@@ -56,7 +55,6 @@ describe("applyAuthAccessStreamEvent", () => {
56 pairingLinks: [55 pairingLinks: [
57 {56 {
58 id: "pairing-link",57 id: "pairing-link",
59 credential: "credential",
60 scopes: ["orchestration:read"],58 scopes: ["orchestration:read"],
61 subject: "subject",59 subject: "subject",
62 label: "Phone",60 label: "Phone",
packages/contracts/src/auth.ts+1-1
...@@ -218,9 +218,9 @@ export const AuthPairingCredentialResult = Schema.Struct({...@@ -218,9 +218,9 @@ export const AuthPairingCredentialResult = Schema.Struct({
218});218});
219export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Type;219export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Type;
220220
221// Read models contain metadata only. Credentials are returned by creation alone.
221export const AuthPairingLink = Schema.Struct({222export const AuthPairingLink = Schema.Struct({
222 id: TrimmedNonEmptyString,223 id: TrimmedNonEmptyString,
223 credential: TrimmedNonEmptyString,
224 scopes: AuthEnvironmentScopes,224 scopes: AuthEnvironmentScopes,
225 subject: TrimmedNonEmptyString,225 subject: TrimmedNonEmptyString,
226 label: Schema.optionalKey(TrimmedNonEmptyString),226 label: Schema.optionalKey(TrimmedNonEmptyString),