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