Open History to see the last commit
1import * as Context from "effect/Context";
2import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as SqlClient from "effect/sql/SqlClient";
8import * as SqlSchema from "effect/sql/SqlSchema";
9
10import {
11 AuthClientMetadataDeviceType,
12 AuthEnvironmentScopes,
13 AuthSessionId,
14 ServerAuthSessionMethod,
15} from "@t3tools/contracts";
16
17import {
18 type AuthSessionRepositoryError,
19 PersistenceDecodeError,
20 type PersistenceErrorCorrelation,
21 PersistenceSqlError,
22} from "./Errors.ts";
23
24export const AuthSessionClientMetadataRecord = Schema.Struct({
25 label: Schema.NullOr(Schema.String),
26 ipAddress: Schema.NullOr(Schema.String),
27 userAgent: Schema.NullOr(Schema.String),
28 deviceType: AuthClientMetadataDeviceType,
29 os: Schema.NullOr(Schema.String),
30 browser: Schema.NullOr(Schema.String),
31});
32export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type;
33
34export const AuthSessionRecord = Schema.Struct({
35 sessionId: AuthSessionId,
36 subject: Schema.String,
37 scopes: AuthEnvironmentScopes,
38 method: ServerAuthSessionMethod,
39 client: AuthSessionClientMetadataRecord,
40 issuedAt: Schema.DateTimeUtcFromString,
41 expiresAt: Schema.DateTimeUtcFromString,
42 lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
43 revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
44});
45export type AuthSessionRecord = typeof AuthSessionRecord.Type;
46
47export const CreateAuthSessionInput = Schema.Struct({
48 sessionId: AuthSessionId,
49 subject: Schema.String,
50 scopes: AuthEnvironmentScopes,
51 method: ServerAuthSessionMethod,
52 client: AuthSessionClientMetadataRecord,
53 issuedAt: Schema.DateTimeUtcFromString,
54 expiresAt: Schema.DateTimeUtcFromString,
55});
56export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type;
57
58export const CreateReplacingActiveAuthSessionInput = Schema.Struct({
59 session: CreateAuthSessionInput,
60 revokedAt: Schema.DateTimeUtcFromString,
61 replaceSessionId: Schema.optionalKey(AuthSessionId),
62});
63export type CreateReplacingActiveAuthSessionInput =
64 typeof CreateReplacingActiveAuthSessionInput.Type;
65
66export const GetAuthSessionByIdInput = Schema.Struct({
67 sessionId: AuthSessionId,
68});
69export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type;
70
71export const ListActiveAuthSessionsInput = Schema.Struct({
72 now: Schema.DateTimeUtcFromString,
73 connectedSessionIds: Schema.optionalKey(Schema.Array(AuthSessionId)),
74});
75export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type;
76
77export const RevokeAuthSessionInput = Schema.Struct({
78 sessionId: AuthSessionId,
79 revokedAt: Schema.DateTimeUtcFromString,
80});
81export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type;
82
83export const RevokeOtherAuthSessionsInput = Schema.Struct({
84 currentSessionId: AuthSessionId,
85 revokedAt: Schema.DateTimeUtcFromString,
86});
87export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type;
88
89export const SetAuthSessionLastConnectedAtInput = Schema.Struct({
90 sessionId: AuthSessionId,
91 lastConnectedAt: Schema.DateTimeUtcFromString,
92});
93export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type;
94
95export class AuthSessionRepository extends Context.Service<
96 AuthSessionRepository,
97 {
98 readonly create: (
99 input: CreateAuthSessionInput,
100 ) => Effect.Effect<void, AuthSessionRepositoryError>;
101 readonly createReplacingActive: (
102 input: CreateReplacingActiveAuthSessionInput,
103 ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>;
104 readonly createIfAbsent: (
105 input: CreateAuthSessionInput,
106 ) => Effect.Effect<void, AuthSessionRepositoryError>;
107 readonly getById: (
108 input: GetAuthSessionByIdInput,
109 ) => Effect.Effect<Option.Option<AuthSessionRecord>, AuthSessionRepositoryError>;
110 readonly listActive: (
111 input: ListActiveAuthSessionsInput,
112 ) => Effect.Effect<ReadonlyArray<AuthSessionRecord>, AuthSessionRepositoryError>;
113 readonly revoke: (
114 input: RevokeAuthSessionInput,
115 ) => Effect.Effect<boolean, AuthSessionRepositoryError>;
116 readonly revokeAllExcept: (
117 input: RevokeOtherAuthSessionsInput,
118 ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>;
119 readonly setLastConnectedAt: (
120 input: SetAuthSessionLastConnectedAtInput,
121 ) => Effect.Effect<void, AuthSessionRepositoryError>;
122 }
123>()("t3/persistence/AuthSessions/AuthSessionRepository") {}
124
125const AuthSessionDbRow = Schema.Struct({
126 sessionId: AuthSessionId,
127 subject: Schema.String,
128 scopes: Schema.fromJsonString(AuthEnvironmentScopes),
129 method: ServerAuthSessionMethod,
130 clientLabel: Schema.NullOr(Schema.String),
131 clientIpAddress: Schema.NullOr(Schema.String),
132 clientUserAgent: Schema.NullOr(Schema.String),
133 clientDeviceType: Schema.Literals(["desktop", "mobile", "tablet", "bot", "unknown"]),
134 clientOs: Schema.NullOr(Schema.String),
135 clientBrowser: Schema.NullOr(Schema.String),
136 issuedAt: Schema.DateTimeUtcFromString,
137 expiresAt: Schema.DateTimeUtcFromString,
138 lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
139 revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
140});
141
142const AuthSessionRawDbRow = Schema.Struct({
143 sessionId: Schema.String,
144 subject: Schema.Unknown,
145 scopes: Schema.Unknown,
146 method: Schema.Unknown,
147 clientLabel: Schema.Unknown,
148 clientIpAddress: Schema.Unknown,
149 clientUserAgent: Schema.Unknown,
150 clientDeviceType: Schema.Unknown,
151 clientOs: Schema.Unknown,
152 clientBrowser: Schema.Unknown,
153 issuedAt: Schema.Unknown,
154 expiresAt: Schema.Unknown,
155 lastConnectedAt: Schema.Unknown,
156 revokedAt: Schema.Unknown,
157});
158
159const decodeAuthSessionDbRow = Schema.decodeUnknownEffect(AuthSessionDbRow);
160
161function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord {
162 return {
163 sessionId: row.sessionId,
164 subject: row.subject,
165 scopes: row.scopes,
166 method: row.method,
167 client: {
168 label: row.clientLabel,
169 ipAddress: row.clientIpAddress,
170 userAgent: row.clientUserAgent,
171 deviceType: row.clientDeviceType,
172 os: row.clientOs,
173 browser: row.clientBrowser,
174 },
175 issuedAt: row.issuedAt,
176 expiresAt: row.expiresAt,
177 lastConnectedAt: row.lastConnectedAt,
178 revokedAt: row.revokedAt,
179 };
180}
181
182function toPersistenceSqlOrDecodeError(
183 sqlOperation: string,
184 decodeOperation: string,
185 correlation?: PersistenceErrorCorrelation,
186) {
187 return (cause: unknown): AuthSessionRepositoryError =>
188 Schema.isSchemaError(cause)
189 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation)
190 : new PersistenceSqlError({
191 operation: sqlOperation,
192 ...(correlation === undefined ? {} : { correlation }),
193 cause,
194 });
195}
196
197/** @public */
198export const make = Effect.gen(function* () {
199 const sql = yield* SqlClient.SqlClient;
200
201 const insertSessionRow = (ignoreExisting: boolean) =>
202 SqlSchema.void({
203 Request: CreateAuthSessionInput,
204 execute: (input) =>
205 sql`
206 INSERT INTO auth_sessions (
207 session_id,
208 subject,
209 scopes,
210 method,
211 client_label,
212 client_ip_address,
213 client_user_agent,
214 client_device_type,
215 client_os,
216 client_browser,
217 issued_at,
218 expires_at,
219 revoked_at
220 )
221 VALUES (
222 ${input.sessionId},
223 ${input.subject},
224 ${JSON.stringify(input.scopes)},
225 ${input.method},
226 ${input.client.label},
227 ${input.client.ipAddress},
228 ${input.client.userAgent},
229 ${input.client.deviceType},
230 ${input.client.os},
231 ${input.client.browser},
232 ${input.issuedAt},
233 ${input.expiresAt},
234 NULL
235 )
236 ${ignoreExisting ? sql`ON CONFLICT(session_id) DO NOTHING` : sql``}
237 `,
238 });
239 const createSessionRow = insertSessionRow(false);
240 const createSessionRowIfAbsent = insertSessionRow(true);
241
242 const getSessionRowById = SqlSchema.findOneOption({
243 Request: GetAuthSessionByIdInput,
244 Result: AuthSessionRawDbRow,
245 execute: ({ sessionId }) =>
246 sql`
247 SELECT
248 session_id AS "sessionId",
249 subject AS "subject",
250 scopes AS "scopes",
251 method AS "method",
252 client_label AS "clientLabel",
253 client_ip_address AS "clientIpAddress",
254 client_user_agent AS "clientUserAgent",
255 client_device_type AS "clientDeviceType",
256 client_os AS "clientOs",
257 client_browser AS "clientBrowser",
258 issued_at AS "issuedAt",
259 expires_at AS "expiresAt",
260 last_connected_at AS "lastConnectedAt",
261 revoked_at AS "revokedAt"
262 FROM auth_sessions
263 WHERE session_id = ${sessionId}
264 `,
265 });
266
267 const revokeActiveSessionsForReplacement = SqlSchema.findAll({
268 Request: CreateReplacingActiveAuthSessionInput,
269 Result: Schema.Struct({ sessionId: AuthSessionId }),
270 execute: ({ session, revokedAt, replaceSessionId }) =>
271 sql`
272 UPDATE auth_sessions
273 SET revoked_at = ${revokedAt}
274 WHERE ${
275 replaceSessionId === undefined
276 ? sql`subject = ${session.subject}`
277 : sql`session_id = ${replaceSessionId}`
278 }
279 AND method = ${session.method}
280 AND revoked_at IS NULL
281 AND expires_at > ${revokedAt}
282 RETURNING session_id AS "sessionId"
283 `,
284 });
285
286 const listActiveSessionRows = SqlSchema.findAll({
287 Request: ListActiveAuthSessionsInput,
288 Result: AuthSessionRawDbRow,
289 execute: ({ now, connectedSessionIds = [] }) =>
290 sql`
291 SELECT
292 session_id AS "sessionId",
293 subject AS "subject",
294 scopes AS "scopes",
295 method AS "method",
296 client_label AS "clientLabel",
297 client_ip_address AS "clientIpAddress",
298 client_user_agent AS "clientUserAgent",
299 client_device_type AS "clientDeviceType",
300 client_os AS "clientOs",
301 client_browser AS "clientBrowser",
302 issued_at AS "issuedAt",
303 expires_at AS "expiresAt",
304 last_connected_at AS "lastConnectedAt",
305 revoked_at AS "revokedAt"
306 FROM auth_sessions
307 WHERE revoked_at IS NULL
308 AND (expires_at > ${now} OR ${sql.in("session_id", connectedSessionIds)})
309 ORDER BY issued_at DESC, session_id DESC
310 `,
311 });
312
313 const setLastConnectedAtRow = SqlSchema.void({
314 Request: SetAuthSessionLastConnectedAtInput,
315 execute: ({ sessionId, lastConnectedAt }) =>
316 sql`
317 UPDATE auth_sessions
318 SET last_connected_at = ${lastConnectedAt}
319 WHERE session_id = ${sessionId}
320 AND revoked_at IS NULL
321 `,
322 });
323
324 const revokeSessionRows = SqlSchema.findAll({
325 Request: RevokeAuthSessionInput,
326 Result: Schema.Struct({ sessionId: AuthSessionId }),
327 execute: ({ sessionId, revokedAt }) =>
328 sql`
329 UPDATE auth_sessions
330 SET revoked_at = ${revokedAt}
331 WHERE session_id = ${sessionId}
332 AND revoked_at IS NULL
333 RETURNING session_id AS "sessionId"
334 `,
335 });
336
337 const revokeOtherSessionRows = SqlSchema.findAll({
338 Request: RevokeOtherAuthSessionsInput,
339 Result: Schema.Struct({ sessionId: AuthSessionId }),
340 execute: ({ currentSessionId, revokedAt }) =>
341 sql`
342 UPDATE auth_sessions
343 SET revoked_at = ${revokedAt}
344 WHERE session_id <> ${currentSessionId}
345 AND revoked_at IS NULL
346 RETURNING session_id AS "sessionId"
347 `,
348 });
349
350 const create: AuthSessionRepository["Service"]["create"] = (input) =>
351 createSessionRow(input).pipe(
352 Effect.mapError(
353 toPersistenceSqlOrDecodeError(
354 "AuthSessionRepository.create:query",
355 "AuthSessionRepository.create:encodeRequest",
356 { sessionId: input.sessionId },
357 ),
358 ),
359 );
360
361 const createReplacingActive: AuthSessionRepository["Service"]["createReplacingActive"] = (
362 input,
363 ) =>
364 sql
365 .withTransaction(
366 revokeActiveSessionsForReplacement(input).pipe(
367 Effect.flatMap((revokedRows) =>
368 createSessionRow(input.session).pipe(
369 Effect.as(revokedRows.map((row) => row.sessionId)),
370 ),
371 ),
372 ),
373 )
374 .pipe(
375 Effect.mapError(
376 toPersistenceSqlOrDecodeError(
377 "AuthSessionRepository.createReplacingActive:query",
378 "AuthSessionRepository.createReplacingActive:encodeRequest",
379 { sessionId: input.session.sessionId },
380 ),
381 ),
382 );
383
384 const createIfAbsent: AuthSessionRepository["Service"]["createIfAbsent"] = (input) =>
385 createSessionRowIfAbsent(input).pipe(
386 Effect.mapError(
387 toPersistenceSqlOrDecodeError(
388 "AuthSessionRepository.createIfAbsent:query",
389 "AuthSessionRepository.createIfAbsent:encodeRequest",
390 { sessionId: input.sessionId },
391 ),
392 ),
393 );
394
395 const getById: AuthSessionRepository["Service"]["getById"] = (input) =>
396 getSessionRowById(input).pipe(
397 NodeSqliteClient.readOnly,
398 Effect.mapError(
399 toPersistenceSqlOrDecodeError(
400 "AuthSessionRepository.getById:query",
401 "AuthSessionRepository.getById:decodeRow",
402 { sessionId: input.sessionId },
403 ),
404 ),
405 Effect.flatMap((rowOption) =>
406 Option.match(rowOption, {
407 onNone: () => Effect.succeedNone,
408 onSome: (row) =>
409 decodeAuthSessionDbRow(row).pipe(
410 Effect.mapError((cause) =>
411 PersistenceDecodeError.fromSchemaError(
412 "AuthSessionRepository.getById:decodeRow",
413 cause,
414 { sessionId: input.sessionId },
415 ),
416 ),
417 Effect.map((decodedRow) => Option.some(toAuthSessionRecord(decodedRow))),
418 ),
419 }),
420 ),
421 );
422
423 const listActive: AuthSessionRepository["Service"]["listActive"] = (input) =>
424 listActiveSessionRows(input).pipe(
425 NodeSqliteClient.readOnly,
426 Effect.mapError(
427 toPersistenceSqlOrDecodeError(
428 "AuthSessionRepository.listActive:query",
429 "AuthSessionRepository.listActive:decodeRows",
430 ),
431 ),
432 Effect.flatMap((rows) =>
433 Effect.forEach(rows, (row) =>
434 decodeAuthSessionDbRow(row).pipe(
435 Effect.mapError((cause) =>
436 PersistenceDecodeError.fromSchemaError(
437 "AuthSessionRepository.listActive:decodeRows",
438 cause,
439 { sessionId: row.sessionId },
440 ),
441 ),
442 Effect.map(toAuthSessionRecord),
443 ),
444 ),
445 ),
446 );
447
448 const revoke: AuthSessionRepository["Service"]["revoke"] = (input) =>
449 revokeSessionRows(input).pipe(
450 Effect.mapError(
451 toPersistenceSqlOrDecodeError(
452 "AuthSessionRepository.revoke:query",
453 "AuthSessionRepository.revoke:decodeRows",
454 { sessionId: input.sessionId },
455 ),
456 ),
457 Effect.map((rows) => rows.length > 0),
458 );
459
460 const revokeAllExcept: AuthSessionRepository["Service"]["revokeAllExcept"] = (input) =>
461 revokeOtherSessionRows(input).pipe(
462 Effect.mapError(
463 toPersistenceSqlOrDecodeError(
464 "AuthSessionRepository.revokeAllExcept:query",
465 "AuthSessionRepository.revokeAllExcept:decodeRows",
466 { currentSessionId: input.currentSessionId },
467 ),
468 ),
469 Effect.map((rows) => rows.map((row) => row.sessionId)),
470 );
471
472 const setLastConnectedAt: AuthSessionRepository["Service"]["setLastConnectedAt"] = (input) =>
473 setLastConnectedAtRow(input).pipe(
474 Effect.mapError(
475 toPersistenceSqlOrDecodeError(
476 "AuthSessionRepository.setLastConnectedAt:query",
477 "AuthSessionRepository.setLastConnectedAt:encodeRequest",
478 { sessionId: input.sessionId },
479 ),
480 ),
481 );
482
483 return {
484 create,
485 createReplacingActive,
486 createIfAbsent,
487 getById,
488 listActive,
489 revoke,
490 revokeAllExcept,
491 setLastConnectedAt,
492 } satisfies AuthSessionRepository["Service"];
493});
494
495export const layer = Layer.effect(AuthSessionRepository, make);