1import {
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";
21import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";
22import { parseOAuthScope } from "@t3tools/shared/oauthScope";
23import { causeErrorTag } from "@t3tools/shared/observability";
24import * as DateTime from "effect/DateTime";
25import * as Effect from "effect/Effect";
26import * as Schema from "effect/Schema";
27import * as Layer from "effect/Layer";
28import * as Cookies from "effect/http/Cookies";
29import * as HttpEffect from "effect/http/HttpEffect";
30import { HttpServerRequest, HttpServerResponse } from "effect/http";
31import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
32
33import * as EnvironmentAuth from "./EnvironmentAuth.ts";
34import * as SessionStore from "./SessionStore.ts";
35import { deriveAuthClientMetadata } from "./utils.ts";
36import { verifyRequestDpopProof } from "./dpop.ts";
37
38const CREDENTIAL_RESPONSE_HEADERS = {
39 "cache-control": "no-store",
40 pragma: "no-cache",
41} as const;
42
43const appendCredentialResponseHeaders = HttpEffect.appendPreResponseHandler((_request, response) =>
44 Effect.succeed(HttpServerResponse.setHeaders(response, CREDENTIAL_RESPONSE_HEADERS)),
45);
46
47const appendDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) =>
48 Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", "DPoP")),
49);
50
51const 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
63const currentEnvironmentTraceId = Effect.currentParentSpan.pipe(
64 Effect.map((span) => span.traceId),
65 Effect.orElseSucceed(() => "unavailable"),
66);
67
68export 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
93export 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
111export 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
119export 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
133function 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
147export 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 */
162export 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
185export 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
199const 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
216export 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
226export 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
254export 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);