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