import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts";import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect";import { Option } from "effect"; import { ServerConfig } from "../../config.ts";import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts";import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";import { ServerSecretStore } from "../Services/ServerSecretStore.ts";import { SessionCredentialError, SessionCredentialService, type IssuedSession, type SessionCredentialChange, type SessionCredentialServiceShape, type VerifiedSession,} from "../Services/SessionCredentialService.ts";import { base64UrlDecodeUtf8, base64UrlEncode, resolveSessionCookieName, signPayload, timingSafeEqualBase64Url,} from "../utils.ts"; 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, role: Schema.Literals(["owner", "client"]), method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), 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, };} export const makeSessionCredentialService = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const secretStore = yield* 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 toSessionCredentialError = (message: string) => (cause: unknown) => new SessionCredentialError({ message, cause, }); 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, role: row.value.role, 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: SessionCredentialServiceShape["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, }), ), ), ); const markDisconnected: SessionCredentialServiceShape["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, }), ), ), ); const issue: SessionCredentialServiceShape["issue"] = (input) => Effect.gen(function* () { const sessionId = AuthSessionId.make(crypto.randomUUID()); 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", role: input?.role ?? "client", method: input?.method ?? "browser-session-cookie", iat: issuedAt.epochMilliseconds, exp: expiresAt.epochMilliseconds, }; const encodedPayload = base64UrlEncode(JSON.stringify(claims)); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); yield* authSessions.create({ sessionId, subject: claims.sub, role: claims.role, 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, role: claims.role, method: claims.method, client, issuedAt, expiresAt, lastConnectedAt: null, connected: false, }), ); return { sessionId, token: `${encodedPayload}.${signature}`, method: claims.method, client, expiresAt: expiresAt, role: claims.role, } satisfies IssuedSession; }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential."))); const verify: SessionCredentialServiceShape["verify"] = (token) => Effect.gen(function* () { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { return yield* new SessionCredentialError({ message: "Malformed session token.", }); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { return yield* new SessionCredentialError({ message: "Invalid session token signature.", }); } const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( Effect.mapError( (cause) => new SessionCredentialError({ message: "Invalid session token payload.", cause, }), ), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { return yield* new SessionCredentialError({ message: "Session token expired.", }); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { return yield* new SessionCredentialError({ message: "Unknown session token.", }); } if (row.value.revokedAt !== null) { return yield* new SessionCredentialError({ message: "Session token revoked.", }); } return { sessionId: claims.sid, token, method: claims.method, client: toClientMetadata(row.value.client), expiresAt: DateTime.makeUnsafe(claims.exp), subject: claims.sub, role: claims.role, } satisfies VerifiedSession; }).pipe( Effect.mapError((cause) => cause instanceof SessionCredentialError ? cause : new SessionCredentialError({ message: "Failed to verify session credential.", cause, }), ), ); const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = ( sessionId, input, ) => Effect.gen(function* () { 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 = base64UrlEncode(JSON.stringify(claims)); const signature = signPayload(encodedPayload, signingSecret); return { token: `${encodedPayload}.${signature}`, expiresAt, }; }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue websocket token."))); const verifyWebSocketToken: SessionCredentialServiceShape["verifyWebSocketToken"] = (token) => Effect.gen(function* () { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { return yield* new SessionCredentialError({ message: "Malformed websocket token.", }); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { return yield* new SessionCredentialError({ message: "Invalid websocket token signature.", }); } const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( Effect.mapError( (cause) => new SessionCredentialError({ message: "Invalid websocket token payload.", cause, }), ), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { return yield* new SessionCredentialError({ message: "Websocket token expired.", }); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { return yield* new SessionCredentialError({ message: "Unknown websocket session.", }); } if (row.value.expiresAt.epochMilliseconds <= now) { return yield* new SessionCredentialError({ message: "Websocket session expired.", }); } if (row.value.revokedAt !== null) { return yield* new SessionCredentialError({ message: "Websocket session revoked.", }); } return { sessionId: row.value.sessionId, token, method: row.value.method, client: toClientMetadata(row.value.client), expiresAt: row.value.expiresAt, subject: row.value.subject, role: row.value.role, } satisfies VerifiedSession; }).pipe( Effect.mapError((cause) => cause instanceof SessionCredentialError ? cause : new SessionCredentialError({ message: "Failed to verify websocket token.", cause, }), ), ); const listActive: SessionCredentialServiceShape["listActive"] = () => Effect.gen(function* () { const now = yield* DateTime.now; const connectedSessions = yield* Ref.get(connectedSessionsRef); const rows = yield* authSessions.listActive({ now }); return rows.map((row) => toAuthClientSession({ sessionId: row.sessionId, subject: row.subject, role: row.role, method: row.method, client: toClientMetadata(row.client), issuedAt: row.issuedAt, expiresAt: row.expiresAt, lastConnectedAt: row.lastConnectedAt, connected: connectedSessions.has(row.sessionId), }), ); }).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions."))); const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) => Effect.gen(function* () { const revokedAt = yield* DateTime.now; const revoked = yield* authSessions.revoke({ sessionId, revokedAt, }); if (revoked) { yield* Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); next.delete(sessionId); return next; }); yield* emitRemoved(sessionId); } return revoked; }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session."))); const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) => Effect.gen(function* () { const revokedAt = yield* DateTime.now; const revokedSessionIds = yield* authSessions.revokeAllExcept({ currentSessionId: sessionId, revokedAt, }); if (revokedSessionIds.length > 0) { yield* Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); for (const revokedSessionId of revokedSessionIds) { next.delete(revokedSessionId); } return next; }); yield* Effect.forEach( revokedSessionIds, (revokedSessionId) => emitRemoved(revokedSessionId), { concurrency: "unbounded", discard: true, }, ); } return revokedSessionIds.length; }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); return { cookieName, issue, verify, issueWebSocketToken, verifyWebSocketToken, listActive, get streamChanges() { return Stream.fromPubSub(changesPubSub); }, revoke, revokeAllExcept, markConnected, markDisconnected, } satisfies SessionCredentialServiceShape;}); export const SessionCredentialServiceLive = Layer.effect( SessionCredentialService, makeSessionCredentialService,).pipe(Layer.provideMerge(AuthSessionRepositoryLive));
import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts";
import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect";
import { Option } from "effect";
import { ServerConfig } from "../../config.ts";
import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts";
import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";
import { ServerSecretStore } from "../Services/ServerSecretStore.ts";
import {
SessionCredentialError,
SessionCredentialService,
type IssuedSession,
type SessionCredentialChange,
type SessionCredentialServiceShape,
type VerifiedSession,
} from "../Services/SessionCredentialService.ts";
import {
base64UrlDecodeUtf8,
base64UrlEncode,
resolveSessionCookieName,
signPayload,
timingSafeEqualBase64Url,
} from "../utils.ts";
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,
role: Schema.Literals(["owner", "client"]),
method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]),
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,
};
}
export const makeSessionCredentialService = Effect.gen(function* () {
const serverConfig = yield* ServerConfig;
const secretStore = yield* 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 toSessionCredentialError = (message: string) => (cause: unknown) =>
new SessionCredentialError({
message,
cause,
});
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,
role: row.value.role,
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: SessionCredentialServiceShape["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,
}),
),
),
);
const markDisconnected: SessionCredentialServiceShape["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,
}),
),
),
);
const issue: SessionCredentialServiceShape["issue"] = (input) =>
Effect.gen(function* () {
const sessionId = AuthSessionId.make(crypto.randomUUID());
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",
role: input?.role ?? "client",
method: input?.method ?? "browser-session-cookie",
iat: issuedAt.epochMilliseconds,
exp: expiresAt.epochMilliseconds,
};
const encodedPayload = base64UrlEncode(JSON.stringify(claims));
const signature = signPayload(encodedPayload, signingSecret);
const client = input?.client ?? createDefaultClientMetadata();
yield* authSessions.create({
sessionId,
subject: claims.sub,
role: claims.role,
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,
role: claims.role,
method: claims.method,
client,
issuedAt,
expiresAt,
lastConnectedAt: null,
connected: false,
}),
);
return {
sessionId,
token: `${encodedPayload}.${signature}`,
method: claims.method,
client,
expiresAt: expiresAt,
role: claims.role,
} satisfies IssuedSession;
}).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential.")));
const verify: SessionCredentialServiceShape["verify"] = (token) =>
Effect.gen(function* () {
const [encodedPayload, signature] = token.split(".");
if (!encodedPayload || !signature) {
return yield* new SessionCredentialError({
message: "Malformed session token.",
});
}
const expectedSignature = signPayload(encodedPayload, signingSecret);
if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
return yield* new SessionCredentialError({
message: "Invalid session token signature.",
});
}
const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
Effect.mapError(
(cause) =>
new SessionCredentialError({
message: "Invalid session token payload.",
cause,
}),
),
);
const now = yield* Clock.currentTimeMillis;
if (claims.exp <= now) {
return yield* new SessionCredentialError({
message: "Session token expired.",
});
}
const row = yield* authSessions.getById({ sessionId: claims.sid });
if (Option.isNone(row)) {
return yield* new SessionCredentialError({
message: "Unknown session token.",
});
}
if (row.value.revokedAt !== null) {
return yield* new SessionCredentialError({
message: "Session token revoked.",
});
}
return {
sessionId: claims.sid,
token,
method: claims.method,
client: toClientMetadata(row.value.client),
expiresAt: DateTime.makeUnsafe(claims.exp),
subject: claims.sub,
role: claims.role,
} satisfies VerifiedSession;
}).pipe(
Effect.mapError((cause) =>
cause instanceof SessionCredentialError
? cause
: new SessionCredentialError({
message: "Failed to verify session credential.",
cause,
}),
),
);
const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = (
sessionId,
input,
) =>
Effect.gen(function* () {
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 = base64UrlEncode(JSON.stringify(claims));
const signature = signPayload(encodedPayload, signingSecret);
return {
token: `${encodedPayload}.${signature}`,
expiresAt,
};
}).pipe(Effect.mapError(toSessionCredentialError("Failed to issue websocket token.")));
const verifyWebSocketToken: SessionCredentialServiceShape["verifyWebSocketToken"] = (token) =>
Effect.gen(function* () {
const [encodedPayload, signature] = token.split(".");
if (!encodedPayload || !signature) {
return yield* new SessionCredentialError({
message: "Malformed websocket token.",
});
}
const expectedSignature = signPayload(encodedPayload, signingSecret);
if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
return yield* new SessionCredentialError({
message: "Invalid websocket token signature.",
});
}
const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
Effect.mapError(
(cause) =>
new SessionCredentialError({
message: "Invalid websocket token payload.",
cause,
}),
),
);
const now = yield* Clock.currentTimeMillis;
if (claims.exp <= now) {
return yield* new SessionCredentialError({
message: "Websocket token expired.",
});
}
const row = yield* authSessions.getById({ sessionId: claims.sid });
if (Option.isNone(row)) {
return yield* new SessionCredentialError({
message: "Unknown websocket session.",
});
}
if (row.value.expiresAt.epochMilliseconds <= now) {
return yield* new SessionCredentialError({
message: "Websocket session expired.",
});
}
if (row.value.revokedAt !== null) {
return yield* new SessionCredentialError({
message: "Websocket session revoked.",
});
}
return {
sessionId: row.value.sessionId,
token,
method: row.value.method,
client: toClientMetadata(row.value.client),
expiresAt: row.value.expiresAt,
subject: row.value.subject,
role: row.value.role,
} satisfies VerifiedSession;
}).pipe(
Effect.mapError((cause) =>
cause instanceof SessionCredentialError
? cause
: new SessionCredentialError({
message: "Failed to verify websocket token.",
cause,
}),
),
);
const listActive: SessionCredentialServiceShape["listActive"] = () =>
Effect.gen(function* () {
const now = yield* DateTime.now;
const connectedSessions = yield* Ref.get(connectedSessionsRef);
const rows = yield* authSessions.listActive({ now });
return rows.map((row) =>
toAuthClientSession({
sessionId: row.sessionId,
subject: row.subject,
role: row.role,
method: row.method,
client: toClientMetadata(row.client),
issuedAt: row.issuedAt,
expiresAt: row.expiresAt,
lastConnectedAt: row.lastConnectedAt,
connected: connectedSessions.has(row.sessionId),
}),
);
}).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions.")));
const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) =>
Effect.gen(function* () {
const revokedAt = yield* DateTime.now;
const revoked = yield* authSessions.revoke({
sessionId,
revokedAt,
});
if (revoked) {
yield* Ref.update(connectedSessionsRef, (current) => {
const next = new Map(current);
next.delete(sessionId);
return next;
});
yield* emitRemoved(sessionId);
}
return revoked;
}).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session.")));
const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) =>
Effect.gen(function* () {
const revokedAt = yield* DateTime.now;
const revokedSessionIds = yield* authSessions.revokeAllExcept({
currentSessionId: sessionId,
revokedAt,
});
if (revokedSessionIds.length > 0) {
yield* Ref.update(connectedSessionsRef, (current) => {
const next = new Map(current);
for (const revokedSessionId of revokedSessionIds) {
next.delete(revokedSessionId);
}
return next;
});
yield* Effect.forEach(
revokedSessionIds,
(revokedSessionId) => emitRemoved(revokedSessionId),
{
concurrency: "unbounded",
discard: true,
},
);
}
return revokedSessionIds.length;
}).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions.")));
return {
cookieName,
issue,
verify,
issueWebSocketToken,
verifyWebSocketToken,
listActive,
get streamChanges() {
return Stream.fromPubSub(changesPubSub);
},
revoke,
revokeAllExcept,
markConnected,
markDisconnected,
} satisfies SessionCredentialServiceShape;
});
export const SessionCredentialServiceLive = Layer.effect(
SessionCredentialService,
makeSessionCredentialService,
).pipe(Layer.provideMerge(AuthSessionRepositoryLive));