import { AuthSessionId, AuthStandardClientScopes, AuthEnvironmentScopes, type AuthClientMetadata, type AuthClientSession, type AuthEnvironmentScope, type ServerAuthSessionMethod,} from "@t3tools/contracts";import * as Clock from "effect/Clock";import * as Context from "effect/Context";import * as Crypto from "effect/Crypto";import * as Data from "effect/Data";import * as DateTime from "effect/DateTime";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as PubSub from "effect/PubSub";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import * as Option from "effect/Option"; import { ServerConfig } from "../config.ts";import { AuthSessionRepositoryLive } from "../persistence/Layers/AuthSessions.ts";import { AuthSessionRepository } from "../persistence/Services/AuthSessions.ts";import * as ServerSecretStore from "./ServerSecretStore.ts";import { base64UrlDecodeUtf8, base64UrlEncode, resolveSessionCookieName, signPayload, timingSafeEqualBase64Url,} from "./utils.ts"; export interface IssuedSession { readonly sessionId: AuthSessionId; readonly token: string; readonly method: ServerAuthSessionMethod; readonly client: AuthClientMetadata; readonly expiresAt: DateTime.DateTime; readonly scopes: ReadonlyArray<AuthEnvironmentScope>; readonly proofKeyThumbprint?: string;} export interface VerifiedSession { readonly sessionId: AuthSessionId; readonly token: string; readonly method: ServerAuthSessionMethod; readonly client: AuthClientMetadata; readonly expiresAt?: DateTime.DateTime; readonly subject: string; readonly scopes: ReadonlyArray<AuthEnvironmentScope>; readonly proofKeyThumbprint?: string;} export type SessionCredentialChange = | { readonly type: "clientUpserted"; readonly clientSession: AuthClientSession; } | { readonly type: "clientRemoved"; readonly sessionId: AuthSessionId; }; export class SessionCredentialInvalidError extends Data.TaggedError( "SessionCredentialInvalidError",)<{ readonly message: string; readonly cause?: unknown;}> {} export class SessionCredentialInternalError extends Data.TaggedError( "SessionCredentialInternalError",)<{ readonly message: string; readonly cause?: unknown;}> {} export type SessionCredentialError = SessionCredentialInvalidError | SessionCredentialInternalError; export interface SessionStoreShape { readonly cookieName: string; readonly issue: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; readonly method?: ServerAuthSessionMethod; readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; readonly client?: AuthClientMetadata; readonly proofKeyThumbprint?: string; }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; readonly issueWebSocketToken: ( sessionId: AuthSessionId, input?: { readonly ttl?: Duration.Duration; }, ) => Effect.Effect< { readonly token: string; readonly expiresAt: DateTime.DateTime; }, SessionCredentialInternalError >; readonly verifyWebSocketToken: ( token: string, ) => Effect.Effect<VerifiedSession, SessionCredentialError>; readonly listActive: () => Effect.Effect< ReadonlyArray<AuthClientSession>, SessionCredentialInternalError >; readonly streamChanges: Stream.Stream<SessionCredentialChange>; readonly revoke: ( sessionId: AuthSessionId, ) => Effect.Effect<boolean, SessionCredentialInternalError>; readonly revokeAllExcept: ( sessionId: AuthSessionId, ) => Effect.Effect<number, SessionCredentialInternalError>; readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;} export class SessionStore extends Context.Service<SessionStore, SessionStoreShape>()( "t3/auth/SessionStore",) {} const SIGNING_SECRET_NAME = "server-signing-key";const DEFAULT_SESSION_TTL = Duration.days(30);const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); const SessionClaims = Schema.Struct({ v: Schema.Literal(1), kind: Schema.Literal("session"), sid: AuthSessionId, sub: Schema.String, scopes: AuthEnvironmentScopes, method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), jkt: Schema.optionalKey(Schema.String), iat: Schema.Number, exp: Schema.Number,});type SessionClaims = typeof SessionClaims.Type; const WebSocketClaims = Schema.Struct({ v: Schema.Literal(1), kind: Schema.Literal("websocket"), sid: AuthSessionId, iat: Schema.Number, exp: Schema.Number,});type WebSocketClaims = typeof WebSocketClaims.Type; const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims)); function createDefaultClientMetadata(): AuthClientMetadata { return { deviceType: "unknown", };} function toClientMetadata(record: { readonly label: string | null; readonly ipAddress: string | null; readonly userAgent: string | null; readonly deviceType: AuthClientMetadata["deviceType"]; readonly os: string | null; readonly browser: string | null;}): AuthClientMetadata { return { ...(record.label ? { label: record.label } : {}), ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}), ...(record.userAgent ? { userAgent: record.userAgent } : {}), deviceType: record.deviceType, ...(record.os ? { os: record.os } : {}), ...(record.browser ? { browser: record.browser } : {}), };} function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession { return { ...input, current: false, };} const toSessionCredentialInternalError = (message: string) => (cause: unknown) => new SessionCredentialInternalError({ message, cause, }); export const make = Effect.fn("makeSessionStore")(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig; const secretStore = yield* ServerSecretStore.ServerSecretStore; const authSessions = yield* AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map<string, number>()); const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); const cookieName = resolveSessionCookieName({ mode: serverConfig.mode, port: serverConfig.port, }); const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { type: "clientUpserted", clientSession, }).pipe(Effect.asVoid); const emitRemoved = (sessionId: AuthSessionId) => PubSub.publish(changesPubSub, { type: "clientRemoved", sessionId, }).pipe(Effect.asVoid); const loadActiveSession = (sessionId: AuthSessionId) => Effect.gen(function* () { const row = yield* authSessions.getById({ sessionId }); if (Option.isNone(row) || row.value.revokedAt !== null) { return Option.none<AuthClientSession>(); } const connectedSessions = yield* Ref.get(connectedSessionsRef); return Option.some( toAuthClientSession({ sessionId: row.value.sessionId, subject: row.value.subject, scopes: row.value.scopes, method: row.value.method, client: toClientMetadata(row.value.client), issuedAt: row.value.issuedAt, expiresAt: row.value.expiresAt, lastConnectedAt: row.value.lastConnectedAt, connected: connectedSessions.has(row.value.sessionId), }), ); }); const markConnected: SessionStoreShape["markConnected"] = (sessionId) => Ref.modify(connectedSessionsRef, (current) => { const next = new Map(current); const wasDisconnected = !next.has(sessionId); next.set(sessionId, (next.get(sessionId) ?? 0) + 1); return [wasDisconnected, next] as const; }).pipe( Effect.flatMap((wasDisconnected) => wasDisconnected ? DateTime.now.pipe( Effect.flatMap((lastConnectedAt) => authSessions.setLastConnectedAt({ sessionId, lastConnectedAt, }), ), ) : Effect.void, ), Effect.flatMap(() => loadActiveSession(sessionId)), Effect.flatMap((session) => Option.isSome(session) ? emitUpsert(session.value) : Effect.void, ), Effect.catchCause((cause) => Effect.logError("Failed to publish connected-session auth update.").pipe( Effect.annotateLogs({ sessionId, cause, }), ), ), Effect.withSpan("SessionStore.markConnected"), ); const markDisconnected: SessionStoreShape["markDisconnected"] = (sessionId) => Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); const remaining = (next.get(sessionId) ?? 0) - 1; if (remaining > 0) { next.set(sessionId, remaining); } else { next.delete(sessionId); } return next; }).pipe( Effect.flatMap(() => loadActiveSession(sessionId)), Effect.flatMap((session) => Option.isSome(session) ? emitUpsert(session.value) : Effect.void, ), Effect.catchCause((cause) => Effect.logError("Failed to publish disconnected-session auth update.").pipe( Effect.annotateLogs({ sessionId, cause, }), ), ), Effect.withSpan("SessionStore.markDisconnected"), ); const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); const issue: SessionStoreShape["issue"] = Effect.fn("SessionStore.issue")( function* (input) { const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); const issuedAt = yield* DateTime.now; const expiresAt = DateTime.add(issuedAt, { milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), }); const claims: SessionClaims = { v: 1, kind: "session", sid: sessionId, sub: input?.subject ?? "browser", scopes: input?.scopes ?? AuthStandardClientScopes, method: input?.method ?? "browser-session-cookie", ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}), iat: issuedAt.epochMilliseconds, exp: expiresAt.epochMilliseconds, }; const encodedPayload = yield* encodeClaims(claims).pipe( Effect.map(base64UrlEncode), Effect.mapError( (cause) => new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), ), ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); yield* authSessions.create({ sessionId, subject: claims.sub, scopes: claims.scopes, method: claims.method, client: { label: client.label ?? null, ipAddress: client.ipAddress ?? null, userAgent: client.userAgent ?? null, deviceType: client.deviceType, os: client.os ?? null, browser: client.browser ?? null, }, issuedAt, expiresAt, }); yield* emitUpsert( toAuthClientSession({ sessionId, subject: claims.sub, scopes: claims.scopes, method: claims.method, client, issuedAt, expiresAt, lastConnectedAt: null, connected: false, }), ); return { sessionId, token: `${encodedPayload}.${signature}`, method: claims.method, client, expiresAt: expiresAt, scopes: claims.scopes, ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), ); const verify: SessionStoreShape["verify"] = Effect.fn("SessionStore.verify")( function* (token) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { return yield* new SessionCredentialInvalidError({ message: "Malformed session token.", }); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { return yield* new SessionCredentialInvalidError({ message: "Invalid session token signature.", }); } const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( Effect.mapError( (cause) => new SessionCredentialInvalidError({ message: "Invalid session token payload.", cause, }), ), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { return yield* new SessionCredentialInvalidError({ message: "Session token expired.", }); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { return yield* new SessionCredentialInvalidError({ message: "Unknown session token.", }); } if (row.value.revokedAt !== null) { return yield* new SessionCredentialInvalidError({ message: "Session token revoked.", }); } const expiresAt = DateTime.make(claims.exp); if (Option.isNone(expiresAt)) { return yield* new SessionCredentialInvalidError({ message: "Invalid `exp` claim", }); } return { sessionId: claims.sid, token, method: claims.method, client: toClientMetadata(row.value.client), expiresAt: expiresAt.value, subject: claims.sub, scopes: claims.scopes, ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies VerifiedSession; }, Effect.mapError((cause) => cause._tag === "SessionCredentialInvalidError" ? cause : new SessionCredentialInternalError({ message: "Failed to verify session credential.", cause, }), ), ); const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); const issueWebSocketToken: SessionStoreShape["issueWebSocketToken"] = Effect.fn( "SessionStore.issueWebSocketToken", )( function* (sessionId, input) { const issuedAt = yield* DateTime.now; const expiresAt = DateTime.add(issuedAt, { milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), }); const claims: WebSocketClaims = { v: 1, kind: "websocket", sid: sessionId, iat: issuedAt.epochMilliseconds, exp: expiresAt.epochMilliseconds, }; const encodedPayload = yield* encodeWsClaims(claims).pipe( Effect.map(base64UrlEncode), Effect.mapError( (cause) => new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), ), ); const signature = signPayload(encodedPayload, signingSecret); return { token: `${encodedPayload}.${signature}`, expiresAt, }; }, Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")), ); const verifyWebSocketToken: SessionStoreShape["verifyWebSocketToken"] = Effect.fn( "SessionStore.verifyWebSocketToken", )( function* (token) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { return yield* new SessionCredentialInvalidError({ message: "Malformed websocket token.", }); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { return yield* new SessionCredentialInvalidError({ message: "Invalid websocket token signature.", }); } const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( Effect.mapError( (cause) => new SessionCredentialInvalidError({Lines 1–500 of 644. Load more
import { AuthSessionId, AuthStandardClientScopes, AuthEnvironmentScopes, type AuthClientMetadata, type AuthClientSession, type AuthEnvironmentScope, type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import { ServerConfig } from "../config.ts"; import { AuthSessionRepositoryLive } from "../persistence/Layers/AuthSessions.ts"; import { AuthSessionRepository } from "../persistence/Services/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { base64UrlDecodeUtf8, base64UrlEncode, resolveSessionCookieName, signPayload, timingSafeEqualBase64Url, } from "./utils.ts"; export interface IssuedSession { readonly sessionId: AuthSessionId; readonly token: string; readonly method: ServerAuthSessionMethod; readonly client: AuthClientMetadata; readonly expiresAt: DateTime.DateTime; readonly scopes: ReadonlyArray<AuthEnvironmentScope>; readonly proofKeyThumbprint?: string; } export interface VerifiedSession { readonly sessionId: AuthSessionId; readonly token: string; readonly method: ServerAuthSessionMethod; readonly client: AuthClientMetadata; readonly expiresAt?: DateTime.DateTime; readonly subject: string; readonly scopes: ReadonlyArray<AuthEnvironmentScope>; readonly proofKeyThumbprint?: string; } export type SessionCredentialChange = | { readonly type: "clientUpserted"; readonly clientSession: AuthClientSession; } | { readonly type: "clientRemoved"; readonly sessionId: AuthSessionId; }; export class SessionCredentialInvalidError extends Data.TaggedError( "SessionCredentialInvalidError", )<{ readonly message: string; readonly cause?: unknown; }> {} export class SessionCredentialInternalError extends Data.TaggedError( "SessionCredentialInternalError", )<{ readonly message: string; readonly cause?: unknown; }> {} export type SessionCredentialError = SessionCredentialInvalidError | SessionCredentialInternalError; export interface SessionStoreShape { readonly cookieName: string; readonly issue: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; readonly method?: ServerAuthSessionMethod; readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; readonly client?: AuthClientMetadata; readonly proofKeyThumbprint?: string; }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; readonly issueWebSocketToken: ( sessionId: AuthSessionId, input?: { readonly ttl?: Duration.Duration; }, ) => Effect.Effect< { readonly token: string; readonly expiresAt: DateTime.DateTime; }, SessionCredentialInternalError >; readonly verifyWebSocketToken: ( token: string, ) => Effect.Effect<VerifiedSession, SessionCredentialError>; readonly listActive: () => Effect.Effect< ReadonlyArray<AuthClientSession>, SessionCredentialInternalError >; readonly streamChanges: Stream.Stream<SessionCredentialChange>; readonly revoke: ( sessionId: AuthSessionId, ) => Effect.Effect<boolean, SessionCredentialInternalError>; readonly revokeAllExcept: ( sessionId: AuthSessionId, ) => Effect.Effect<number, SessionCredentialInternalError>; readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; } export class SessionStore extends Context.Service<SessionStore, SessionStoreShape>()( "t3/auth/SessionStore", ) {} const SIGNING_SECRET_NAME = "server-signing-key"; const DEFAULT_SESSION_TTL = Duration.days(30); const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); const SessionClaims = Schema.Struct({ v: Schema.Literal(1), kind: Schema.Literal("session"), sid: AuthSessionId, sub: Schema.String, scopes: AuthEnvironmentScopes, method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), jkt: Schema.optionalKey(Schema.String), iat: Schema.Number, exp: Schema.Number, }); type SessionClaims = typeof SessionClaims.Type; const WebSocketClaims = Schema.Struct({ v: Schema.Literal(1), kind: Schema.Literal("websocket"), sid: AuthSessionId, iat: Schema.Number, exp: Schema.Number, }); type WebSocketClaims = typeof WebSocketClaims.Type; const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims)); const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims)); function createDefaultClientMetadata(): AuthClientMetadata { return { deviceType: "unknown", }; } function toClientMetadata(record: { readonly label: string | null; readonly ipAddress: string | null; readonly userAgent: string | null; readonly deviceType: AuthClientMetadata["deviceType"]; readonly os: string | null; readonly browser: string | null; }): AuthClientMetadata { return { ...(record.label ? { label: record.label } : {}), ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}), ...(record.userAgent ? { userAgent: record.userAgent } : {}), deviceType: record.deviceType, ...(record.os ? { os: record.os } : {}), ...(record.browser ? { browser: record.browser } : {}), }; } function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession { return { ...input, current: false, }; } const toSessionCredentialInternalError = (message: string) => (cause: unknown) => new SessionCredentialInternalError({ message, cause, }); export const make = Effect.fn("makeSessionStore")(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig; const secretStore = yield* ServerSecretStore.ServerSecretStore; const authSessions = yield* AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map<string, number>()); const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); const cookieName = resolveSessionCookieName({ mode: serverConfig.mode, port: serverConfig.port, }); const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { type: "clientUpserted", clientSession, }).pipe(Effect.asVoid); const emitRemoved = (sessionId: AuthSessionId) => PubSub.publish(changesPubSub, { type: "clientRemoved", sessionId, }).pipe(Effect.asVoid); const loadActiveSession = (sessionId: AuthSessionId) => Effect.gen(function* () { const row = yield* authSessions.getById({ sessionId }); if (Option.isNone(row) || row.value.revokedAt !== null) { return Option.none<AuthClientSession>(); } const connectedSessions = yield* Ref.get(connectedSessionsRef); return Option.some( toAuthClientSession({ sessionId: row.value.sessionId, subject: row.value.subject, scopes: row.value.scopes, method: row.value.method, client: toClientMetadata(row.value.client), issuedAt: row.value.issuedAt, expiresAt: row.value.expiresAt, lastConnectedAt: row.value.lastConnectedAt, connected: connectedSessions.has(row.value.sessionId), }), ); }); const markConnected: SessionStoreShape["markConnected"] = (sessionId) => Ref.modify(connectedSessionsRef, (current) => { const next = new Map(current); const wasDisconnected = !next.has(sessionId); next.set(sessionId, (next.get(sessionId) ?? 0) + 1); return [wasDisconnected, next] as const; }).pipe( Effect.flatMap((wasDisconnected) => wasDisconnected ? DateTime.now.pipe( Effect.flatMap((lastConnectedAt) => authSessions.setLastConnectedAt({ sessionId, lastConnectedAt, }), ), ) : Effect.void, ), Effect.flatMap(() => loadActiveSession(sessionId)), Effect.flatMap((session) => Option.isSome(session) ? emitUpsert(session.value) : Effect.void, ), Effect.catchCause((cause) => Effect.logError("Failed to publish connected-session auth update.").pipe( Effect.annotateLogs({ sessionId, cause, }), ), ), Effect.withSpan("SessionStore.markConnected"), ); const markDisconnected: SessionStoreShape["markDisconnected"] = (sessionId) => Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); const remaining = (next.get(sessionId) ?? 0) - 1; if (remaining > 0) { next.set(sessionId, remaining); } else { next.delete(sessionId); } return next; }).pipe( Effect.flatMap(() => loadActiveSession(sessionId)), Effect.flatMap((session) => Option.isSome(session) ? emitUpsert(session.value) : Effect.void, ), Effect.catchCause((cause) => Effect.logError("Failed to publish disconnected-session auth update.").pipe( Effect.annotateLogs({ sessionId, cause, }), ), ), Effect.withSpan("SessionStore.markDisconnected"), ); const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); const issue: SessionStoreShape["issue"] = Effect.fn("SessionStore.issue")( function* (input) { const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); const issuedAt = yield* DateTime.now; const expiresAt = DateTime.add(issuedAt, { milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), }); const claims: SessionClaims = { v: 1, kind: "session", sid: sessionId, sub: input?.subject ?? "browser", scopes: input?.scopes ?? AuthStandardClientScopes, method: input?.method ?? "browser-session-cookie", ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}), iat: issuedAt.epochMilliseconds, exp: expiresAt.epochMilliseconds, }; const encodedPayload = yield* encodeClaims(claims).pipe( Effect.map(base64UrlEncode), Effect.mapError( (cause) => new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), ), ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); yield* authSessions.create({ sessionId, subject: claims.sub, scopes: claims.scopes, method: claims.method, client: { label: client.label ?? null, ipAddress: client.ipAddress ?? null, userAgent: client.userAgent ?? null, deviceType: client.deviceType, os: client.os ?? null, browser: client.browser ?? null, }, issuedAt, expiresAt, }); yield* emitUpsert( toAuthClientSession({ sessionId, subject: claims.sub, scopes: claims.scopes, method: claims.method, client, issuedAt, expiresAt, lastConnectedAt: null, connected: false, }), ); return { sessionId, token: `${encodedPayload}.${signature}`, method: claims.method, client, expiresAt: expiresAt, scopes: claims.scopes, ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), ); const verify: SessionStoreShape["verify"] = Effect.fn("SessionStore.verify")( function* (token) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { return yield* new SessionCredentialInvalidError({ message: "Malformed session token.", }); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { return yield* new SessionCredentialInvalidError({ message: "Invalid session token signature.", }); } const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( Effect.mapError( (cause) => new SessionCredentialInvalidError({ message: "Invalid session token payload.", cause, }), ), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { return yield* new SessionCredentialInvalidError({ message: "Session token expired.", }); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { return yield* new SessionCredentialInvalidError({ message: "Unknown session token.", }); } if (row.value.revokedAt !== null) { return yield* new SessionCredentialInvalidError({ message: "Session token revoked.", }); } const expiresAt = DateTime.make(claims.exp); if (Option.isNone(expiresAt)) { return yield* new SessionCredentialInvalidError({ message: "Invalid `exp` claim", }); } return { sessionId: claims.sid, token, method: claims.method, client: toClientMetadata(row.value.client), expiresAt: expiresAt.value, subject: claims.sub, scopes: claims.scopes, ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies VerifiedSession; }, Effect.mapError((cause) => cause._tag === "SessionCredentialInvalidError" ? cause : new SessionCredentialInternalError({ message: "Failed to verify session credential.", cause, }), ), ); const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); const issueWebSocketToken: SessionStoreShape["issueWebSocketToken"] = Effect.fn( "SessionStore.issueWebSocketToken", )( function* (sessionId, input) { const issuedAt = yield* DateTime.now; const expiresAt = DateTime.add(issuedAt, { milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), }); const claims: WebSocketClaims = { v: 1, kind: "websocket", sid: sessionId, iat: issuedAt.epochMilliseconds, exp: expiresAt.epochMilliseconds, }; const encodedPayload = yield* encodeWsClaims(claims).pipe( Effect.map(base64UrlEncode), Effect.mapError( (cause) => new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), ), ); const signature = signPayload(encodedPayload, signingSecret); return { token: `${encodedPayload}.${signature}`, expiresAt, }; }, Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")), ); const verifyWebSocketToken: SessionStoreShape["verifyWebSocketToken"] = Effect.fn( "SessionStore.verifyWebSocketToken", )( function* (token) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { return yield* new SessionCredentialInvalidError({ message: "Malformed websocket token.", }); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { return yield* new SessionCredentialInvalidError({ message: "Invalid websocket token signature.", }); } const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( Effect.mapError( (cause) => new SessionCredentialInvalidError({