| 1 | import type { |
| 2 | AuthBrowserSessionResult, |
| 3 | AuthClientMetadata, |
| 4 | AuthEnvironmentScope, |
| 5 | AuthGrantScope, |
| 6 | AuthPairingCredentialResult, |
| 7 | ServerAuthSessionMethod, |
| 8 | AuthSessionId, |
| 9 | AuthSessionState, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { EnvironmentHttpCommonError, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; |
| 12 | import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Schema from "effect/Schema"; |
| 15 | import { HttpClientError } from "effect/http"; |
| 16 | |
| 17 | import { |
| 18 | getPairingTokenFromUrl, |
| 19 | stripPairingTokenFromUrl as stripPairingTokenUrl, |
| 20 | } from "../../pairingUrl"; |
| 21 | |
| 22 | import { PrimaryEnvironmentHttpClient } from "./httpClient"; |
| 23 | import { runPrimaryHttp } from "../../lib/runtime"; |
| 24 | |
| 25 | const PrimaryEnvironmentRequestOperation = Schema.Literals([ |
| 26 | "fetch-session-state", |
| 27 | "exchange-bootstrap-credential", |
| 28 | "fetch-environment-descriptor", |
| 29 | "create-pairing-credential", |
| 30 | "list-pairing-links", |
| 31 | "revoke-pairing-link", |
| 32 | "list-client-sessions", |
| 33 | "revoke-client-session", |
| 34 | "revoke-other-client-sessions", |
| 35 | ]); |
| 36 | type PrimaryEnvironmentRequestOperation = typeof PrimaryEnvironmentRequestOperation.Type; |
| 37 | |
| 38 | export class PrimaryEnvironmentRequestError extends Schema.TaggedError<PrimaryEnvironmentRequestError>()( |
| 39 | "PrimaryEnvironmentRequestError", |
| 40 | { |
| 41 | operation: PrimaryEnvironmentRequestOperation, |
| 42 | status: Schema.Number, |
| 43 | pairingLinkId: Schema.optional(Schema.String), |
| 44 | sessionId: Schema.optional(Schema.String), |
| 45 | cause: Schema.Defect(), |
| 46 | }, |
| 47 | ) { |
| 48 | static fromCause(input: { |
| 49 | readonly operation: PrimaryEnvironmentRequestOperation; |
| 50 | readonly cause: unknown; |
| 51 | readonly pairingLinkId?: string; |
| 52 | readonly sessionId?: string; |
| 53 | }): PrimaryEnvironmentRequestError { |
| 54 | const status = readHttpApiStatus(input.cause) ?? 500; |
| 55 | return new PrimaryEnvironmentRequestError({ |
| 56 | operation: input.operation, |
| 57 | status, |
| 58 | ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}), |
| 59 | ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), |
| 60 | cause: input.cause, |
| 61 | }); |
| 62 | } |
| 63 | |
| 64 | override get message(): string { |
| 65 | return `Primary environment request failed during ${this.operation} (HTTP ${this.status}).`; |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError); |
| 70 | |
| 71 | export class PrimaryEnvironmentPairingCredentialRejectedError extends Schema.TaggedError<PrimaryEnvironmentPairingCredentialRejectedError>()( |
| 72 | "PrimaryEnvironmentPairingCredentialRejectedError", |
| 73 | { |
| 74 | providedLength: Schema.Number, |
| 75 | cause: Schema.Defect(), |
| 76 | }, |
| 77 | ) { |
| 78 | override get message(): string { |
| 79 | return "Invalid pairing token. Check the token and try again."; |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | export const isPrimaryEnvironmentPairingCredentialRejectedError = Schema.is( |
| 84 | PrimaryEnvironmentPairingCredentialRejectedError, |
| 85 | ); |
| 86 | |
| 87 | export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedError<PrimaryEnvironmentAuthSessionTimeoutError>()( |
| 88 | "PrimaryEnvironmentAuthSessionTimeoutError", |
| 89 | { |
| 90 | timeoutMs: Schema.Number, |
| 91 | elapsedMs: Schema.Number, |
| 92 | }, |
| 93 | ) { |
| 94 | override get message(): string { |
| 95 | return "Timed out waiting for authenticated session after bootstrap."; |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedError<PrimaryEnvironmentPairingCredentialRequiredError>()( |
| 100 | "PrimaryEnvironmentPairingCredentialRequiredError", |
| 101 | { |
| 102 | providedLength: Schema.Number, |
| 103 | }, |
| 104 | ) { |
| 105 | override get message(): string { |
| 106 | return "Enter a pairing token to continue."; |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); |
| 111 | |
| 112 | export interface ServerPairingLinkRecord { |
| 113 | readonly id: string; |
| 114 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 115 | readonly subject: string; |
| 116 | readonly label?: string; |
| 117 | readonly createdAt: string; |
| 118 | readonly expiresAt: string; |
| 119 | } |
| 120 | |
| 121 | export interface ServerClientSessionRecord { |
| 122 | readonly sessionId: AuthSessionId; |
| 123 | readonly subject: string; |
| 124 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 125 | readonly method: ServerAuthSessionMethod; |
| 126 | readonly client: AuthClientMetadata; |
| 127 | readonly issuedAt: string; |
| 128 | readonly expiresAt: string; |
| 129 | readonly lastConnectedAt: string | null; |
| 130 | readonly connected: boolean; |
| 131 | readonly current: boolean; |
| 132 | } |
| 133 | |
| 134 | type ServerAuthGateState = |
| 135 | | { status: "authenticated" } |
| 136 | | { |
| 137 | status: "requires-auth"; |
| 138 | auth: AuthSessionState["auth"]; |
| 139 | errorMessage?: string; |
| 140 | }; |
| 141 | |
| 142 | let bootstrapPromise: Promise<ServerAuthGateState> | null = null; |
| 143 | let resolvedAuthenticatedGateState: ServerAuthGateState | null = null; |
| 144 | let explicitPairingRequested = false; |
| 145 | const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; |
| 146 | const AUTH_SESSION_ESTABLISH_STEP_MS = 100; |
| 147 | |
| 148 | export function peekPairingTokenFromUrl(): string | null { |
| 149 | return getPairingTokenFromUrl(new URL(window.location.href)); |
| 150 | } |
| 151 | |
| 152 | export function stripPairingTokenFromUrl() { |
| 153 | const url = new URL(window.location.href); |
| 154 | const next = stripPairingTokenUrl(url); |
| 155 | if (next.toString() === url.toString()) { |
| 156 | return; |
| 157 | } |
| 158 | window.history.replaceState({}, document.title, next.toString()); |
| 159 | } |
| 160 | |
| 161 | export function takePairingTokenFromUrl(): string | null { |
| 162 | const token = peekPairingTokenFromUrl(); |
| 163 | if (!token) { |
| 164 | return null; |
| 165 | } |
| 166 | stripPairingTokenFromUrl(); |
| 167 | return token; |
| 168 | } |
| 169 | |
| 170 | function getDesktopBootstrapCredential(): string | null { |
| 171 | // Both backends share the same bootstrap token (DesktopBackendConfiguration |
| 172 | // mints one tokenRef and feeds it to both resolvers), so picking the |
| 173 | // primary entry is fine even when the WSL backend is also registered. |
| 174 | const bootstraps = window.desktopBridge?.getLocalEnvironmentBootstraps() ?? []; |
| 175 | const primary = bootstraps.find((entry) => entry.id === PRIMARY_LOCAL_ENVIRONMENT_ID); |
| 176 | return typeof primary?.bootstrapToken === "string" && primary.bootstrapToken.length > 0 |
| 177 | ? primary.bootstrapToken |
| 178 | : null; |
| 179 | } |
| 180 | |
| 181 | export async function fetchSessionState(): Promise<AuthSessionState> { |
| 182 | return retryTransientBootstrap(async () => { |
| 183 | try { |
| 184 | return await runPrimaryHttp( |
| 185 | PrimaryEnvironmentHttpClient.pipe( |
| 186 | Effect.flatMap((client) => client.auth.session({ headers: {} })), |
| 187 | ), |
| 188 | ); |
| 189 | } catch (error) { |
| 190 | throw PrimaryEnvironmentRequestError.fromCause({ |
| 191 | operation: "fetch-session-state", |
| 192 | cause: error, |
| 193 | }); |
| 194 | } |
| 195 | }); |
| 196 | } |
| 197 | |
| 198 | function readHttpApiStatus(error: unknown): number | null { |
| 199 | if (isEnvironmentHttpCommonError(error)) { |
| 200 | return readEnvironmentHttpErrorStatus(error); |
| 201 | } |
| 202 | return HttpClientError.isHttpClientError(error) && error.response !== undefined |
| 203 | ? error.response.status |
| 204 | : null; |
| 205 | } |
| 206 | |
| 207 | function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): number { |
| 208 | switch (error._tag) { |
| 209 | case "EnvironmentRequestInvalidError": |
| 210 | return 400; |
| 211 | case "EnvironmentAuthInvalidError": |
| 212 | return 401; |
| 213 | case "EnvironmentScopeRequiredError": |
| 214 | case "EnvironmentOperationForbiddenError": |
| 215 | return 403; |
| 216 | case "EnvironmentResourceNotFoundError": |
| 217 | return 404; |
| 218 | case "EnvironmentInternalError": |
| 219 | return 500; |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | async function exchangeBootstrapCredential(credential: string): Promise<AuthBrowserSessionResult> { |
| 224 | return retryTransientBootstrap(async () => { |
| 225 | try { |
| 226 | return await runPrimaryHttp( |
| 227 | PrimaryEnvironmentHttpClient.pipe( |
| 228 | Effect.flatMap((client) => client.auth.browserSession({ payload: { credential } })), |
| 229 | ), |
| 230 | ); |
| 231 | } catch (error) { |
| 232 | if ( |
| 233 | isEnvironmentHttpCommonError(error) && |
| 234 | error._tag === "EnvironmentAuthInvalidError" && |
| 235 | error.reason === "invalid_credential" |
| 236 | ) { |
| 237 | throw new PrimaryEnvironmentPairingCredentialRejectedError({ |
| 238 | providedLength: credential.length, |
| 239 | cause: error, |
| 240 | }); |
| 241 | } |
| 242 | throw PrimaryEnvironmentRequestError.fromCause({ |
| 243 | operation: "exchange-bootstrap-credential", |
| 244 | cause: error, |
| 245 | }); |
| 246 | } |
| 247 | }); |
| 248 | } |
| 249 | |
| 250 | async function waitForAuthenticatedSessionAfterBootstrap(): Promise<AuthSessionState> { |
| 251 | const startedAt = Date.now(); |
| 252 | |
| 253 | while (true) { |
| 254 | const session = await fetchSessionState(); |
| 255 | if (session.authenticated) { |
| 256 | return session; |
| 257 | } |
| 258 | |
| 259 | const elapsedMs = Date.now() - startedAt; |
| 260 | if (elapsedMs >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { |
| 261 | throw new PrimaryEnvironmentAuthSessionTimeoutError({ |
| 262 | timeoutMs: AUTH_SESSION_ESTABLISH_TIMEOUT_MS, |
| 263 | elapsedMs, |
| 264 | }); |
| 265 | } |
| 266 | |
| 267 | await waitForBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS); |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); |
| 272 | const BOOTSTRAP_RETRY_TIMEOUT_MS = 15_000; |
| 273 | const BOOTSTRAP_RETRY_STEP_MS = 500; |
| 274 | |
| 275 | export async function retryTransientBootstrap<T>(operation: () => Promise<T>): Promise<T> { |
| 276 | const startedAt = Date.now(); |
| 277 | while (true) { |
| 278 | try { |
| 279 | return await operation(); |
| 280 | } catch (error) { |
| 281 | if (!isTransientBootstrapError(error)) { |
| 282 | throw error; |
| 283 | } |
| 284 | |
| 285 | if (Date.now() - startedAt >= BOOTSTRAP_RETRY_TIMEOUT_MS) { |
| 286 | throw error; |
| 287 | } |
| 288 | |
| 289 | await waitForBootstrapRetry(BOOTSTRAP_RETRY_STEP_MS); |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | function waitForBootstrapRetry(delayMs: number): Promise<void> { |
| 295 | return new Promise((resolve) => { |
| 296 | setTimeout(resolve, delayMs); |
| 297 | }); |
| 298 | } |
| 299 | |
| 300 | function isTransientBootstrapError(error: unknown): boolean { |
| 301 | if (isPrimaryEnvironmentRequestError(error)) { |
| 302 | return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); |
| 303 | } |
| 304 | |
| 305 | if (error instanceof TypeError) { |
| 306 | return true; |
| 307 | } |
| 308 | |
| 309 | return error instanceof DOMException && error.name === "AbortError"; |
| 310 | } |
| 311 | |
| 312 | async function bootstrapServerAuth(urlCredential: string | null): Promise<ServerAuthGateState> { |
| 313 | const currentSession = await fetchSessionState(); |
| 314 | if (currentSession.authenticated && !urlCredential) { |
| 315 | return { status: "authenticated" }; |
| 316 | } |
| 317 | |
| 318 | const bootstrapCredential = urlCredential ?? getDesktopBootstrapCredential(); |
| 319 | if (!bootstrapCredential) { |
| 320 | return { |
| 321 | status: "requires-auth", |
| 322 | auth: currentSession.auth, |
| 323 | }; |
| 324 | } |
| 325 | |
| 326 | try { |
| 327 | await exchangeBootstrapCredential(bootstrapCredential); |
| 328 | await waitForAuthenticatedSessionAfterBootstrap(); |
| 329 | return { status: "authenticated" }; |
| 330 | } catch (error) { |
| 331 | return { |
| 332 | status: "requires-auth", |
| 333 | auth: currentSession.auth, |
| 334 | errorMessage: error instanceof Error ? error.message : "Authentication failed.", |
| 335 | }; |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | export async function submitServerAuthCredential(credential: string): Promise<void> { |
| 340 | const trimmedCredential = credential.trim(); |
| 341 | if (!trimmedCredential) { |
| 342 | throw new PrimaryEnvironmentPairingCredentialRequiredError({ |
| 343 | providedLength: credential.length, |
| 344 | }); |
| 345 | } |
| 346 | |
| 347 | resolvedAuthenticatedGateState = null; |
| 348 | await exchangeBootstrapCredential(trimmedCredential); |
| 349 | await waitForAuthenticatedSessionAfterBootstrap(); |
| 350 | resolvedAuthenticatedGateState = { status: "authenticated" }; |
| 351 | bootstrapPromise = null; |
| 352 | explicitPairingRequested = false; |
| 353 | stripPairingTokenFromUrl(); |
| 354 | } |
| 355 | |
| 356 | export async function createServerPairingCredential(input?: { |
| 357 | readonly label?: string; |
| 358 | readonly scopes?: ReadonlyArray<AuthGrantScope>; |
| 359 | }): Promise<AuthPairingCredentialResult> { |
| 360 | const trimmedLabel = input?.label?.trim(); |
| 361 | try { |
| 362 | return await runPrimaryHttp( |
| 363 | PrimaryEnvironmentHttpClient.pipe( |
| 364 | Effect.flatMap((client) => |
| 365 | client.auth.pairingCredential({ |
| 366 | headers: {}, |
| 367 | payload: { |
| 368 | ...(trimmedLabel ? { label: trimmedLabel } : {}), |
| 369 | ...(input?.scopes ? { scopes: input.scopes } : {}), |
| 370 | }, |
| 371 | }), |
| 372 | ), |
| 373 | ), |
| 374 | ); |
| 375 | } catch (error) { |
| 376 | throw PrimaryEnvironmentRequestError.fromCause({ |
| 377 | operation: "create-pairing-credential", |
| 378 | cause: error, |
| 379 | }); |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | export async function revokeServerPairingLink(id: string): Promise<void> { |
| 384 | try { |
| 385 | await runPrimaryHttp( |
| 386 | PrimaryEnvironmentHttpClient.pipe( |
| 387 | Effect.flatMap((client) => client.auth.revokePairingLink({ headers: {}, payload: { id } })), |
| 388 | ), |
| 389 | ); |
| 390 | } catch (error) { |
| 391 | throw PrimaryEnvironmentRequestError.fromCause({ |
| 392 | operation: "revoke-pairing-link", |
| 393 | pairingLinkId: id, |
| 394 | cause: error, |
| 395 | }); |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | export async function revokeServerClientSession(sessionId: AuthSessionId): Promise<void> { |
| 400 | try { |
| 401 | await runPrimaryHttp( |
| 402 | PrimaryEnvironmentHttpClient.pipe( |
| 403 | Effect.flatMap((client) => |
| 404 | client.auth.revokeClient({ headers: {}, payload: { sessionId } }), |
| 405 | ), |
| 406 | ), |
| 407 | ); |
| 408 | } catch (error) { |
| 409 | throw PrimaryEnvironmentRequestError.fromCause({ |
| 410 | operation: "revoke-client-session", |
| 411 | sessionId, |
| 412 | cause: error, |
| 413 | }); |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | export async function revokeOtherServerClientSessions(): Promise<number> { |
| 418 | try { |
| 419 | const result = await runPrimaryHttp( |
| 420 | PrimaryEnvironmentHttpClient.pipe( |
| 421 | Effect.flatMap((client) => client.auth.revokeOtherClients({ headers: {} })), |
| 422 | ), |
| 423 | ); |
| 424 | return result.revokedCount; |
| 425 | } catch (error) { |
| 426 | throw PrimaryEnvironmentRequestError.fromCause({ |
| 427 | operation: "revoke-other-client-sessions", |
| 428 | cause: error, |
| 429 | }); |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> { |
| 434 | // An explicit pairing link replaces this browser's grant, even when the |
| 435 | // current cookie or a cached gate already authenticates it. Keep that intent |
| 436 | // after stripping the token, which causes the router to load this gate again. |
| 437 | if (window.location.pathname.replace(/\/+$/, "") !== "/pair") { |
| 438 | explicitPairingRequested = false; |
| 439 | } else if (peekPairingTokenFromUrl()) { |
| 440 | explicitPairingRequested = true; |
| 441 | } |
| 442 | if (explicitPairingRequested) { |
| 443 | const currentSession = await fetchSessionState(); |
| 444 | return { status: "requires-auth", auth: currentSession.auth }; |
| 445 | } |
| 446 | |
| 447 | const urlCredential = takePairingTokenFromUrl(); |
| 448 | const previousPromise = bootstrapPromise; |
| 449 | if (urlCredential) { |
| 450 | resolvedAuthenticatedGateState = null; |
| 451 | } else { |
| 452 | if (previousPromise) { |
| 453 | return previousPromise; |
| 454 | } |
| 455 | |
| 456 | if (resolvedAuthenticatedGateState?.status === "authenticated") { |
| 457 | return resolvedAuthenticatedGateState; |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | const nextPromise = previousPromise |
| 462 | ? previousPromise |
| 463 | .catch(() => undefined) |
| 464 | .then(() => { |
| 465 | resolvedAuthenticatedGateState = null; |
| 466 | return bootstrapServerAuth(urlCredential); |
| 467 | }) |
| 468 | : bootstrapServerAuth(urlCredential); |
| 469 | bootstrapPromise = nextPromise; |
| 470 | return nextPromise |
| 471 | .then((result) => { |
| 472 | if (bootstrapPromise === nextPromise && result.status === "authenticated") { |
| 473 | resolvedAuthenticatedGateState = result; |
| 474 | } |
| 475 | return result; |
| 476 | }) |
| 477 | .finally(() => { |
| 478 | if (bootstrapPromise === nextPromise) { |
| 479 | bootstrapPromise = null; |
| 480 | } |
| 481 | }); |
| 482 | } |
| 483 | |
| 484 | export function __resetServerAuthBootstrapForTests() { |
| 485 | bootstrapPromise = null; |
| 486 | resolvedAuthenticatedGateState = null; |
| 487 | explicitPairingRequested = false; |
| 488 | } |