| 1 | import { |
| 2 | RelayAccessTokenType, |
| 3 | RelayApi, |
| 4 | type RelayClientEnvironmentRecord, |
| 5 | type RelayClientDeviceRecord, |
| 6 | RelayConnectEnvironmentEndpoint, |
| 7 | type RelayDeviceRegistrationRequest, |
| 8 | RelayDpopAccessTokenScope, |
| 9 | RelayDpopTokenExchangeGrantType, |
| 10 | type RelayEnvironmentConnectRequest, |
| 11 | type RelayEnvironmentConnectResponse, |
| 12 | type RelayEnvironmentLinkChallengeRequest, |
| 13 | type RelayEnvironmentLinkChallengeResponse, |
| 14 | type RelayEnvironmentLinkRequest, |
| 15 | type RelayEnvironmentLinkResponse, |
| 16 | type RelayEnvironmentStatusResponse, |
| 17 | RelayExchangeDpopAccessTokenEndpoint, |
| 18 | RelayGetEnvironmentStatusEndpoint, |
| 19 | RelayJwtSubjectTokenType, |
| 20 | type RelayAgentActivitySnapshotResponse, |
| 21 | type RelayLiveActivityRegistrationRequest, |
| 22 | RelayMobileRegistrationScope, |
| 23 | type RelayOkResponse, |
| 24 | type RelayPublicClientId, |
| 25 | RelayRegisterDeviceEndpoint, |
| 26 | RelayAgentActivitySnapshotEndpoint, |
| 27 | RelayRegisterLiveActivityEndpoint, |
| 28 | RelayProtectedError, |
| 29 | type RelayProtectedError as RelayProtectedErrorType, |
| 30 | RelayUnregisterDeviceEndpoint, |
| 31 | } from "@t3tools/contracts/relay"; |
| 32 | import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; |
| 33 | import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; |
| 34 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; |
| 35 | import * as Clock from "effect/Clock"; |
| 36 | import * as Context from "effect/Context"; |
| 37 | import * as Duration from "effect/Duration"; |
| 38 | import * as Effect from "effect/Effect"; |
| 39 | import * as Layer from "effect/Layer"; |
| 40 | import * as Option from "effect/Option"; |
| 41 | import * as Schema from "effect/Schema"; |
| 42 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 43 | import * as HttpClientError from "effect/http/HttpClientError"; |
| 44 | import type * as HttpMethod from "effect/http/HttpMethod"; |
| 45 | import * as HttpApiClient from "effect/http-api/HttpApiClient"; |
| 46 | import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; |
| 47 | |
| 48 | export interface ManagedRelayDpopProofInput { |
| 49 | readonly method: HttpMethod.HttpMethod; |
| 50 | readonly url: string; |
| 51 | readonly accessToken?: string; |
| 52 | } |
| 53 | |
| 54 | export class ManagedRelayDpopKeyLoadError extends Schema.TaggedError<ManagedRelayDpopKeyLoadError>()( |
| 55 | "ManagedRelayDpopKeyLoadError", |
| 56 | { |
| 57 | keyStore: Schema.Literals(["expo-secure-store", "indexed-db"]), |
| 58 | cause: Schema.Defect(), |
| 59 | }, |
| 60 | ) { |
| 61 | override get message(): string { |
| 62 | return "Could not load relay DPoP proof key."; |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | export class ManagedRelayDpopProofCreationError extends Schema.TaggedError<ManagedRelayDpopProofCreationError>()( |
| 67 | "ManagedRelayDpopProofCreationError", |
| 68 | { |
| 69 | method: Schema.String, |
| 70 | url: Schema.String, |
| 71 | cause: Schema.Defect(), |
| 72 | }, |
| 73 | ) { |
| 74 | override get message(): string { |
| 75 | return `Could not create the relay DPoP proof for ${this.method} ${this.url}.`; |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | export const ManagedRelayDpopSignerError = Schema.Union([ |
| 80 | ManagedRelayDpopKeyLoadError, |
| 81 | ManagedRelayDpopProofCreationError, |
| 82 | ]); |
| 83 | export type ManagedRelayDpopSignerError = typeof ManagedRelayDpopSignerError.Type; |
| 84 | |
| 85 | export const ManagedRelayRequestAction = Schema.Literals([ |
| 86 | "exchange relay DPoP access token", |
| 87 | "list relay-managed environments", |
| 88 | "list relay client devices", |
| 89 | "create relay environment link challenge", |
| 90 | "link relay environment", |
| 91 | "unlink relay environment", |
| 92 | "get relay environment status", |
| 93 | "connect relay environment", |
| 94 | "register relay mobile device", |
| 95 | "unregister relay mobile device", |
| 96 | "register relay live activity", |
| 97 | "read relay agent activity snapshot", |
| 98 | ]); |
| 99 | export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type; |
| 100 | |
| 101 | export const ManagedRelayRequestActivity = Schema.Literals([ |
| 102 | "Relay DPoP access token exchange", |
| 103 | "Relay environment listing", |
| 104 | "Relay client device listing", |
| 105 | "Relay environment link challenge", |
| 106 | "Relay environment linking", |
| 107 | "Relay environment unlinking", |
| 108 | "Relay environment status request", |
| 109 | "Relay environment connection", |
| 110 | "Relay mobile device registration", |
| 111 | "Relay mobile device unregistration", |
| 112 | "Relay Live Activity registration", |
| 113 | "Relay agent activity snapshot", |
| 114 | ]); |
| 115 | export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type; |
| 116 | |
| 117 | export class ManagedRelayRequestTimeoutError extends Schema.TaggedError<ManagedRelayRequestTimeoutError>()( |
| 118 | "ManagedRelayRequestTimeoutError", |
| 119 | { |
| 120 | activity: ManagedRelayRequestActivity, |
| 121 | timeoutMs: Schema.Number, |
| 122 | // The CLIENT span's trace id. A timed-out request has no server response |
| 123 | // to take an id from, but the client span was exported, so carrying its id |
| 124 | // makes the failure searchable instead of logging `traceId: null`. |
| 125 | traceId: Schema.NullOr(Schema.String), |
| 126 | }, |
| 127 | ) { |
| 128 | override get message(): string { |
| 129 | return `${this.activity} timed out. ${NETWORK_BLOCKING_HINT}`; |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | export class ManagedRelayUrlInvalidError extends Schema.TaggedError<ManagedRelayUrlInvalidError>()( |
| 134 | "ManagedRelayUrlInvalidError", |
| 135 | { |
| 136 | relayUrl: Schema.String, |
| 137 | }, |
| 138 | ) { |
| 139 | override get message(): string { |
| 140 | return "Relay URL must be a secure absolute HTTPS origin."; |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | export class ManagedRelayRequestFailedError extends Schema.TaggedError<ManagedRelayRequestFailedError>()( |
| 145 | "ManagedRelayRequestFailedError", |
| 146 | { |
| 147 | action: ManagedRelayRequestAction, |
| 148 | transportFailed: Schema.optionalKey(Schema.Boolean), |
| 149 | cause: Schema.Defect(), |
| 150 | relayError: Schema.optional(RelayProtectedError), |
| 151 | // HTTP status of a relay response that did not decode as a typed relay error. |
| 152 | status: Schema.optional(Schema.Number), |
| 153 | traceId: Schema.optional(Schema.String), |
| 154 | }, |
| 155 | ) { |
| 156 | override get message(): string { |
| 157 | const message = `Could not ${this.action}.`; |
| 158 | return this.transportFailed ? `${message} ${NETWORK_BLOCKING_HINT}` : message; |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | export class ManagedRelayAccessTokenScopesUnexpectedError extends Schema.TaggedError<ManagedRelayAccessTokenScopesUnexpectedError>()( |
| 163 | "ManagedRelayAccessTokenScopesUnexpectedError", |
| 164 | { |
| 165 | requestedScopes: Schema.Array(RelayDpopAccessTokenScope), |
| 166 | grantedScope: Schema.String, |
| 167 | }, |
| 168 | ) { |
| 169 | override get message(): string { |
| 170 | return "Relay granted unexpected DPoP access token scopes."; |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | export class ManagedRelayTokenProofCreationError extends Schema.TaggedError<ManagedRelayTokenProofCreationError>()( |
| 175 | "ManagedRelayTokenProofCreationError", |
| 176 | { |
| 177 | method: Schema.String, |
| 178 | url: Schema.String, |
| 179 | cause: Schema.Defect(), |
| 180 | }, |
| 181 | ) { |
| 182 | override get message(): string { |
| 183 | return "Could not create relay token DPoP proof."; |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | export class ManagedRelayRequestProofCreationError extends Schema.TaggedError<ManagedRelayRequestProofCreationError>()( |
| 188 | "ManagedRelayRequestProofCreationError", |
| 189 | { |
| 190 | method: Schema.String, |
| 191 | url: Schema.String, |
| 192 | cause: Schema.Defect(), |
| 193 | }, |
| 194 | ) { |
| 195 | override get message(): string { |
| 196 | return "Could not create relay request DPoP proof."; |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | export const ManagedRelayClientError = Schema.Union([ |
| 201 | ManagedRelayUrlInvalidError, |
| 202 | ManagedRelayRequestFailedError, |
| 203 | ManagedRelayRequestTimeoutError, |
| 204 | ManagedRelayAccessTokenScopesUnexpectedError, |
| 205 | ManagedRelayDpopKeyLoadError, |
| 206 | ManagedRelayTokenProofCreationError, |
| 207 | ManagedRelayRequestProofCreationError, |
| 208 | ]); |
| 209 | export type ManagedRelayClientError = typeof ManagedRelayClientError.Type; |
| 210 | |
| 211 | type RelayHttpRequestError = |
| 212 | | RelayProtectedErrorType |
| 213 | | HttpClientError.HttpClientError |
| 214 | | Schema.SchemaError; |
| 215 | |
| 216 | export class ManagedRelayDpopSigner extends Context.Service< |
| 217 | ManagedRelayDpopSigner, |
| 218 | { |
| 219 | readonly thumbprint: Effect.Effect<string, ManagedRelayDpopKeyLoadError>; |
| 220 | readonly createProof: ( |
| 221 | input: ManagedRelayDpopProofInput, |
| 222 | ) => Effect.Effect<string, ManagedRelayDpopProofCreationError>; |
| 223 | } |
| 224 | >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {} |
| 225 | |
| 226 | export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; |
| 227 | |
| 228 | export interface ManagedRelayAccessTokenCacheEntry { |
| 229 | readonly accountId: string; |
| 230 | readonly clientId: RelayPublicClientId; |
| 231 | readonly relayUrl: string; |
| 232 | readonly thumbprint: string; |
| 233 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; |
| 234 | readonly accessToken: string; |
| 235 | readonly expiresAtMillis: number; |
| 236 | } |
| 237 | |
| 238 | export interface ManagedRelayAccessTokenStore { |
| 239 | readonly load: Effect.Effect<ReadonlyArray<ManagedRelayAccessTokenCacheEntry>>; |
| 240 | readonly save: (entries: ReadonlyArray<ManagedRelayAccessTokenCacheEntry>) => Effect.Effect<void>; |
| 241 | readonly clear: Effect.Effect<void>; |
| 242 | } |
| 243 | |
| 244 | export interface ManagedRelayAuthorization { |
| 245 | readonly accessToken: string; |
| 246 | readonly proof: string; |
| 247 | readonly thumbprint: string; |
| 248 | } |
| 249 | |
| 250 | export interface ManagedRelayClientLayerOptions { |
| 251 | readonly relayUrl: string; |
| 252 | readonly clientId: RelayPublicClientId; |
| 253 | readonly accessTokenStore?: ManagedRelayAccessTokenStore; |
| 254 | } |
| 255 | |
| 256 | export class ManagedRelayClient extends Context.Service< |
| 257 | ManagedRelayClient, |
| 258 | { |
| 259 | readonly relayUrl: string; |
| 260 | readonly listEnvironments: (input: { |
| 261 | readonly clerkToken: string; |
| 262 | }) => Effect.Effect<ReadonlyArray<RelayClientEnvironmentRecord>, ManagedRelayClientError>; |
| 263 | readonly listDevices: (input: { |
| 264 | readonly clerkToken: string; |
| 265 | }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, ManagedRelayClientError>; |
| 266 | readonly createEnvironmentLinkChallenge: (input: { |
| 267 | readonly clerkToken: string; |
| 268 | readonly payload: RelayEnvironmentLinkChallengeRequest; |
| 269 | }) => Effect.Effect<RelayEnvironmentLinkChallengeResponse, ManagedRelayClientError>; |
| 270 | readonly linkEnvironment: (input: { |
| 271 | readonly clerkToken: string; |
| 272 | readonly payload: RelayEnvironmentLinkRequest; |
| 273 | }) => Effect.Effect<RelayEnvironmentLinkResponse, ManagedRelayClientError>; |
| 274 | readonly unlinkEnvironment: (input: { |
| 275 | readonly clerkToken: string; |
| 276 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; |
| 277 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; |
| 278 | readonly getEnvironmentStatus: (input: { |
| 279 | readonly clerkToken: string; |
| 280 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; |
| 281 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; |
| 282 | }) => Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelayClientError>; |
| 283 | readonly connectEnvironment: (input: { |
| 284 | readonly clerkToken: string; |
| 285 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; |
| 286 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; |
| 287 | readonly deviceId?: string; |
| 288 | }) => Effect.Effect<RelayEnvironmentConnectResponse, ManagedRelayClientError>; |
| 289 | readonly registerDevice: (input: { |
| 290 | readonly clerkToken: string; |
| 291 | readonly payload: RelayDeviceRegistrationRequest; |
| 292 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; |
| 293 | readonly unregisterDevice: (input: { |
| 294 | readonly clerkToken: string; |
| 295 | readonly deviceId: string; |
| 296 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; |
| 297 | readonly registerLiveActivity: (input: { |
| 298 | readonly clerkToken: string; |
| 299 | readonly payload: RelayLiveActivityRegistrationRequest; |
| 300 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; |
| 301 | readonly getAgentActivitySnapshot: (input: { |
| 302 | readonly clerkToken: string; |
| 303 | }) => Effect.Effect<RelayAgentActivitySnapshotResponse, ManagedRelayClientError>; |
| 304 | readonly resetTokenCache: Effect.Effect<void>; |
| 305 | } |
| 306 | >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} |
| 307 | |
| 308 | const isRelayProtectedError = Schema.is(RelayProtectedError); |
| 309 | |
| 310 | const TRACEPARENT_TRACE_ID = /^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/i; |
| 311 | |
| 312 | // Keeps the status and the relay's traceparent trace ID for a response that |
| 313 | // carries no typed relay error, such as the relay's request-deadline 504. |
| 314 | function untypedResponseFields(cause: RelayHttpRequestError) { |
| 315 | const response = HttpClientError.isHttpClientError(cause) ? cause.response : undefined; |
| 316 | if (!response) return {}; |
| 317 | const traceId = TRACEPARENT_TRACE_ID.exec(response.headers.traceparent ?? "")?.[1]; |
| 318 | return { status: response.status, ...(traceId ? { traceId } : {}) }; |
| 319 | } |
| 320 | |
| 321 | function relayRequestError(action: ManagedRelayRequestAction) { |
| 322 | return (cause: RelayHttpRequestError): ManagedRelayClientError => |
| 323 | new ManagedRelayRequestFailedError({ |
| 324 | action, |
| 325 | transportFailed: |
| 326 | HttpClientError.isHttpClientError(cause) && cause.reason._tag === "TransportError", |
| 327 | cause, |
| 328 | ...(isRelayProtectedError(cause) |
| 329 | ? { relayError: cause, traceId: cause.traceId } |
| 330 | : untypedResponseFields(cause)), |
| 331 | }); |
| 332 | } |
| 333 | |
| 334 | function proofCreationErrorFields(error: ManagedRelayDpopProofCreationError) { |
| 335 | return { |
| 336 | method: error.method, |
| 337 | url: error.url, |
| 338 | cause: error, |
| 339 | }; |
| 340 | } |
| 341 | |
| 342 | function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean { |
| 343 | return ( |
| 344 | error._tag === "ManagedRelayRequestFailedError" && |
| 345 | error.relayError?._tag === "RelayAuthInvalidError" && |
| 346 | error.relayError.reason === "invalid_bearer" |
| 347 | ); |
| 348 | } |
| 349 | |
| 350 | function timeoutRelayRequest(activity: ManagedRelayRequestActivity) { |
| 351 | return <A, E, R>( |
| 352 | effect: Effect.Effect<A, E, R>, |
| 353 | ): Effect.Effect<A, E | ManagedRelayClientError, R> => |
| 354 | effect.pipe( |
| 355 | Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), |
| 356 | Effect.flatMap( |
| 357 | Option.match({ |
| 358 | onNone: () => |
| 359 | Effect.currentParentSpan.pipe( |
| 360 | Effect.map((span) => span.traceId), |
| 361 | Effect.orElseSucceed(() => null), |
| 362 | Effect.flatMap((traceId) => |
| 363 | Effect.fail( |
| 364 | new ManagedRelayRequestTimeoutError({ |
| 365 | activity, |
| 366 | timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, |
| 367 | traceId, |
| 368 | }), |
| 369 | ), |
| 370 | ), |
| 371 | ), |
| 372 | onSome: Effect.succeed, |
| 373 | }), |
| 374 | ), |
| 375 | ); |
| 376 | } |
| 377 | |
| 378 | function tokenMatches( |
| 379 | token: ManagedRelayAccessTokenCacheEntry, |
| 380 | input: { |
| 381 | readonly accountId: string; |
| 382 | readonly clientId: RelayPublicClientId; |
| 383 | readonly relayUrl: string; |
| 384 | readonly thumbprint: string; |
| 385 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; |
| 386 | readonly nowMillis: number; |
| 387 | }, |
| 388 | ): boolean { |
| 389 | return ( |
| 390 | token.accountId === input.accountId && |
| 391 | token.clientId === input.clientId && |
| 392 | token.relayUrl === input.relayUrl && |
| 393 | token.thumbprint === input.thumbprint && |
| 394 | token.expiresAtMillis > input.nowMillis + 5_000 && |
| 395 | input.scopes.every((scope) => token.scopes.includes(scope)) |
| 396 | ); |
| 397 | } |
| 398 | |
| 399 | function relayAccountId(clerkToken: string): Option.Option<string> { |
| 400 | try { |
| 401 | return Option.fromNullishOr(decodeRelayJwt(clerkToken).sub).pipe( |
| 402 | Option.filter((subject) => subject.length > 0), |
| 403 | ); |
| 404 | } catch { |
| 405 | return Option.none(); |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | function bearerHeaders(clerkToken: string) { |
| 410 | return { authorization: `Bearer ${clerkToken}` }; |
| 411 | } |
| 412 | |
| 413 | function dpopHeaders(authorization: ManagedRelayAuthorization) { |
| 414 | return { |
| 415 | authorization: `DPoP ${authorization.accessToken}`, |
| 416 | dpop: authorization.proof, |
| 417 | }; |
| 418 | } |
| 419 | |
| 420 | function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Service"] { |
| 421 | const unavailable = (spanName: string) => |
| 422 | Effect.fn(spanName)(function* () { |
| 423 | return yield* new ManagedRelayUrlInvalidError({ relayUrl }); |
| 424 | }); |
| 425 | return ManagedRelayClient.of({ |
| 426 | relayUrl, |
| 427 | listEnvironments: unavailable("clientRuntime.managedRelay.listEnvironments"), |
| 428 | listDevices: unavailable("clientRuntime.managedRelay.listDevices"), |
| 429 | createEnvironmentLinkChallenge: unavailable( |
| 430 | "clientRuntime.managedRelay.createEnvironmentLinkChallenge", |
| 431 | ), |
| 432 | linkEnvironment: unavailable("clientRuntime.managedRelay.linkEnvironment"), |
| 433 | unlinkEnvironment: unavailable("clientRuntime.managedRelay.unlinkEnvironment"), |
| 434 | getEnvironmentStatus: unavailable("clientRuntime.managedRelay.getEnvironmentStatus"), |
| 435 | connectEnvironment: unavailable("clientRuntime.managedRelay.connectEnvironment"), |
| 436 | registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"), |
| 437 | unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"), |
| 438 | registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"), |
| 439 | getAgentActivitySnapshot: unavailable("clientRuntime.managedRelay.getAgentActivitySnapshot"), |
| 440 | resetTokenCache: Effect.void.pipe( |
| 441 | Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), |
| 442 | ), |
| 443 | }); |
| 444 | } |
| 445 | |
| 446 | /** @public Service construction is part of the canonical Effect module API. */ |
| 447 | export const make = Effect.fn("ManagedRelayClient.make")(function* ( |
| 448 | options: ManagedRelayClientLayerOptions, |
| 449 | ) { |
| 450 | const relayUrl = normalizeSecureRelayUrl(options.relayUrl); |
| 451 | if (relayUrl === null) { |
| 452 | return disabledManagedRelayClient(options.relayUrl); |
| 453 | } |
| 454 | const signer = yield* ManagedRelayDpopSigner; |
| 455 | const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); |
| 456 | const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : []; |
| 457 | const cachedTokens = yield* SynchronizedRef.make< |
| 458 | ReadonlyArray<ManagedRelayAccessTokenCacheEntry> |
| 459 | >(initialTokens.filter((token) => token.clientId === options.clientId)); |
| 460 | const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); |
| 461 | |
| 462 | type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">; |
| 463 | const dpopProofTargets = { |
| 464 | exchangeAccessToken: (): DpopProofTarget => ({ |
| 465 | method: RelayExchangeDpopAccessTokenEndpoint.method, |
| 466 | url: urlBuilder.token.exchangeDpopAccessToken(), |
| 467 | }), |
| 468 | getEnvironmentStatus: ( |
| 469 | environmentId: RelayClientEnvironmentRecord["environmentId"], |
| 470 | ): DpopProofTarget => ({ |
| 471 | method: RelayGetEnvironmentStatusEndpoint.method, |
| 472 | url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), |
| 473 | }), |
| 474 | connectEnvironment: ( |
| 475 | environmentId: RelayClientEnvironmentRecord["environmentId"], |
| 476 | ): DpopProofTarget => ({ |
| 477 | method: RelayConnectEnvironmentEndpoint.method, |
| 478 | url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), |
| 479 | }), |
| 480 | registerDevice: (): DpopProofTarget => ({ |
| 481 | method: RelayRegisterDeviceEndpoint.method, |
| 482 | url: urlBuilder.mobile.registerDevice(), |
| 483 | }), |
| 484 | unregisterDevice: (deviceId: string): DpopProofTarget => ({ |
| 485 | method: RelayUnregisterDeviceEndpoint.method, |
| 486 | url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), |
| 487 | }), |
| 488 | getAgentActivitySnapshot: (): DpopProofTarget => ({ |
| 489 | method: RelayAgentActivitySnapshotEndpoint.method, |
| 490 | url: urlBuilder.mobile.getAgentActivitySnapshot(), |
| 491 | }), |
| 492 | registerLiveActivity: (): DpopProofTarget => ({ |
| 493 | method: RelayRegisterLiveActivityEndpoint.method, |
| 494 | url: urlBuilder.mobile.registerLiveActivity(), |
| 495 | }), |
| 496 | }; |
| 497 | |
| 498 | const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")( |
| 499 | function* (input: { |
| 500 | readonly clerkToken: string; |
| 501 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; |
| 502 | }) { |
| 503 | yield* Effect.annotateCurrentSpan({ |
| 504 | "relay.client_id": options.clientId, |
| 505 | "relay.scopes": input.scopes.join(" "), |
| 506 | }); |
| 507 | const proof = yield* signer |
| 508 | .createProof(dpopProofTargets.exchangeAccessToken()) |
| 509 | .pipe( |
| 510 | Effect.mapError( |
| 511 | (error) => new ManagedRelayTokenProofCreationError(proofCreationErrorFields(error)), |
| 512 | ), |
| 513 | ); |
| 514 | const response = yield* client.token |
| 515 | .exchangeDpopAccessToken({ |
| 516 | headers: { dpop: proof }, |
| 517 | payload: { |
| 518 | grant_type: RelayDpopTokenExchangeGrantType, |
| 519 | subject_token: input.clerkToken, |
| 520 | subject_token_type: RelayJwtSubjectTokenType, |
| 521 | requested_token_type: RelayAccessTokenType, |
| 522 | resource: relayUrl, |
| 523 | scope: encodeOAuthScope(input.scopes), |
| 524 | client_id: options.clientId, |
| 525 | }, |
| 526 | }) |
| 527 | .pipe( |
| 528 | Effect.mapError(relayRequestError("exchange relay DPoP access token")), |
| 529 | timeoutRelayRequest("Relay DPoP access token exchange"), |
| 530 | ); |
| 531 | if (!oauthScopeSetEquals(response.scope, input.scopes)) { |
| 532 | return yield* new ManagedRelayAccessTokenScopesUnexpectedError({ |
| 533 | requestedScopes: input.scopes, |
| 534 | grantedScope: response.scope, |
| 535 | }); |
| 536 | } |
| 537 | return response; |
| 538 | }, |
| 539 | ); |
| 540 | |
| 541 | const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( |
| 542 | function* (input: { |
| 543 | readonly clerkToken: string; |
| 544 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; |
| 545 | readonly thumbprint: string; |
| 546 | }) { |
| 547 | yield* Effect.annotateCurrentSpan({ |
| 548 | "relay.client_id": options.clientId, |
| 549 | "relay.scopes": input.scopes.join(" "), |
| 550 | }); |
| 551 | const nowMillis = yield* Clock.currentTimeMillis; |
| 552 | const accountId = relayAccountId(input.clerkToken); |
| 553 | if (Option.isNone(accountId)) { |
| 554 | yield* Effect.annotateCurrentSpan({ |
| 555 | "relay.token_cache.result": "bypass", |
| 556 | "relay.token_cache.bypass_reason": "invalid_subject_token", |
| 557 | }); |
| 558 | const response = yield* exchangeAccessToken(input); |
| 559 | return { |
| 560 | accountId: "", |
| 561 | clientId: options.clientId, |
| 562 | relayUrl, |
| 563 | thumbprint: input.thumbprint, |
| 564 | scopes: input.scopes, |
| 565 | accessToken: response.access_token, |
| 566 | expiresAtMillis: nowMillis + response.expires_in * 1_000, |
| 567 | } satisfies ManagedRelayAccessTokenCacheEntry; |
| 568 | } |
| 569 | const match = { |
| 570 | accountId: accountId.value, |
| 571 | clientId: options.clientId, |
| 572 | relayUrl, |
| 573 | thumbprint: input.thumbprint, |
| 574 | scopes: input.scopes, |
| 575 | nowMillis, |
| 576 | }; |
| 577 | // Cache hits do not need to wait for an unrelated exchange or store write. |
| 578 | const cached = (yield* SynchronizedRef.get(cachedTokens)).find((token) => |
| 579 | tokenMatches(token, match), |
| 580 | ); |
| 581 | if (cached) { |
| 582 | yield* Effect.annotateCurrentSpan({ |
| 583 | "relay.token_cache.result": "hit", |
| 584 | }); |
| 585 | return cached; |
| 586 | } |
| 587 | return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => |
| 588 | Effect.gen(function* () { |
| 589 | const lookupMillis = yield* Clock.currentTimeMillis; |
| 590 | const activeTokens = tokens.filter( |
| 591 | (token) => token.expiresAtMillis > lookupMillis + 5_000, |
| 592 | ); |
| 593 | const cached = activeTokens.find((token) => |
| 594 | tokenMatches(token, { ...match, nowMillis: lookupMillis }), |
| 595 | ); |
| 596 | if (cached) { |
| 597 | yield* Effect.annotateCurrentSpan({ |
| 598 | "relay.token_cache.result": "hit", |
| 599 | }); |
| 600 | return [cached, activeTokens] as const; |
| 601 | } |
| 602 | yield* Effect.annotateCurrentSpan({ |
| 603 | "relay.token_cache.result": "miss", |
| 604 | }); |
| 605 | const response = yield* exchangeAccessToken(input); |
| 606 | const next: ManagedRelayAccessTokenCacheEntry = { |
| 607 | accountId: accountId.value, |
| 608 | clientId: options.clientId, |
| 609 | relayUrl, |
| 610 | thumbprint: input.thumbprint, |
| 611 | scopes: input.scopes, |
| 612 | accessToken: response.access_token, |
| 613 | expiresAtMillis: lookupMillis + response.expires_in * 1_000, |
| 614 | }; |
| 615 | const nextTokens = [...activeTokens, next]; |
| 616 | if (options.accessTokenStore) { |
| 617 | yield* options.accessTokenStore.save(nextTokens); |
| 618 | } |
| 619 | return [next, nextTokens] as const; |
| 620 | }), |
| 621 | ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection")); |
| 622 | }, |
| 623 | ); |
| 624 | |
| 625 | const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: { |
| 626 | readonly clerkToken: string; |
| 627 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; |
| 628 | readonly target: DpopProofTarget; |
| 629 | }) { |
| 630 | yield* Effect.annotateCurrentSpan({ |
| 631 | "relay.client_id": options.clientId, |
| 632 | "relay.scopes": input.scopes.join(" "), |
| 633 | "http.request.method": input.target.method, |
| 634 | "url.full": input.target.url, |
| 635 | }); |
| 636 | const thumbprint = yield* signer.thumbprint; |
| 637 | const token = yield* obtainAccessToken({ |
| 638 | clerkToken: input.clerkToken, |
| 639 | scopes: input.scopes, |
| 640 | thumbprint, |
| 641 | }); |
| 642 | const proof = yield* signer |
| 643 | .createProof({ |
| 644 | ...input.target, |
| 645 | accessToken: token.accessToken, |
| 646 | }) |
| 647 | .pipe( |
| 648 | Effect.mapError( |
| 649 | (error) => new ManagedRelayRequestProofCreationError(proofCreationErrorFields(error)), |
| 650 | ), |
| 651 | ); |
| 652 | return { accessToken: token.accessToken, proof, thumbprint }; |
| 653 | }); |
| 654 | |
| 655 | const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")( |
| 656 | function* (accessToken: string) { |
| 657 | return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { |
| 658 | const nextTokens = tokens.filter((token) => token.accessToken !== accessToken); |
| 659 | if (nextTokens.length === tokens.length) { |
| 660 | return Effect.succeed([false, tokens] as const); |
| 661 | } |
| 662 | return ( |
| 663 | options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void |
| 664 | ).pipe(Effect.as([true, nextTokens] as const)); |
| 665 | }); |
| 666 | }, |
| 667 | ); |
| 668 | |
| 669 | const runDpopRequest = <A>( |
| 670 | input: { |
| 671 | readonly clerkToken: string; |
| 672 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; |
| 673 | readonly target: DpopProofTarget; |
| 674 | }, |
| 675 | request: ( |
| 676 | authorization: ManagedRelayAuthorization, |
| 677 | ) => Effect.Effect<A, ManagedRelayClientError>, |
| 678 | ): Effect.Effect<A, ManagedRelayClientError> => { |
| 679 | const attempt = (refreshRejectedToken: boolean): Effect.Effect<A, ManagedRelayClientError> => |
| 680 | authorize(input).pipe( |
| 681 | Effect.flatMap((authorization) => |
| 682 | request(authorization).pipe( |
| 683 | Effect.catchIf(isRejectedDpopAccessToken, (error) => |
| 684 | invalidateAccessToken(authorization.accessToken).pipe( |
| 685 | Effect.tap((invalidated) => |
| 686 | Effect.annotateCurrentSpan({ |
| 687 | "relay.token_cache.invalidated": invalidated, |
| 688 | "relay.token_cache.invalidation_reason": "invalid_bearer", |
| 689 | "relay.token_cache.retry_after_invalidation": refreshRejectedToken, |
| 690 | }), |
| 691 | ), |
| 692 | Effect.tap((invalidated) => |
| 693 | invalidated && refreshRejectedToken |
| 694 | ? Effect.logWarning( |
| 695 | "Relay rejected a cached DPoP access token; refreshing it once.", |
| 696 | ) |
| 697 | : Effect.void, |
| 698 | ), |
| 699 | Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)), |
| 700 | ), |
| 701 | ), |
| 702 | ), |
| 703 | ), |
| 704 | ); |
| 705 | return attempt(true); |
| 706 | }; |
| 707 | |
| 708 | const mobileRegistrationRequest = <A>( |
| 709 | input: { |
| 710 | readonly clerkToken: string; |
| 711 | readonly target: DpopProofTarget; |
| 712 | }, |
| 713 | request: ( |
| 714 | authorization: ManagedRelayAuthorization, |
| 715 | ) => Effect.Effect<A, ManagedRelayClientError>, |
| 716 | ) => |
| 717 | runDpopRequest( |
| 718 | { |
| 719 | ...input, |
| 720 | scopes: [RelayMobileRegistrationScope], |
| 721 | }, |
| 722 | request, |
| 723 | ); |
| 724 | |
| 725 | return ManagedRelayClient.of({ |
| 726 | relayUrl, |
| 727 | listEnvironments: Effect.fnUntraced(function* (input) { |
| 728 | return yield* client.client |
| 729 | .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) |
| 730 | .pipe( |
| 731 | Effect.map((response) => response.environments), |
| 732 | Effect.mapError(relayRequestError("list relay-managed environments")), |
| 733 | timeoutRelayRequest("Relay environment listing"), |
| 734 | ); |
| 735 | }, Effect.withSpan("clientRuntime.managedRelay.listEnvironments")), |
| 736 | listDevices: Effect.fnUntraced(function* (input) { |
| 737 | return yield* client.client |
| 738 | .listDevicesV2({ |
| 739 | headers: bearerHeaders(input.clerkToken), |
| 740 | }) |
| 741 | .pipe( |
| 742 | Effect.map((response) => response.devices), |
| 743 | Effect.mapError(relayRequestError("list relay client devices")), |
| 744 | timeoutRelayRequest("Relay client device listing"), |
| 745 | ); |
| 746 | }, Effect.withSpan("clientRuntime.managedRelay.listDevices")), |
| 747 | createEnvironmentLinkChallenge: Effect.fnUntraced(function* (input) { |
| 748 | return yield* client.client |
| 749 | .createEnvironmentLinkChallenge({ |
| 750 | headers: bearerHeaders(input.clerkToken), |
| 751 | payload: input.payload, |
| 752 | }) |
| 753 | .pipe( |
| 754 | Effect.mapError(relayRequestError("create relay environment link challenge")), |
| 755 | timeoutRelayRequest("Relay environment link challenge"), |
| 756 | ); |
| 757 | }, Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge")), |
| 758 | linkEnvironment: Effect.fnUntraced(function* (input) { |
| 759 | return yield* client.client |
| 760 | .linkEnvironment({ |
| 761 | headers: bearerHeaders(input.clerkToken), |
| 762 | payload: input.payload, |
| 763 | }) |
| 764 | .pipe( |
| 765 | Effect.mapError(relayRequestError("link relay environment")), |
| 766 | timeoutRelayRequest("Relay environment linking"), |
| 767 | ); |
| 768 | }, Effect.withSpan("clientRuntime.managedRelay.linkEnvironment")), |
| 769 | unlinkEnvironment: Effect.fnUntraced(function* (input) { |
| 770 | return yield* client.client |
| 771 | .unlinkEnvironment({ |
| 772 | headers: bearerHeaders(input.clerkToken), |
| 773 | params: { environmentId: input.environmentId }, |
| 774 | }) |
| 775 | .pipe( |
| 776 | Effect.mapError(relayRequestError("unlink relay environment")), |
| 777 | timeoutRelayRequest("Relay environment unlinking"), |
| 778 | ); |
| 779 | }, Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment")), |
| 780 | getEnvironmentStatus: Effect.fnUntraced(function* (input) { |
| 781 | yield* Effect.annotateCurrentSpan({ |
| 782 | "environment.id": input.environmentId, |
| 783 | }); |
| 784 | return yield* runDpopRequest( |
| 785 | { |
| 786 | clerkToken: input.clerkToken, |
| 787 | scopes: input.scopes, |
| 788 | target: dpopProofTargets.getEnvironmentStatus(input.environmentId), |
| 789 | }, |
| 790 | (authorization) => |
| 791 | client.dpopClient |
| 792 | .getEnvironmentStatus({ |
| 793 | headers: dpopHeaders(authorization), |
| 794 | params: { environmentId: input.environmentId }, |
| 795 | }) |
| 796 | .pipe( |
| 797 | Effect.mapError(relayRequestError("get relay environment status")), |
| 798 | timeoutRelayRequest("Relay environment status request"), |
| 799 | ), |
| 800 | ); |
| 801 | }, Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus")), |
| 802 | connectEnvironment: Effect.fnUntraced(function* (input) { |
| 803 | yield* Effect.annotateCurrentSpan({ |
| 804 | "environment.id": input.environmentId, |
| 805 | }); |
| 806 | return yield* runDpopRequest( |
| 807 | { |
| 808 | clerkToken: input.clerkToken, |
| 809 | scopes: input.scopes, |
| 810 | target: dpopProofTargets.connectEnvironment(input.environmentId), |
| 811 | }, |
| 812 | (authorization) => { |
| 813 | const payload: RelayEnvironmentConnectRequest = { |
| 814 | ...(input.deviceId ? { deviceId: input.deviceId } : {}), |
| 815 | clientKeyThumbprint: authorization.thumbprint, |
| 816 | }; |
| 817 | return client.dpopClient |
| 818 | .connectEnvironment({ |
| 819 | headers: dpopHeaders(authorization), |
| 820 | params: { environmentId: input.environmentId }, |
| 821 | payload, |
| 822 | }) |
| 823 | .pipe( |
| 824 | Effect.mapError(relayRequestError("connect relay environment")), |
| 825 | timeoutRelayRequest("Relay environment connection"), |
| 826 | ); |
| 827 | }, |
| 828 | ); |
| 829 | }, Effect.withSpan("clientRuntime.managedRelay.connectEnvironment")), |
| 830 | registerDevice: Effect.fnUntraced(function* (input) { |
| 831 | return yield* mobileRegistrationRequest( |
| 832 | { |
| 833 | clerkToken: input.clerkToken, |
| 834 | target: dpopProofTargets.registerDevice(), |
| 835 | }, |
| 836 | (authorization) => |
| 837 | client.mobile |
| 838 | .registerDevice({ |
| 839 | headers: dpopHeaders(authorization), |
| 840 | payload: input.payload, |
| 841 | }) |
| 842 | .pipe( |
| 843 | Effect.mapError(relayRequestError("register relay mobile device")), |
| 844 | timeoutRelayRequest("Relay mobile device registration"), |
| 845 | ), |
| 846 | ); |
| 847 | }, Effect.withSpan("clientRuntime.managedRelay.registerDevice")), |
| 848 | unregisterDevice: Effect.fnUntraced(function* (input) { |
| 849 | return yield* mobileRegistrationRequest( |
| 850 | { |
| 851 | clerkToken: input.clerkToken, |
| 852 | target: dpopProofTargets.unregisterDevice(input.deviceId), |
| 853 | }, |
| 854 | (authorization) => |
| 855 | client.mobile |
| 856 | .unregisterDevice({ |
| 857 | headers: dpopHeaders(authorization), |
| 858 | params: { deviceId: input.deviceId }, |
| 859 | }) |
| 860 | .pipe( |
| 861 | Effect.mapError(relayRequestError("unregister relay mobile device")), |
| 862 | timeoutRelayRequest("Relay mobile device unregistration"), |
| 863 | ), |
| 864 | ); |
| 865 | }, Effect.withSpan("clientRuntime.managedRelay.unregisterDevice")), |
| 866 | getAgentActivitySnapshot: Effect.fnUntraced(function* (input) { |
| 867 | return yield* mobileRegistrationRequest( |
| 868 | { |
| 869 | clerkToken: input.clerkToken, |
| 870 | target: dpopProofTargets.getAgentActivitySnapshot(), |
| 871 | }, |
| 872 | (authorization) => |
| 873 | client.mobile |
| 874 | .getAgentActivitySnapshot({ |
| 875 | headers: dpopHeaders(authorization), |
| 876 | }) |
| 877 | .pipe( |
| 878 | Effect.mapError(relayRequestError("read relay agent activity snapshot")), |
| 879 | timeoutRelayRequest("Relay agent activity snapshot"), |
| 880 | ), |
| 881 | ); |
| 882 | }, Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot")), |
| 883 | registerLiveActivity: Effect.fnUntraced(function* (input) { |
| 884 | return yield* mobileRegistrationRequest( |
| 885 | { |
| 886 | clerkToken: input.clerkToken, |
| 887 | target: dpopProofTargets.registerLiveActivity(), |
| 888 | }, |
| 889 | (authorization) => |
| 890 | client.mobile |
| 891 | .registerLiveActivity({ |
| 892 | headers: dpopHeaders(authorization), |
| 893 | payload: input.payload, |
| 894 | }) |
| 895 | .pipe( |
| 896 | Effect.mapError(relayRequestError("register relay live activity")), |
| 897 | timeoutRelayRequest("Relay Live Activity registration"), |
| 898 | ), |
| 899 | ); |
| 900 | }, Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity")), |
| 901 | resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( |
| 902 | Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), |
| 903 | Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), |
| 904 | ), |
| 905 | }); |
| 906 | }); |
| 907 | |
| 908 | export const layer = (options: ManagedRelayClientLayerOptions) => |
| 909 | Layer.effect(ManagedRelayClient, make(options)); |