1import * as Config from "effect/Config";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Redacted from "effect/Redacted";
8import * as Schema from "effect/Schema";
9import { HostedAuthError } from "@t3tools/contracts";
10
11export const HOSTED_SESSION_COOKIE = "__Host-knife-session";
12export const HOSTED_TRANSACTION_COOKIE = "__Host-knife-oidc";
13export const HOSTED_CSRF_SECRET = "hosted-auth-csrf";
14
15const Settings = Schema.Struct({
16 publicUrl: Schema.NonEmptyString,
17 issuer: Schema.NonEmptyString,
18 clientId: Schema.NonEmptyString,
19 clientSecret: Schema.NonEmptyString,
20 ownerSub: Schema.NonEmptyString,
21 maxInteractiveAgeSeconds: Schema.optionalKey(
22 Schema.Int.check(Schema.isBetween({ minimum: 900, maximum: 31_536_000 })),
23 ),
24 issuerRevalidationSeconds: Schema.optionalKey(
25 Schema.Int.check(Schema.isBetween({ minimum: 60, maximum: 86_400 })),
26 ),
27});
28
29export class HostedAuthConfig extends Context.Service<
30 HostedAuthConfig,
31 Option.Option<{
32 readonly publicUrl: string;
33 readonly issuer: string;
34 readonly clientId: string;
35 readonly clientSecret: Redacted.Redacted<string>;
36 readonly ownerSub: string;
37 readonly maxInteractiveAgeSeconds: number;
38 readonly issuerRevalidationSeconds: number;
39 }>
40>()("t3/auth/HostedAuthConfig") {}
41
42const make = Effect.gen(function* () {
43 const path = yield* Config.option(Config.String("T3CODE_SSO_CONFIG"));
44 if (Option.isNone(path)) return Option.none();
45 const fs = yield* FileSystem.FileSystem;
46 const settings = yield* fs.readFileString(path.value).pipe(
47 Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Settings))),
48 Effect.mapError(() => new HostedAuthError({ reason: "configuration" })),
49 );
50 const valid = yield* Effect.try({
51 try: () => {
52 const origin = new URL(settings.publicUrl);
53 const issuer = new URL(settings.issuer);
54 if (
55 origin.protocol !== "https:" ||
56 origin.origin !== settings.publicUrl ||
57 issuer.protocol !== "https:" ||
58 issuer.username ||
59 issuer.password ||
60 issuer.search ||
61 issuer.hash ||
62 origin.username ||
63 origin.password
64 )
65 throw new Error();
66 return origin.origin;
67 },
68 catch: () => new HostedAuthError({ reason: "configuration" }),
69 });
70 return Option.some({
71 ...settings,
72 publicUrl: valid,
73 clientSecret: Redacted.make(settings.clientSecret),
74 maxInteractiveAgeSeconds: settings.maxInteractiveAgeSeconds ?? 2_592_000,
75 issuerRevalidationSeconds: settings.issuerRevalidationSeconds ?? 3_600,
76 });
77});
78
79export const layer = Layer.effect(HostedAuthConfig, make);