1import {
2 AuthAdministrativeScopes,
3 AuthAccessReadScope,
4 AuthAccessWriteScope,
5 AuthEnvironmentConnectScope,
6 AuthClientPresentationMetadata,
7 AuthSessionId,
8 EnvironmentId,
9 HostedAuthError,
10 type AuthEnvironmentScope,
11 type HostedAuthAccount,
12 type HostedAuthDevice,
13 type HostedAuthDeviceExchange,
14 type HostedAuthDeviceStart,
15 type HostedAuthDeviceStartResult,
16 type HostedAuthGrant,
17} from "@t3tools/contracts";
18import { createLocalJWKSet, jwtVerify } from "jose";
19import * as Context from "effect/Context";
20import * as Crypto from "effect/Crypto";
21import * as DateTime from "effect/DateTime";
22import * as Duration from "effect/Duration";
23import * as Effect from "effect/Effect";
24import * as Base64Url from "effect/encoding/Base64Url";
25import * as Layer from "effect/Layer";
26import * as Option from "effect/Option";
27import * as Redacted from "effect/Redacted";
28import * as Schema from "effect/Schema";
29import * as SqlClient from "effect/sql/SqlClient";
30import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
31import * as KeyedLock from "@t3tools/shared/KeyedLock";
32import type { AuthenticatedSession } from "./EnvironmentAuth.ts";
33import * as HostedAuthConfig from "./HostedAuthConfig.ts";
34import * as ServerSecretStore from "./ServerSecretStore.ts";
35import * as SessionStore from "./SessionStore.ts";
36import { signPayload } from "./utils.ts";
37
38const isHostedAuthError = Schema.is(HostedAuthError);
39const encodeClient = Schema.encodeEffect(Schema.fromJsonString(AuthClientPresentationMetadata));
40
41const Discovery = Schema.Struct({
42 issuer: Schema.String,
43 authorization_endpoint: Schema.String,
44 token_endpoint: Schema.String,
45 jwks_uri: Schema.String,
46 code_challenge_methods_supported: Schema.Array(Schema.String),
47 response_types_supported: Schema.Array(Schema.String),
48 token_endpoint_auth_methods_supported: Schema.Array(Schema.String),
49 id_token_signing_alg_values_supported: Schema.Array(Schema.String),
50});
51const Tokens = Schema.Struct({
52 access_token: Schema.NonEmptyString,
53 token_type: Schema.NonEmptyString,
54 refresh_token: Schema.optionalKey(Schema.NonEmptyString),
55 id_token: Schema.optionalKey(Schema.NonEmptyString),
56});
57const Device = Schema.Struct({
58 deviceId: Schema.String,
59 accountId: Schema.String,
60 environmentId: Schema.NullOr(EnvironmentId),
61 client: Schema.fromJsonString(AuthClientPresentationMetadata),
62 createdAt: Schema.Number,
63 expiresAt: Schema.Number,
64 validatedAt: Schema.Number,
65 revokedAt: Schema.NullOr(Schema.Number),
66});
67type Device = typeof Device.Type;
68type NativeRequest = { readonly input: HostedAuthDeviceStart; readonly expiresAt: number };
69type Login = {
70 readonly browserHash: string;
71 readonly nonce: string;
72 readonly verifier: string;
73 readonly returnTo: string;
74 readonly expiresAt: number;
75 readonly native?: HostedAuthDeviceStart;
76};
77type Handoff = {
78 readonly input: HostedAuthDeviceStart;
79 readonly refreshToken: string;
80 readonly expiresAt: number;
81};
82
83export class HostedAuth extends Context.Service<
84 HostedAuth,
85 {
86 readonly enabled: boolean;
87 readonly startDevice: (
88 input: HostedAuthDeviceStart,
89 ) => Effect.Effect<HostedAuthDeviceStartResult, HostedAuthError>;
90 readonly begin: (input: {
91 request?: string;
92 returnTo?: string;
93 }) => Effect.Effect<{ url: string; browserCookie: string }, HostedAuthError>;
94 readonly callback: (input: {
95 state: string;
96 code: string;
97 browserCookie: string;
98 client: AuthClientPresentationMetadata;
99 }) => Effect.Effect<{ url: string; session?: SessionStore.IssuedSession }, HostedAuthError>;
100 readonly exchange: (
101 input: HostedAuthDeviceExchange,
102 ) => Effect.Effect<HostedAuthGrant, HostedAuthError>;
103 readonly refresh: (refreshToken: string) => Effect.Effect<HostedAuthGrant, HostedAuthError>;
104 readonly renewBrowser: (
105 session: AuthenticatedSession,
106 ) => Effect.Effect<SessionStore.IssuedSession, HostedAuthError>;
107 readonly account: (
108 session: AuthenticatedSession,
109 ) => Effect.Effect<HostedAuthAccount, HostedAuthError>;
110 readonly devices: (
111 session: AuthenticatedSession,
112 ) => Effect.Effect<ReadonlyArray<HostedAuthDevice>, HostedAuthError>;
113 readonly revoke: (
114 session: AuthenticatedSession,
115 deviceId: string,
116 ) => Effect.Effect<void, HostedAuthError>;
117 readonly token: (
118 session: AuthenticatedSession,
119 scopes: ReadonlyArray<AuthEnvironmentScope>,
120 ) => Effect.Effect<SessionStore.IssuedSession, HostedAuthError>;
121 readonly bindEnvironment: (
122 session: AuthenticatedSession,
123 environmentId: EnvironmentId,
124 ) => Effect.Effect<void, HostedAuthError>;
125 readonly authorizeEnvironment: (
126 session: AuthenticatedSession,
127 environmentId: EnvironmentId,
128 ) => Effect.Effect<void, HostedAuthError>;
129 readonly emergencyRevokeAll: Effect.Effect<number, HostedAuthError>;
130 }
131>()("t3/auth/HostedAuth") {}
132
133const make = Effect.gen(function* () {
134 const configuration = yield* HostedAuthConfig.HostedAuthConfig;
135 const http = yield* HttpClient.HttpClient;
136 const sql = yield* SqlClient.SqlClient;
137 const crypto = yield* Crypto.Crypto;
138 const secrets = yield* ServerSecretStore.ServerSecretStore;
139 const sessions = yield* SessionStore.SessionStore;
140 const locks = yield* KeyedLock.make<string>();
141 const requests = new Map<string, NativeRequest>();
142 const logins = new Map<string, Login>();
143 const handoffs = new Map<string, Handoff>();
144 const config = Effect.fromOption(configuration).pipe(
145 Effect.mapError(() => new HostedAuthError({ reason: "disabled" })),
146 );
147 const random = crypto.randomBytes(32).pipe(
148 Effect.map(Base64Url.encode),
149 Effect.mapError(
150 (cause) => new HostedAuthError({ reason: "storage", cause: Redacted.make(cause) }),
151 ),
152 );
153 const hash = (text: string) =>
154 crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe(
155 Effect.map(Base64Url.encode),
156 Effect.mapError(
157 (cause) => new HostedAuthError({ reason: "storage", cause: Redacted.make(cause) }),
158 ),
159 );
160 const storage = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
161 effect.pipe(
162 Effect.mapError((cause) =>
163 isHostedAuthError(cause)
164 ? cause
165 : new HostedAuthError({ reason: "storage", cause: Redacted.make(cause) }),
166 ),
167 );
168 const accountId = config.pipe(
169 Effect.flatMap((value) => hash(`${value.issuer}\0${value.ownerSub}`)),
170 );
171 const prune = (now: number) => {
172 for (const entries of [requests, logins, handoffs])
173 for (const [key, value] of entries) if (value.expiresAt <= now) entries.delete(key);
174 if (requests.size + logins.size + handoffs.size >= 1_000)
175 return Effect.fail(new HostedAuthError({ reason: "transaction" }));
176 return Effect.void;
177 };
178 const requestJson = <A>(
179 request: HttpClientRequest.HttpClientRequest,
180 schema: Schema.Decoder<A>,
181 ) =>
182 Effect.gen(function* () {
183 const response = yield* http
184 .execute(request)
185 .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }));
186 if (response.status !== 200)
187 return yield* new HostedAuthError({
188 reason:
189 response.status === 400 || response.status === 401 ? "reauthentication" : "upstream",
190 });
191 return yield* response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(schema)));
192 }).pipe(
193 Effect.timeout("15 seconds"),
194 Effect.mapError((cause) =>
195 isHostedAuthError(cause)
196 ? cause
197 : new HostedAuthError({ reason: "upstream", cause: Redacted.make(cause) }),
198 ),
199 Effect.scoped,
200 );
201 const discovery = Effect.gen(function* () {
202 const settings = yield* config;
203 const result = yield* requestJson(
204 HttpClientRequest.get(
205 `${settings.issuer.replace(/\/$/u, "")}/.well-known/openid-configuration`,
206 ),
207 Discovery,
208 );
209 if (
210 result.issuer !== settings.issuer ||
211 !result.code_challenge_methods_supported.includes("S256") ||
212 !result.response_types_supported.includes("code") ||
213 !result.token_endpoint_auth_methods_supported.includes("client_secret_basic") ||
214 !result.id_token_signing_alg_values_supported.includes("RS256")
215 )
216 return yield* new HostedAuthError({ reason: "configuration" });
217 for (const endpoint of [
218 result.authorization_endpoint,
219 result.token_endpoint,
220 result.jwks_uri,
221 ]) {
222 const valid = yield* Effect.try({
223 try: () => {
224 const url = new URL(endpoint);
225 return url.protocol === "https:" && !url.username && !url.password && !url.hash;
226 },
227 catch: () => new HostedAuthError({ reason: "configuration" }),
228 });
229 if (!valid) return yield* new HostedAuthError({ reason: "configuration" });
230 }
231 return result;
232 });
233 const exchangeTokens = (metadata: typeof Discovery.Type, body: Record<string, string>) =>
234 Effect.gen(function* () {
235 const settings = yield* config;
236 const basic = Buffer.from(
237 `${encodeURIComponent(settings.clientId)}:${encodeURIComponent(Redacted.value(settings.clientSecret))}`,
238 ).toString("base64");
239 return yield* requestJson(
240 HttpClientRequest.post(metadata.token_endpoint).pipe(
241 HttpClientRequest.setHeader("authorization", `Basic ${basic}`),
242 HttpClientRequest.bodyUrlParams(body),
243 ),
244 Tokens,
245 );
246 });
247 const verifyIdentity = (metadata: typeof Discovery.Type, token: string, nonce?: string) =>
248 Effect.gen(function* () {
249 const settings = yield* config;
250 const jwks = yield* requestJson(
251 HttpClientRequest.get(metadata.jwks_uri),
252 Schema.Struct({
253 keys: Schema.Array(
254 Schema.Struct({
255 kty: Schema.String,
256 n: Schema.optionalKey(Schema.String),
257 e: Schema.optionalKey(Schema.String),
258 kid: Schema.optionalKey(Schema.String),
259 alg: Schema.optionalKey(Schema.String),
260 use: Schema.optionalKey(Schema.String),
261 }),
262 ),
263 }),
264 );
265 const now = yield* DateTime.now;
266 const claims = yield* Effect.tryPromise({
267 try: () =>
268 jwtVerify(token, createLocalJWKSet({ keys: [...jwks.keys] }), {
269 algorithms: ["RS256"],
270 issuer: settings.issuer,
271 audience: settings.clientId,
272 currentDate: DateTime.toDate(now),
273 clockTolerance: 5,
274 requiredClaims: ["sub", "iss", "aud", "iat", "exp"],
275 }),
276 catch: () => new HostedAuthError({ reason: "identity" }),
277 });
278 if (
279 claims.payload.sub !== settings.ownerSub ||
280 (nonce !== undefined && claims.payload.nonce !== nonce) ||
281 (claims.payload.azp !== undefined && claims.payload.azp !== settings.clientId) ||
282 (Array.isArray(claims.payload.aud) &&
283 claims.payload.aud.length > 1 &&
284 claims.payload.azp !== settings.clientId)
285 )
286 return yield* new HostedAuthError({ reason: "identity" });
287 });
288 const loadDevice = (id: string) =>
289 Effect.gen(function* () {
290 const rows = yield* storage(
291 sql`SELECT device_id AS "deviceId", account_id AS "accountId", environment_id AS "environmentId", client_json AS "client", created_at AS "createdAt", expires_at AS "expiresAt", validated_at AS "validatedAt", revoked_at AS "revokedAt" FROM auth_devices WHERE device_id = ${id}`.pipe(
292 Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Device))),
293 ),
294 );
295 const device = rows[0];
296 const now = yield* DateTime.now;
297 if (
298 !device ||
299 device.accountId !== (yield* accountId) ||
300 device.revokedAt !== null ||
301 device.expiresAt <= now.epochMilliseconds
302 )
303 return yield* new HostedAuthError({ reason: "reauthentication" });
304 return device;
305 });
306 const sessionDevice = (session: AuthenticatedSession) =>
307 session.deviceId === undefined
308 ? Effect.fail(new HostedAuthError({ reason: "forbidden" }))
309 : loadDevice(session.deviceId);
310 const revokeDevice = (deviceId: string) =>
311 Effect.gen(function* () {
312 const now = yield* DateTime.now;
313 const rows = yield* storage(
314 sql.withTransaction(
315 Effect.gen(function* () {
316 yield* sql`UPDATE auth_devices SET revoked_at = COALESCE(revoked_at, ${now.epochMilliseconds}) WHERE device_id = ${deviceId}`;
317 yield* sql`DELETE FROM auth_device_refresh WHERE device_id = ${deviceId}`;
318 return yield* sql`SELECT session_id AS "sessionId" FROM auth_sessions WHERE device_id = ${deviceId}`.pipe(
319 Effect.flatMap(
320 Schema.decodeUnknownEffect(
321 Schema.Array(Schema.Struct({ sessionId: AuthSessionId })),
322 ),
323 ),
324 );
325 }),
326 ),
327 );
328 for (const row of rows) yield* storage(sessions.revoke(row.sessionId));
329 yield* storage(secrets.remove(`hosted-oidc-${deviceId}`));
330 });
331 const issueAccess = (
332 device: Device,
333 method: "browser-session-cookie" | "bearer-access-token",
334 scopes: ReadonlyArray<AuthEnvironmentScope> = AuthAdministrativeScopes,
335 options?: { readonly replaceSessionId?: AuthSessionId; readonly expiresAt?: DateTime.DateTime },
336 ) =>
337 Effect.gen(function* () {
338 const now = yield* DateTime.now;
339 return yield* storage(
340 sessions.issue({
341 deviceId: device.deviceId,
342 subject: device.accountId,
343 method,
344 scopes,
345 ttl: Duration.millis(
346 Math.min(
347 900_000,
348 device.expiresAt - now.epochMilliseconds,
349 options?.expiresAt === undefined
350 ? Infinity
351 : options.expiresAt.epochMilliseconds - now.epochMilliseconds,
352 ),
353 ),
354 client: { deviceType: device.client.deviceType ?? "unknown", ...device.client },
355 ...(options?.replaceSessionId === undefined
356 ? {}
357 : { replaceSessionId: options.replaceSessionId }),
358 }),
359 );
360 });
361 const createDevice = (client: typeof AuthClientPresentationMetadata.Type, refreshToken: string) =>
362 Effect.gen(function* () {
363 const settings = yield* config;
364 const now = yield* DateTime.now;
365 const id = yield* storage(crypto.randomUUIDv4());
366 const device: Device = {
367 deviceId: id,
368 accountId: yield* accountId,
369 environmentId: null,
370 client,
371 createdAt: now.epochMilliseconds,
372 expiresAt: now.epochMilliseconds + settings.maxInteractiveAgeSeconds * 1_000,
373 validatedAt: now.epochMilliseconds,
374 revokedAt: null,
375 };
376 yield* storage(secrets.set(`hosted-oidc-${id}`, new TextEncoder().encode(refreshToken)));
377 const encodedClient = yield* storage(encodeClient(client));
378 yield* storage(
379 sql`INSERT INTO auth_devices(device_id, account_id, client_json, created_at, expires_at, validated_at) VALUES (${id}, ${device.accountId}, ${encodedClient}, ${device.createdAt}, ${device.expiresAt}, ${device.validatedAt})`,
380 );
381 return device;
382 });
383 const renewIssuer = (device: Device) =>
384 Effect.gen(function* () {
385 const settings = yield* config;
386 const now = yield* DateTime.now;
387 if (device.validatedAt + settings.issuerRevalidationSeconds * 1_000 > now.epochMilliseconds)
388 return;
389 const refresh = yield* storage(secrets.get(`hosted-oidc-${device.deviceId}`));
390 if (Option.isNone(refresh)) return yield* new HostedAuthError({ reason: "reauthentication" });
391 const metadata = yield* discovery;
392 const tokens = yield* exchangeTokens(metadata, {
393 grant_type: "refresh_token",
394 refresh_token: new TextDecoder().decode(refresh.value),
395 }).pipe(
396 Effect.catchTags({
397 HostedAuthError: (error) =>
398 error.reason === "reauthentication"
399 ? revokeDevice(device.deviceId).pipe(Effect.andThen(Effect.fail(error)))
400 : Effect.fail(error),
401 }),
402 );
403 if (tokens.id_token)
404 yield* verifyIdentity(metadata, tokens.id_token).pipe(
405 Effect.catchTags({
406 HostedAuthError: (error) =>
407 revokeDevice(device.deviceId).pipe(Effect.andThen(Effect.fail(error))),
408 }),
409 );
410 if (tokens.token_type.toLowerCase() !== "bearer")
411 return yield* new HostedAuthError({ reason: "identity" });
412 if (tokens.refresh_token)
413 yield* storage(
414 secrets.set(
415 `hosted-oidc-${device.deviceId}`,
416 new TextEncoder().encode(tokens.refresh_token),
417 ),
418 );
419 const updated = yield* storage(
420 sql`UPDATE auth_devices SET validated_at = ${now.epochMilliseconds} WHERE device_id = ${device.deviceId} AND revoked_at IS NULL RETURNING device_id`,
421 );
422 if (updated.length === 0) {
423 yield* storage(secrets.remove(`hosted-oidc-${device.deviceId}`));
424 return yield* new HostedAuthError({ reason: "reauthentication" });
425 }
426 });
427 const makeGrant = (device: Device) =>
428 Effect.gen(function* () {
429 const refreshToken = yield* random;
430 const tokenHash = yield* hash(refreshToken);
431 yield* storage(
432 sql`INSERT INTO auth_device_refresh(token_hash, device_id, expires_at) VALUES (${tokenHash}, ${device.deviceId}, ${device.expiresAt})`,
433 );
434 const issued = yield* issueAccess(device, "bearer-access-token");
435 return {
436 deviceId: device.deviceId,
437 accessToken: issued.token,
438 refreshToken,
439 expiresAt: DateTime.toUtc(issued.expiresAt),
440 } satisfies HostedAuthGrant;
441 });
442
443 return HostedAuth.of({
444 enabled: Option.isSome(configuration),
445 startDevice: (input) =>
446 Effect.gen(function* () {
447 const settings = yield* config;
448 const now = yield* DateTime.now;
449 yield* prune(now.epochMilliseconds);
450 const encoded = yield* storage(encodeClient(input.client));
451 if (encoded.length > 2_048) return yield* new HostedAuthError({ reason: "transaction" });
452 const id = yield* random;
453 requests.set(id, { input, expiresAt: now.epochMilliseconds + 300_000 });
454 const url = new URL("/api/auth/oidc/start", settings.publicUrl);
455 url.searchParams.set("request", id);
456 return { authorizationUrl: url.href };
457 }),
458 begin: (input) =>
459 Effect.gen(function* () {
460 const settings = yield* config;
461 const now = yield* DateTime.now;
462 yield* prune(now.epochMilliseconds);
463 const native = input.request ? requests.get(input.request) : undefined;
464 if (input.request && !native) return yield* new HostedAuthError({ reason: "transaction" });
465 if (input.request) requests.delete(input.request);
466 const returnTo = yield* Effect.try({
467 try: () => {
468 const url = new URL(input.returnTo ?? "/", settings.publicUrl);
469 if (url.origin !== settings.publicUrl) throw new Error();
470 return `${url.pathname}${url.search}${url.hash}`;
471 },
472 catch: () => new HostedAuthError({ reason: "transaction" }),
473 });
474 const metadata = yield* discovery;
475 const state = yield* random;
476 const browserCookie = yield* random;
477 const nonce = yield* random;
478 const verifier = yield* random;
479 logins.set(state, {
480 browserHash: yield* hash(browserCookie),
481 nonce,
482 verifier,
483 returnTo,
484 expiresAt: now.epochMilliseconds + 300_000,
485 ...(native ? { native: native.input } : {}),
486 });
487 const url = new URL(metadata.authorization_endpoint);
488 for (const [key, value] of Object.entries({
489 client_id: settings.clientId,
490 response_type: "code",
491 scope: "openid offline_access",
492 redirect_uri: `${settings.publicUrl}/api/auth/oidc/callback`,
493 state,
494 nonce,
495 code_challenge: yield* hash(verifier),
496 code_challenge_method: "S256",
497 }))
498 url.searchParams.set(key, value);
499 return { url: url.href, browserCookie };
500 }),
501 callback: (input) =>
502 Effect.gen(function* () {
503 const settings = yield* config;
504 const now = yield* DateTime.now;
505 yield* prune(now.epochMilliseconds);
506 const login = logins.get(input.state);
507 logins.delete(input.state);
508 if (!login || login.browserHash !== (yield* hash(input.browserCookie)))
509 return yield* new HostedAuthError({ reason: "transaction" });
510 const metadata = yield* discovery;
511 const tokens = yield* exchangeTokens(metadata, {
512 grant_type: "authorization_code",
513 code: input.code,
514 code_verifier: login.verifier,
515 redirect_uri: `${settings.publicUrl}/api/auth/oidc/callback`,
516 });
517 if (
518 !tokens.id_token ||
519 !tokens.refresh_token ||
520 tokens.token_type.toLowerCase() !== "bearer"
521 )
522 return yield* new HostedAuthError({ reason: "identity" });
523 yield* verifyIdentity(metadata, tokens.id_token, login.nonce);
524 if (login.native) {
525 const code = yield* random;
526 handoffs.set(yield* hash(code), {
527 input: login.native,
528 refreshToken: tokens.refresh_token,
529 expiresAt: now.epochMilliseconds + 60_000,
530 });
531 const url = new URL("t3code://app/self-hosted/callback");
532 for (const [key, value] of Object.entries({
533 code,
534 state: login.native.state,
535 server: settings.publicUrl,
536 }))
537 url.searchParams.set(key, value);
538 return { url: url.href };
539 }
540 const device = yield* createDevice(input.client, tokens.refresh_token);
541 const session = yield* issueAccess(device, "browser-session-cookie");
542 return { url: new URL(login.returnTo, settings.publicUrl).href, session };
543 }),
544 exchange: (input) =>
545 Effect.gen(function* () {
546 const now = yield* DateTime.now;
547 yield* prune(now.epochMilliseconds);
548 const key = yield* hash(input.code);
549 const handoff = handoffs.get(key);
550 if (!handoff || handoff.input.codeChallenge !== (yield* hash(input.codeVerifier)))
551 return yield* new HostedAuthError({ reason: "credential" });
552 handoffs.delete(key);
553 const device = yield* createDevice(handoff.input.client, handoff.refreshToken);
554 return yield* storage(sql.withTransaction(makeGrant(device)));
555 }),
556 refresh: (refreshToken) =>
557 Effect.gen(function* () {
558 const tokenHash = yield* hash(refreshToken);
559 const lookup =
560 sql`SELECT device_id AS "deviceId", consumed_at AS "consumedAt" FROM auth_device_refresh WHERE token_hash = ${tokenHash}`.pipe(
561 Effect.flatMap(
562 Schema.decodeUnknownEffect(
563 Schema.Array(
564 Schema.Struct({
565 deviceId: Schema.String,
566 consumedAt: Schema.NullOr(Schema.Number),
567 }),
568 ),
569 ),
570 ),
571 );
572 const rows = yield* storage(lookup);
573 const found = rows[0];
574 if (!found) return yield* new HostedAuthError({ reason: "credential" });
575 return yield* locks.withLock(
576 found.deviceId,
577 Effect.gen(function* () {
578 const current = (yield* storage(lookup))[0];
579 if (!current) return yield* new HostedAuthError({ reason: "credential" });
580 if (current.consumedAt !== null) {
581 yield* revokeDevice(current.deviceId);
582 return yield* new HostedAuthError({ reason: "credential" });
583 }
584 const device = yield* loadDevice(current.deviceId);
585 yield* renewIssuer(device);
586 return yield* storage(
587 sql.withTransaction(
588 Effect.gen(function* () {
589 yield* loadDevice(current.deviceId);
590 const now = yield* DateTime.now;
591 const changed =
592 yield* sql`UPDATE auth_device_refresh SET consumed_at = ${now.epochMilliseconds} WHERE token_hash = ${tokenHash} AND consumed_at IS NULL RETURNING token_hash`;
593 if (changed.length !== 1)
594 return yield* new HostedAuthError({ reason: "credential" });
595 return yield* makeGrant(device);
596 }),
597 ),
598 );
599 }),
600 );
601 }),
602 renewBrowser: (session) =>
603 Effect.gen(function* () {
604 const device = yield* sessionDevice(session);
605 return yield* locks.withLock(
606 device.deviceId,
607 Effect.gen(function* () {
608 yield* renewIssuer(device);
609 return yield* storage(
610 sql.withTransaction(
611 Effect.gen(function* () {
612 const current = yield* loadDevice(device.deviceId);
613 return yield* issueAccess(current, "browser-session-cookie", session.scopes, {
614 replaceSessionId: session.sessionId,
615 });
616 }),
617 ),
618 );
619 }),
620 );
621 }),
622 account: (session) =>
623 Effect.gen(function* () {
624 const device = yield* sessionDevice(session);
625 const secret = yield* storage(
626 secrets.getOrCreateRandom(HostedAuthConfig.HOSTED_CSRF_SECRET, 32),
627 );
628 return {
629 accountId: device.accountId,
630 deviceId: device.deviceId,
631 csrfToken: signPayload(device.deviceId, secret),
632 expiresAt: DateTime.toUtc(session.expiresAt ?? DateTime.makeUnsafe(device.expiresAt)),
633 };
634 }),
635 devices: (session) =>
636 Effect.gen(function* () {
637 if (!session.scopes.includes(AuthAccessReadScope))
638 return yield* new HostedAuthError({ reason: "forbidden" });
639 const device = yield* sessionDevice(session);
640 const rows = yield* storage(
641 sql`SELECT device_id AS "deviceId", account_id AS "accountId", environment_id AS "environmentId", client_json AS "client", created_at AS "createdAt", expires_at AS "expiresAt", validated_at AS "validatedAt", revoked_at AS "revokedAt" FROM auth_devices WHERE account_id = ${device.accountId} ORDER BY created_at DESC`.pipe(
642 Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Device))),
643 ),
644 );
645 return rows.map((row) => ({
646 deviceId: row.deviceId,
647 client: row.client,
648 createdAt: DateTime.makeUnsafe(row.createdAt),
649 expiresAt: DateTime.makeUnsafe(row.expiresAt),
650 revokedAt: row.revokedAt === null ? null : DateTime.makeUnsafe(row.revokedAt),
651 current: row.deviceId === device.deviceId,
652 ...(row.environmentId === null ? {} : { environmentId: row.environmentId }),
653 }));
654 }),
655 revoke: (session, deviceId) =>
656 Effect.gen(function* () {
657 if (session.deviceId !== deviceId && !session.scopes.includes(AuthAccessWriteScope))
658 return yield* new HostedAuthError({ reason: "forbidden" });
659 const current = yield* sessionDevice(session);
660 const matches = yield* storage(
661 sql`SELECT 1 FROM auth_devices WHERE device_id = ${deviceId} AND account_id = ${current.accountId}`,
662 );
663 if (matches.length !== 1) return yield* new HostedAuthError({ reason: "forbidden" });
664 yield* revokeDevice(deviceId);
665 }),
666 token: (session, scopes) =>
667 Effect.gen(function* () {
668 const device = yield* sessionDevice(session);
669 if (
670 !session.expiresAt ||
671 scopes.length === 0 ||
672 scopes.some((scope) => !session.scopes.includes(scope))
673 )
674 return yield* new HostedAuthError({ reason: "forbidden" });
675 const expiresAt = session.expiresAt;
676 return yield* storage(
677 sql.withTransaction(
678 Effect.gen(function* () {
679 yield* sessions.verifyActive(session.sessionId);
680 yield* loadDevice(device.deviceId);
681 return yield* issueAccess(device, "bearer-access-token", scopes, { expiresAt });
682 }),
683 ),
684 );
685 }),
686 bindEnvironment: (session, environmentId) =>
687 Effect.gen(function* () {
688 if (!session.scopes.includes(AuthEnvironmentConnectScope))
689 return yield* new HostedAuthError({ reason: "forbidden" });
690 const device = yield* sessionDevice(session);
691 if (device.environmentId !== null && device.environmentId !== environmentId)
692 return yield* new HostedAuthError({ reason: "forbidden" });
693 const replaced = yield* storage(
694 sql.withTransaction(
695 Effect.gen(function* () {
696 yield* loadDevice(device.deviceId);
697 const conflicts =
698 yield* sql`SELECT device_id AS "deviceId", account_id AS "accountId" FROM auth_devices WHERE environment_id = ${environmentId} AND device_id != ${device.deviceId} AND revoked_at IS NULL`.pipe(
699 Effect.flatMap(
700 Schema.decodeUnknownEffect(
701 Schema.Array(
702 Schema.Struct({ deviceId: Schema.String, accountId: Schema.String }),
703 ),
704 ),
705 ),
706 );
707 if (conflicts.some((previous) => previous.accountId !== device.accountId))
708 return yield* new HostedAuthError({ reason: "forbidden" });
709 const now = yield* DateTime.now;
710 for (const previous of conflicts)
711 yield* sql`UPDATE auth_devices SET revoked_at = ${now.epochMilliseconds} WHERE device_id = ${previous.deviceId}`;
712 yield* sql`UPDATE auth_devices SET environment_id = ${environmentId} WHERE device_id = ${device.deviceId} AND revoked_at IS NULL`;
713 return conflicts;
714 }),
715 ),
716 );
717 for (const previous of replaced) yield* revokeDevice(previous.deviceId);
718 }),
719 authorizeEnvironment: (session, environmentId) =>
720 Effect.gen(function* () {
721 const device = yield* sessionDevice(session);
722 if (device.environmentId !== environmentId)
723 return yield* new HostedAuthError({ reason: "forbidden" });
724 }),
725 emergencyRevokeAll: Effect.gen(function* () {
726 const rows = yield* storage(
727 sql`SELECT device_id AS "deviceId" FROM auth_devices WHERE revoked_at IS NULL`.pipe(
728 Effect.flatMap(
729 Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ deviceId: Schema.String }))),
730 ),
731 ),
732 );
733 for (const row of rows) yield* revokeDevice(row.deviceId);
734 return rows.length;
735 }),
736 });
737});
738
739export const layer = Layer.effect(HostedAuth, make);