1import {
2 HostedAuthAccount,
3 HostedAuthDevice,
4 HostedAuthDeviceExchange,
5 HostedAuthDeviceRefresh,
6 HostedAuthDeviceStart,
7 HostedAuthDeviceStartResult,
8 HostedAuthError,
9 HostedAuthGrant,
10 HostedAuthTokenRequest,
11} from "@t3tools/contracts";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Schema from "effect/Schema";
17import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
18import * as EnvironmentAuth from "./EnvironmentAuth.ts";
19import * as HostedAuth from "./HostedAuth.ts";
20import {
21 HostedAuthConfig,
22 HOSTED_SESSION_COOKIE,
23 HOSTED_TRANSACTION_COOKIE,
24} from "./HostedAuthConfig.ts";
25import { deriveAuthClientMetadata } from "./utils.ts";
26
27const isHostedAuthError = Schema.is(HostedAuthError);
28
29const headers = {
30 "cache-control": "no-store",
31 pragma: "no-cache",
32 "referrer-policy": "no-referrer",
33};
34const cookieOptions = { secure: true, httpOnly: true, sameSite: "lax", path: "/" } as const;
35const errors = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
36 effect.pipe(
37 Effect.catch((error) => {
38 if (isHostedAuthError(error))
39 return Effect.succeed(
40 HttpServerResponse.jsonUnsafe(
41 { _tag: error._tag, reason: error.reason },
42 {
43 headers,
44 status:
45 error.reason === "disabled"
46 ? 404
47 : error.reason === "forbidden"
48 ? 403
49 : error.reason === "credential" || error.reason === "reauthentication"
50 ? 401
51 : error.reason === "transaction" || error.reason === "identity"
52 ? 400
53 : 503,
54 },
55 ),
56 );
57 if (EnvironmentAuth.isServerAuthCredentialError(error))
58 return Effect.succeed(
59 HttpServerResponse.jsonUnsafe({ code: "unauthorized" }, { status: 401, headers }),
60 );
61 if (Schema.isSchemaError(error))
62 return Effect.succeed(HttpServerResponse.empty({ status: 400, headers }));
63 return Effect.fail(error);
64 }),
65 );
66const authenticate = Effect.gen(function* () {
67 const request = yield* HttpServerRequest.HttpServerRequest;
68 return yield* (yield* EnvironmentAuth.EnvironmentAuth).authenticateHttpRequest(request);
69});
70const browser = authenticate.pipe(
71 Effect.flatMap((session) =>
72 session.method === "browser-session-cookie"
73 ? Effect.succeed(session)
74 : Effect.fail(new HostedAuthError({ reason: "forbidden" })),
75 ),
76);
77const query = Effect.gen(function* () {
78 const request = yield* HttpServerRequest.HttpServerRequest;
79 const url = new URL(request.url, "https://request.invalid");
80 return url.searchParams;
81});
82
83export const layer = Layer.mergeAll(
84 HttpRouter.add(
85 "GET",
86 "/api/auth/oidc/config",
87 Effect.gen(function* () {
88 return HttpServerResponse.jsonUnsafe(
89 { enabled: (yield* HostedAuth.HostedAuth).enabled },
90 { headers },
91 );
92 }),
93 ),
94 HttpRouter.add(
95 "POST",
96 "/api/auth/devices/start",
97 Effect.gen(function* () {
98 const input = yield* HttpServerRequest.schemaBodyJson(HostedAuthDeviceStart);
99 const result = yield* (yield* HostedAuth.HostedAuth).startDevice(input);
100 return yield* HttpServerResponse.schemaJson(HostedAuthDeviceStartResult)(result, { headers });
101 }).pipe(errors),
102 ),
103 HttpRouter.add(
104 "GET",
105 "/api/auth/oidc/start",
106 Effect.gen(function* () {
107 const params = yield* query;
108 const result = yield* (yield* HostedAuth.HostedAuth).begin({
109 ...(params.has("request") ? { request: params.get("request") ?? "" } : {}),
110 ...(params.has("returnTo") ? { returnTo: params.get("returnTo") ?? "" } : {}),
111 });
112 return HttpServerResponse.redirect(result.url, { status: 303, headers }).pipe(
113 HttpServerResponse.setCookieUnsafe(HOSTED_TRANSACTION_COOKIE, result.browserCookie, {
114 ...cookieOptions,
115 maxAge: "5 minutes",
116 }),
117 );
118 }).pipe(errors),
119 ),
120 HttpRouter.add(
121 "GET",
122 "/api/auth/oidc/callback",
123 Effect.gen(function* () {
124 const params = yield* query;
125 const request = yield* HttpServerRequest.HttpServerRequest;
126 const result = yield* (yield* HostedAuth.HostedAuth).callback({
127 state: params.get("state") ?? "",
128 code: params.get("code") ?? "",
129 browserCookie: request.cookies[HOSTED_TRANSACTION_COOKIE] ?? "",
130 client: deriveAuthClientMetadata({ request }),
131 });
132 let response = HttpServerResponse.redirect(result.url, { status: 303, headers }).pipe(
133 HttpServerResponse.expireCookieUnsafe(HOSTED_TRANSACTION_COOKIE, cookieOptions),
134 );
135 if (result.session)
136 response = HttpServerResponse.setCookieUnsafe(
137 response,
138 HOSTED_SESSION_COOKIE,
139 result.session.token,
140 { ...cookieOptions, expires: DateTime.toDate(result.session.expiresAt) },
141 );
142 return response;
143 }).pipe(
144 Effect.catchIf(isHostedAuthError, (error) =>
145 Effect.gen(function* () {
146 const config = yield* HostedAuthConfig;
147 if (Option.isNone(config)) return yield* error;
148 return HttpServerResponse.redirect(
149 new URL("/pair?signInError=1", config.value.publicUrl).href,
150 { status: 303, headers },
151 ).pipe(HttpServerResponse.expireCookieUnsafe(HOSTED_TRANSACTION_COOKIE, cookieOptions));
152 }),
153 ),
154 errors,
155 ),
156 ),
157 HttpRouter.add(
158 "POST",
159 "/api/auth/devices/exchange",
160 Effect.gen(function* () {
161 const input = yield* HttpServerRequest.schemaBodyJson(HostedAuthDeviceExchange);
162 const result = yield* (yield* HostedAuth.HostedAuth).exchange(input);
163 return yield* HttpServerResponse.schemaJson(Schema.toCodecJson(HostedAuthGrant))(result, {
164 headers,
165 });
166 }).pipe(errors),
167 ),
168 HttpRouter.add(
169 "POST",
170 "/api/auth/devices/refresh",
171 Effect.gen(function* () {
172 const input = yield* HttpServerRequest.schemaBodyJson(HostedAuthDeviceRefresh);
173 const result = yield* (yield* HostedAuth.HostedAuth).refresh(input.refreshToken);
174 return yield* HttpServerResponse.schemaJson(Schema.toCodecJson(HostedAuthGrant))(result, {
175 headers,
176 });
177 }).pipe(errors),
178 ),
179 HttpRouter.add(
180 "POST",
181 "/api/auth/session/refresh",
182 Effect.gen(function* () {
183 const current = yield* browser;
184 const auth = yield* HostedAuth.HostedAuth;
185 const session = yield* auth.renewBrowser(current);
186 const account = yield* auth.account({
187 ...current,
188 sessionId: session.sessionId,
189 expiresAt: session.expiresAt,
190 });
191 return (yield* HttpServerResponse.schemaJson(Schema.toCodecJson(HostedAuthAccount))(account, {
192 headers,
193 })).pipe(
194 HttpServerResponse.setCookieUnsafe(HOSTED_SESSION_COOKIE, session.token, {
195 ...cookieOptions,
196 expires: DateTime.toDate(session.expiresAt),
197 }),
198 );
199 }).pipe(errors),
200 ),
201 HttpRouter.add(
202 "GET",
203 "/api/auth/account",
204 Effect.gen(function* () {
205 const account = yield* (yield* HostedAuth.HostedAuth).account(yield* authenticate);
206 return yield* HttpServerResponse.schemaJson(Schema.toCodecJson(HostedAuthAccount))(account, {
207 headers,
208 });
209 }).pipe(errors),
210 ),
211 HttpRouter.add(
212 "GET",
213 "/api/auth/devices",
214 Effect.gen(function* () {
215 const devices = yield* (yield* HostedAuth.HostedAuth).devices(yield* authenticate);
216 return yield* HttpServerResponse.schemaJson(
217 Schema.toCodecJson(Schema.Array(HostedAuthDevice)),
218 )(devices, { headers });
219 }).pipe(errors),
220 ),
221 HttpRouter.add(
222 "POST",
223 "/api/auth/devices/:deviceId/revoke",
224 Effect.gen(function* () {
225 const params = yield* HttpRouter.params;
226 yield* (yield* HostedAuth.HostedAuth).revoke(yield* authenticate, params.deviceId ?? "");
227 return HttpServerResponse.empty({ headers });
228 }).pipe(errors),
229 ),
230 HttpRouter.add(
231 "POST",
232 "/api/auth/devices/token",
233 Effect.gen(function* () {
234 const input = yield* HttpServerRequest.schemaBodyJson(HostedAuthTokenRequest);
235 const issued = yield* (yield* HostedAuth.HostedAuth).token(yield* authenticate, input.scopes);
236 return yield* HttpServerResponse.schemaJson(
237 Schema.toCodecJson(
238 Schema.Struct({ accessToken: Schema.String, expiresAt: Schema.DateTimeUtc }),
239 ),
240 )({ accessToken: issued.token, expiresAt: DateTime.toUtc(issued.expiresAt) }, { headers });
241 }).pipe(errors),
242 ),
243 HttpRouter.add(
244 "POST",
245 "/api/auth/logout",
246 Effect.gen(function* () {
247 const session = yield* authenticate;
248 if (!session.deviceId) return yield* new HostedAuthError({ reason: "forbidden" });
249 yield* (yield* HostedAuth.HostedAuth).revoke(session, session.deviceId);
250 return HttpServerResponse.empty({ headers }).pipe(
251 HttpServerResponse.expireCookieUnsafe(HOSTED_SESSION_COOKIE, cookieOptions),
252 );
253 }).pipe(errors),
254 ),
255);