| 1 | import { |
| 2 | authScopeRequiredResponse, |
| 3 | AuthAccessReadScope, |
| 4 | AuthAccessWriteScope, |
| 5 | AuthStandardClientScopes, |
| 6 | AuthGrantScope, |
| 7 | EnvironmentAuthInvalidError, |
| 8 | type EnvironmentAuthInvalidReason, |
| 9 | EnvironmentHttpApi, |
| 10 | EnvironmentInternalError, |
| 11 | type EnvironmentInternalErrorReason, |
| 12 | EnvironmentOperationForbiddenError, |
| 13 | EnvironmentRequestInvalidError, |
| 14 | type EnvironmentRequestInvalidReason, |
| 15 | EnvironmentResourceNotFoundError, |
| 16 | type EnvironmentResourceNotFoundReason, |
| 17 | EnvironmentScopeRequiredError, |
| 18 | EnvironmentAuthenticatedAuth, |
| 19 | EnvironmentAuthenticatedPrincipal, |
| 20 | } from "@t3tools/contracts"; |
| 21 | import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; |
| 22 | import { parseOAuthScope } from "@t3tools/shared/oauthScope"; |
| 23 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 24 | import * as DateTime from "effect/DateTime"; |
| 25 | import * as Effect from "effect/Effect"; |
| 26 | import * as Schema from "effect/Schema"; |
| 27 | import * as Layer from "effect/Layer"; |
| 28 | import * as Cookies from "effect/http/Cookies"; |
| 29 | import * as HttpEffect from "effect/http/HttpEffect"; |
| 30 | import { HttpServerRequest, HttpServerResponse } from "effect/http"; |
| 31 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 32 | |
| 33 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 34 | import * as SessionStore from "./SessionStore.ts"; |
| 35 | import { deriveAuthClientMetadata } from "./utils.ts"; |
| 36 | import { verifyRequestDpopProof } from "./dpop.ts"; |
| 37 | |
| 38 | const CREDENTIAL_RESPONSE_HEADERS = { |
| 39 | "cache-control": "no-store", |
| 40 | pragma: "no-cache", |
| 41 | } as const; |
| 42 | |
| 43 | const appendCredentialResponseHeaders = HttpEffect.appendPreResponseHandler((_request, response) => |
| 44 | Effect.succeed(HttpServerResponse.setHeaders(response, CREDENTIAL_RESPONSE_HEADERS)), |
| 45 | ); |
| 46 | |
| 47 | const appendDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) => |
| 48 | Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", "DPoP")), |
| 49 | ); |
| 50 | |
| 51 | const appendDpopChallengeOnUnauthorized = (error: EnvironmentAuthInvalidError) => |
| 52 | Effect.gen(function* () { |
| 53 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 54 | const usesDpop = |
| 55 | (request.originalUrl.startsWith("/oauth/token") && request.headers.dpop !== undefined) || |
| 56 | request.headers.authorization?.startsWith("DPoP ") === true; |
| 57 | if (usesDpop) { |
| 58 | yield* appendDpopChallengeHeader; |
| 59 | } |
| 60 | return yield* error; |
| 61 | }); |
| 62 | |
| 63 | const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( |
| 64 | Effect.map((span) => span.traceId), |
| 65 | Effect.orElseSucceed(() => "unavailable"), |
| 66 | ); |
| 67 | |
| 68 | export function annotateEnvironmentRequest(endpoint: string) { |
| 69 | return Effect.gen(function* () { |
| 70 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 71 | const url = HttpServerRequest.toURL(request); |
| 72 | const traceId = yield* currentEnvironmentTraceId; |
| 73 | |
| 74 | yield* Effect.addFinalizer((exit) => |
| 75 | exit._tag === "Failure" |
| 76 | ? Effect.logWarning("environment api request failed", { |
| 77 | endpoint, |
| 78 | traceId, |
| 79 | errorTag: causeErrorTag(exit.cause), |
| 80 | cause: exit.cause, |
| 81 | }) |
| 82 | : Effect.void, |
| 83 | ); |
| 84 | yield* Effect.annotateLogsScoped({ "environment.endpoint": endpoint, traceId }); |
| 85 | yield* Effect.annotateCurrentSpan({ |
| 86 | "environment.endpoint": endpoint, |
| 87 | "http.request.method": request.method, |
| 88 | "url.path": url._tag === "Some" ? url.value.pathname : "unknown", |
| 89 | }); |
| 90 | }); |
| 91 | } |
| 92 | |
| 93 | export function failEnvironmentAuthInvalid( |
| 94 | reason: EnvironmentAuthInvalidReason, |
| 95 | dpopFailureReason?: DpopFailureReason, |
| 96 | ) { |
| 97 | return currentEnvironmentTraceId.pipe( |
| 98 | Effect.flatMap((traceId) => |
| 99 | Effect.fail( |
| 100 | new EnvironmentAuthInvalidError({ |
| 101 | code: "auth_invalid", |
| 102 | reason, |
| 103 | ...(dpopFailureReason === undefined ? {} : { dpopFailureReason }), |
| 104 | traceId, |
| 105 | }), |
| 106 | ), |
| 107 | ), |
| 108 | ); |
| 109 | } |
| 110 | |
| 111 | export function failEnvironmentInvalidRequest(reason: EnvironmentRequestInvalidReason) { |
| 112 | return currentEnvironmentTraceId.pipe( |
| 113 | Effect.flatMap((traceId) => |
| 114 | Effect.fail(new EnvironmentRequestInvalidError({ code: "invalid_request", reason, traceId })), |
| 115 | ), |
| 116 | ); |
| 117 | } |
| 118 | |
| 119 | export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope) { |
| 120 | return currentEnvironmentTraceId.pipe( |
| 121 | Effect.flatMap((traceId) => |
| 122 | Effect.fail( |
| 123 | new EnvironmentScopeRequiredError({ |
| 124 | code: "insufficient_scope", |
| 125 | ...authScopeRequiredResponse(requiredScope), |
| 126 | traceId, |
| 127 | }), |
| 128 | ), |
| 129 | ), |
| 130 | ); |
| 131 | } |
| 132 | |
| 133 | function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_allowed") { |
| 134 | return currentEnvironmentTraceId.pipe( |
| 135 | Effect.flatMap((traceId) => |
| 136 | Effect.fail( |
| 137 | new EnvironmentOperationForbiddenError({ |
| 138 | code: "operation_forbidden", |
| 139 | reason, |
| 140 | traceId, |
| 141 | }), |
| 142 | ), |
| 143 | ), |
| 144 | ); |
| 145 | } |
| 146 | |
| 147 | export function failEnvironmentNotFound(reason: EnvironmentResourceNotFoundReason) { |
| 148 | return currentEnvironmentTraceId.pipe( |
| 149 | Effect.flatMap((traceId) => |
| 150 | Effect.fail(new EnvironmentResourceNotFoundError({ code: "not_found", reason, traceId })), |
| 151 | ), |
| 152 | ); |
| 153 | } |
| 154 | |
| 155 | /** |
| 156 | * `<img>` and WebSocket cannot set headers, so media routes (device hub, |
| 157 | * preview stream) authenticate the way the `/ws` upgrade does: a cookie for browser |
| 158 | * sessions, or a short-lived `wsTicket` minted over authenticated HTTP for |
| 159 | * bearer and DPoP clients. The upgrade authenticator already implements that |
| 160 | * fallback order, so it is used for plain requests as well. |
| 161 | */ |
| 162 | export const authenticateMediaRequest = (requiredScope: AuthEnvironmentScope) => |
| 163 | Effect.gen(function* () { |
| 164 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 165 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 166 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( |
| 167 | Effect.catch((error) => |
| 168 | Effect.gen(function* () { |
| 169 | if (EnvironmentAuth.isServerAuthCredentialError(error)) { |
| 170 | return yield* failEnvironmentAuthInvalid( |
| 171 | EnvironmentAuth.serverAuthCredentialReason(error), |
| 172 | EnvironmentAuth.serverAuthDpopFailureReason(error), |
| 173 | ); |
| 174 | } |
| 175 | return yield* failEnvironmentInternal("internal_error", error); |
| 176 | }), |
| 177 | ), |
| 178 | ); |
| 179 | if (!session.scopes.includes(requiredScope)) { |
| 180 | return yield* failEnvironmentScopeRequired(requiredScope); |
| 181 | } |
| 182 | return session; |
| 183 | }); |
| 184 | |
| 185 | export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) { |
| 186 | return Effect.gen(function* () { |
| 187 | const traceId = yield* currentEnvironmentTraceId; |
| 188 | if (error !== undefined) { |
| 189 | yield* Effect.logError("environment api operation failed", { |
| 190 | reason, |
| 191 | traceId, |
| 192 | cause: error, |
| 193 | }); |
| 194 | } |
| 195 | return yield* new EnvironmentInternalError({ code: "internal_error", reason, traceId }); |
| 196 | }); |
| 197 | } |
| 198 | |
| 199 | const appendSessionCookie = (cookieName: string, token: string, expiresAt: DateTime.DateTime) => |
| 200 | Effect.fromResult( |
| 201 | Cookies.set(Cookies.empty, cookieName, token, { |
| 202 | expires: DateTime.toDate(expiresAt), |
| 203 | httpOnly: true, |
| 204 | path: "/", |
| 205 | sameSite: "lax", |
| 206 | }), |
| 207 | ).pipe( |
| 208 | Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")), |
| 209 | Effect.flatMap((cookies) => |
| 210 | HttpEffect.appendPreResponseHandler((_request, response) => |
| 211 | Effect.succeed(HttpServerResponse.mergeCookies(response, cookies)), |
| 212 | ), |
| 213 | ), |
| 214 | ); |
| 215 | |
| 216 | export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( |
| 217 | scope: AuthEnvironmentScope, |
| 218 | ) { |
| 219 | const session = yield* EnvironmentAuthenticatedPrincipal; |
| 220 | if (!session.scopes.has(scope)) { |
| 221 | return yield* failEnvironmentScopeRequired(scope); |
| 222 | } |
| 223 | return session; |
| 224 | }); |
| 225 | |
| 226 | export const layerAuthenticatedAuth = Layer.effect( |
| 227 | EnvironmentAuthenticatedAuth, |
| 228 | Effect.gen(function* () { |
| 229 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 230 | return (httpEffect) => |
| 231 | Effect.gen(function* () { |
| 232 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 233 | const session = yield* serverAuth.authenticateHttpRequest(request).pipe( |
| 234 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 235 | failEnvironmentAuthInvalid( |
| 236 | EnvironmentAuth.serverAuthCredentialReason(error), |
| 237 | EnvironmentAuth.serverAuthDpopFailureReason(error), |
| 238 | ), |
| 239 | ), |
| 240 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 241 | failEnvironmentInternal("internal_error", error), |
| 242 | ), |
| 243 | ); |
| 244 | return yield* httpEffect.pipe( |
| 245 | Effect.provideService(EnvironmentAuthenticatedPrincipal, { |
| 246 | ...session, |
| 247 | scopes: new Set(session.scopes), |
| 248 | }), |
| 249 | ); |
| 250 | }).pipe(Effect.catchTags({ EnvironmentAuthInvalidError: appendDpopChallengeOnUnauthorized })); |
| 251 | }), |
| 252 | ); |
| 253 | |
| 254 | export const layer = HttpApiBuilder.group( |
| 255 | EnvironmentHttpApi, |
| 256 | "auth", |
| 257 | Effect.fnUntraced(function* (handlers) { |
| 258 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 259 | const sessions = yield* SessionStore.SessionStore; |
| 260 | |
| 261 | return handlers |
| 262 | .handle( |
| 263 | "session", |
| 264 | Effect.fn("environment.auth.session")( |
| 265 | function* (args) { |
| 266 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 267 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 268 | const result = yield* serverAuth.getSessionState(request); |
| 269 | const credential = EnvironmentAuth.selectRequestCredential( |
| 270 | request, |
| 271 | sessions.cookieName, |
| 272 | sessions.legacyCookieName, |
| 273 | ); |
| 274 | if ( |
| 275 | credential?.source === "legacy-cookie" && |
| 276 | result.authenticated && |
| 277 | result.sessionMethod === "browser-session-cookie" && |
| 278 | result.expiresAt |
| 279 | ) { |
| 280 | yield* appendSessionCookie(sessions.cookieName, credential.token, result.expiresAt); |
| 281 | yield* appendCredentialResponseHeaders; |
| 282 | } |
| 283 | return result; |
| 284 | }, |
| 285 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 286 | failEnvironmentInternal("internal_error", error), |
| 287 | ), |
| 288 | ), |
| 289 | ) |
| 290 | .handle( |
| 291 | "browserSession", |
| 292 | Effect.fn("environment.auth.browserSession")( |
| 293 | function* (args) { |
| 294 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 295 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 296 | const previousCredential = EnvironmentAuth.selectRequestCredential( |
| 297 | request, |
| 298 | sessions.cookieName, |
| 299 | sessions.legacyCookieName, |
| 300 | ); |
| 301 | const result = yield* serverAuth.createBrowserSession( |
| 302 | args.payload.credential, |
| 303 | deriveAuthClientMetadata({ request }), |
| 304 | previousCredential && |
| 305 | EnvironmentAuth.isBrowserCookieCredential(previousCredential.source) |
| 306 | ? previousCredential.token |
| 307 | : undefined, |
| 308 | ); |
| 309 | const cookieName = result.cookieName ?? sessions.cookieName; |
| 310 | const selectedCookie = yield* Effect.fromResult( |
| 311 | Cookies.set(Cookies.empty, cookieName, result.sessionToken, { |
| 312 | expires: DateTime.toDate(result.response.expiresAt), |
| 313 | httpOnly: true, |
| 314 | path: "/", |
| 315 | sameSite: "lax", |
| 316 | }), |
| 317 | ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); |
| 318 | const sessionCookies = result.expireNormalCookie |
| 319 | ? yield* Effect.fromResult( |
| 320 | Cookies.expireCookie(selectedCookie, sessions.cookieName, { |
| 321 | httpOnly: true, |
| 322 | path: "/", |
| 323 | sameSite: "lax", |
| 324 | }), |
| 325 | ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))) |
| 326 | : selectedCookie; |
| 327 | |
| 328 | yield* HttpEffect.appendPreResponseHandler((_request, response) => |
| 329 | Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), |
| 330 | ); |
| 331 | yield* appendCredentialResponseHeaders; |
| 332 | return result.response; |
| 333 | }, |
| 334 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 335 | failEnvironmentAuthInvalid( |
| 336 | EnvironmentAuth.serverAuthCredentialReason(error), |
| 337 | EnvironmentAuth.serverAuthDpopFailureReason(error), |
| 338 | ), |
| 339 | ), |
| 340 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 341 | failEnvironmentInternal("browser_session_issuance_failed", error), |
| 342 | ), |
| 343 | ), |
| 344 | ) |
| 345 | .handle( |
| 346 | "token", |
| 347 | Effect.fn("environment.auth.token")( |
| 348 | function* (args) { |
| 349 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 350 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 351 | const requestedScopes = |
| 352 | args.payload.scope === undefined |
| 353 | ? undefined |
| 354 | : (parseOAuthScope(args.payload.scope)?.filter(Schema.is(AuthGrantScope)) ?? null); |
| 355 | if (requestedScopes === null || requestedScopes?.length === 0) { |
| 356 | return yield* failEnvironmentInvalidRequest("invalid_scope"); |
| 357 | } |
| 358 | const proofKeyThumbprint = args.headers.dpop |
| 359 | ? yield* verifyRequestDpopProof({ request }).pipe( |
| 360 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 361 | appendDpopChallengeHeader.pipe( |
| 362 | Effect.andThen( |
| 363 | failEnvironmentAuthInvalid( |
| 364 | "invalid_credential", |
| 365 | EnvironmentAuth.serverAuthDpopFailureReason(error), |
| 366 | ), |
| 367 | ), |
| 368 | ), |
| 369 | ), |
| 370 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 371 | failEnvironmentInternal("access_token_issuance_failed", error), |
| 372 | ), |
| 373 | ) |
| 374 | : undefined; |
| 375 | yield* appendCredentialResponseHeaders; |
| 376 | return yield* serverAuth.exchangeBootstrapCredentialForAccessToken( |
| 377 | args.payload.subject_token, |
| 378 | requestedScopes, |
| 379 | deriveAuthClientMetadata({ |
| 380 | request, |
| 381 | presented: { |
| 382 | ...(args.payload.client_label ? { label: args.payload.client_label } : {}), |
| 383 | ...(args.payload.client_device_type |
| 384 | ? { deviceType: args.payload.client_device_type } |
| 385 | : {}), |
| 386 | ...(args.payload.client_os ? { os: args.payload.client_os } : {}), |
| 387 | }, |
| 388 | }), |
| 389 | proofKeyThumbprint ? { proofKeyThumbprint } : undefined, |
| 390 | ); |
| 391 | }, |
| 392 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 393 | failEnvironmentAuthInvalid( |
| 394 | EnvironmentAuth.serverAuthCredentialReason(error), |
| 395 | EnvironmentAuth.serverAuthDpopFailureReason(error), |
| 396 | ), |
| 397 | ), |
| 398 | Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => |
| 399 | failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), |
| 400 | ), |
| 401 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 402 | failEnvironmentInternal("access_token_issuance_failed", error), |
| 403 | ), |
| 404 | ), |
| 405 | ) |
| 406 | .handle( |
| 407 | "webSocketTicket", |
| 408 | Effect.fn("environment.auth.webSocketTicket")( |
| 409 | function* (args) { |
| 410 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 411 | const session = yield* EnvironmentAuthenticatedPrincipal; |
| 412 | yield* appendCredentialResponseHeaders; |
| 413 | return yield* serverAuth.issueWebSocketTicket(session); |
| 414 | }, |
| 415 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 416 | failEnvironmentInternal("websocket_ticket_issuance_failed", error), |
| 417 | ), |
| 418 | ), |
| 419 | ) |
| 420 | .handle( |
| 421 | "pairingCredential", |
| 422 | Effect.fn("environment.auth.pairingCredential")( |
| 423 | function* (args) { |
| 424 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 425 | const session = yield* requireEnvironmentScope(AuthAccessWriteScope); |
| 426 | if (session.parentSessionId !== undefined) |
| 427 | return yield* failEnvironmentInvalidRequest("scope_not_granted"); |
| 428 | const delegatedScopes = args.payload.scopes ?? AuthStandardClientScopes; |
| 429 | if ( |
| 430 | delegatedScopes.length === 0 || |
| 431 | new Set<AuthEnvironmentScope>(delegatedScopes).size !== delegatedScopes.length |
| 432 | ) { |
| 433 | return yield* failEnvironmentInvalidRequest("invalid_scope"); |
| 434 | } |
| 435 | for (const delegatedScope of delegatedScopes) { |
| 436 | if (!session.scopes.has(delegatedScope)) { |
| 437 | return yield* failEnvironmentScopeRequired(delegatedScope); |
| 438 | } |
| 439 | } |
| 440 | return yield* serverAuth.issuePairingCredential(args.payload); |
| 441 | }, |
| 442 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 443 | failEnvironmentInternal("pairing_credential_issuance_failed", error), |
| 444 | ), |
| 445 | ), |
| 446 | ) |
| 447 | .handle( |
| 448 | "delegatedSession", |
| 449 | Effect.fn("environment.auth.delegatedSession")( |
| 450 | function* (args) { |
| 451 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 452 | const session = yield* requireEnvironmentScope(AuthAccessWriteScope); |
| 453 | yield* appendCredentialResponseHeaders; |
| 454 | return yield* serverAuth.issueDelegatedSession( |
| 455 | { ...session, scopes: [...session.scopes] }, |
| 456 | args.payload, |
| 457 | ); |
| 458 | }, |
| 459 | Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => |
| 460 | failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), |
| 461 | ), |
| 462 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 463 | failEnvironmentInternal("access_token_issuance_failed", error), |
| 464 | ), |
| 465 | ), |
| 466 | ) |
| 467 | .handle( |
| 468 | "pairingLinks", |
| 469 | Effect.fn("environment.auth.pairingLinks")( |
| 470 | function* (args) { |
| 471 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 472 | yield* requireEnvironmentScope(AuthAccessReadScope); |
| 473 | return yield* serverAuth.listPairingLinks(); |
| 474 | }, |
| 475 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 476 | failEnvironmentInternal("pairing_links_load_failed", error), |
| 477 | ), |
| 478 | ), |
| 479 | ) |
| 480 | .handle( |
| 481 | "revokePairingLink", |
| 482 | Effect.fn("environment.auth.revokePairingLink")( |
| 483 | function* (args) { |
| 484 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 485 | yield* requireEnvironmentScope(AuthAccessWriteScope); |
| 486 | const revoked = yield* serverAuth.revokePairingLink(args.payload.id); |
| 487 | return { revoked }; |
| 488 | }, |
| 489 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 490 | failEnvironmentInternal("pairing_link_revoke_failed", error), |
| 491 | ), |
| 492 | ), |
| 493 | ) |
| 494 | .handle( |
| 495 | "clients", |
| 496 | Effect.fn("environment.auth.clients")( |
| 497 | function* (args) { |
| 498 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 499 | const session = yield* requireEnvironmentScope(AuthAccessReadScope); |
| 500 | return yield* serverAuth.listClientSessions(session.sessionId); |
| 501 | }, |
| 502 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 503 | failEnvironmentInternal("client_sessions_load_failed", error), |
| 504 | ), |
| 505 | ), |
| 506 | ) |
| 507 | .handle( |
| 508 | "revokeClient", |
| 509 | Effect.fn("environment.auth.revokeClient")( |
| 510 | function* (args) { |
| 511 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 512 | const session = yield* requireEnvironmentScope(AuthAccessWriteScope); |
| 513 | const revoked = yield* serverAuth.revokeClientSession( |
| 514 | session.sessionId, |
| 515 | args.payload.sessionId, |
| 516 | ); |
| 517 | return { revoked }; |
| 518 | }, |
| 519 | Effect.catchTags({ |
| 520 | ServerAuthForbiddenOperationError: () => |
| 521 | failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), |
| 522 | }), |
| 523 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 524 | failEnvironmentInternal("client_session_revoke_failed", error), |
| 525 | ), |
| 526 | ), |
| 527 | ) |
| 528 | .handle( |
| 529 | "revokeOtherClients", |
| 530 | Effect.fn("environment.auth.revokeOtherClients")( |
| 531 | function* (args) { |
| 532 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 533 | const session = yield* requireEnvironmentScope(AuthAccessWriteScope); |
| 534 | const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); |
| 535 | return { revokedCount }; |
| 536 | }, |
| 537 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 538 | failEnvironmentInternal("client_session_revoke_failed", error), |
| 539 | ), |
| 540 | ), |
| 541 | ); |
| 542 | }), |
| 543 | ); |