986 lines · 30.6 KB · TypeScript
RawDownload
import {  AuthSessionId,  AuthStandardClientScopes,  AuthEnvironmentScopes,  type AuthClientMetadata,  type AuthClientSession,  type AuthEnvironmentScope,  type ClientSurface,  type ServerAuthSessionMethod,} from "@t3tools/contracts";import * as Context from "effect/Context";import * as Crypto from "effect/Crypto";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 * as ServerConfig from "../config.ts";import * as ServerEnvironment from "../environment/ServerEnvironment.ts";import * as AuthSessions from "../persistence/AuthSessions.ts";import * as ServerSecretStore from "./ServerSecretStore.ts";import {  base64UrlDecodeUtf8,  base64UrlEncode,  resolveLegacySessionCookieName,  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 MalformedSessionTokenError extends Schema.TaggedErrorClass<MalformedSessionTokenError>()(  "MalformedSessionTokenError",  {},) {  override get message(): string {    return "Malformed session token.";  }} export class InvalidSessionTokenSignatureError extends Schema.TaggedErrorClass<InvalidSessionTokenSignatureError>()(  "InvalidSessionTokenSignatureError",  {},) {  override get message(): string {    return "Invalid session token signature.";  }} export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass<InvalidSessionTokenPayloadError>()(  "InvalidSessionTokenPayloadError",  {    cause: Schema.Defect(),  },) {  override get message(): string {    return "Invalid session token payload.";  }} export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTokenExpiredError>()(  "SessionTokenExpiredError",  {    sessionId: AuthSessionId,    expiresAt: Schema.DateTimeUtc,    observedAt: Schema.DateTimeUtc,  },) {  override get message(): string {    return "Session token expired.";  }} export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSessionTokenError>()(  "UnknownSessionTokenError",  {    sessionId: AuthSessionId,  },) {  override get message(): string {    return "Unknown session token.";  }} export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTokenRevokedError>()(  "SessionTokenRevokedError",  {    sessionId: AuthSessionId,    revokedAt: Schema.DateTimeUtc,  },) {  override get message(): string {    return "Session token revoked.";  }} export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass<InvalidSessionExpirationClaimError>()(  "InvalidSessionExpirationClaimError",  {    sessionId: AuthSessionId,    expirationClaim: Schema.Number,  },) {  override get message(): string {    return "Invalid `exp` claim";  }} export class MalformedWebSocketTokenError extends Schema.TaggedErrorClass<MalformedWebSocketTokenError>()(  "MalformedWebSocketTokenError",  {},) {  override get message(): string {    return "Malformed websocket token.";  }} export class InvalidWebSocketTokenSignatureError extends Schema.TaggedErrorClass<InvalidWebSocketTokenSignatureError>()(  "InvalidWebSocketTokenSignatureError",  {},) {  override get message(): string {    return "Invalid websocket token signature.";  }} export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass<InvalidWebSocketTokenPayloadError>()(  "InvalidWebSocketTokenPayloadError",  {    cause: Schema.Defect(),  },) {  override get message(): string {    return "Invalid websocket token payload.";  }} export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocketTokenExpiredError>()(  "WebSocketTokenExpiredError",  {    sessionId: AuthSessionId,    expiresAt: Schema.DateTimeUtc,    observedAt: Schema.DateTimeUtc,  },) {  override get message(): string {    return "Websocket token expired.";  }} export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<UnknownWebSocketSessionError>()(  "UnknownWebSocketSessionError",  {    sessionId: AuthSessionId,  },) {  override get message(): string {    return "Unknown websocket session.";  }} export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSocketSessionExpiredError>()(  "WebSocketSessionExpiredError",  {    sessionId: AuthSessionId,    expiresAt: Schema.DateTimeUtc,    observedAt: Schema.DateTimeUtc,  },) {  override get message(): string {    return "Websocket session expired.";  }} export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass<WebSocketSessionRevokedError>()(  "WebSocketSessionRevokedError",  {    sessionId: AuthSessionId,    revokedAt: Schema.DateTimeUtc,  },) {  override get message(): string {    return "Websocket session revoked.";  }} export const SessionCredentialInvalidError = Schema.Union([  MalformedSessionTokenError,  InvalidSessionTokenSignatureError,  InvalidSessionTokenPayloadError,  SessionTokenExpiredError,  UnknownSessionTokenError,  SessionTokenRevokedError,  InvalidSessionExpirationClaimError,  MalformedWebSocketTokenError,  InvalidWebSocketTokenSignatureError,  InvalidWebSocketTokenPayloadError,  WebSocketTokenExpiredError,  UnknownWebSocketSessionError,  WebSocketSessionExpiredError,  WebSocketSessionRevokedError,]);export type SessionCredentialInvalidError = typeof SessionCredentialInvalidError.Type;export const isSessionCredentialInvalidError = Schema.is(SessionCredentialInvalidError); const sessionCredentialInternalErrorContext = {  cause: Schema.Defect(),}; export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionClaimsEncodingError>()(  "SessionClaimsEncodingError",  {    sessionId: AuthSessionId,    operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]),    ...sessionCredentialInternalErrorContext,  },) {  override get message(): string {    return "Failed to encode claims";  }} export class SessionCredentialIssueError extends Schema.TaggedErrorClass<SessionCredentialIssueError>()(  "SessionCredentialIssueError",  {    sessionId: Schema.optional(AuthSessionId),    ...sessionCredentialInternalErrorContext,  },) {  override get message(): string {    return "Failed to issue session credential.";  }} export class SessionCredentialVerificationError extends Schema.TaggedErrorClass<SessionCredentialVerificationError>()(  "SessionCredentialVerificationError",  {    sessionId: AuthSessionId,    ...sessionCredentialInternalErrorContext,  },) {  override get message(): string {    return "Failed to verify session credential.";  }} export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketTokenIssueError>()(  "WebSocketTokenIssueError",  {    sessionId: AuthSessionId,    ...sessionCredentialInternalErrorContext,  },) {  override get message(): string {    return "Failed to issue websocket token.";  }} export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass<WebSocketTokenVerificationError>()(  "WebSocketTokenVerificationError",  {    sessionId: AuthSessionId,    ...sessionCredentialInternalErrorContext,  },) {  override get message(): string {    return "Failed to verify websocket token.";  }} export class ActiveSessionsListError extends Schema.TaggedErrorClass<ActiveSessionsListError>()(  "ActiveSessionsListError",  {    ...sessionCredentialInternalErrorContext,  },) {  override get message(): string {    return "Failed to list active sessions.";  }} export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevocationError>()(  "SessionRevocationError",  {    sessionId: AuthSessionId,    ...sessionCredentialInternalErrorContext,  },) {  override get message(): string {    return "Failed to revoke session.";  }} export class OtherSessionsRevocationError extends Schema.TaggedErrorClass<OtherSessionsRevocationError>()(  "OtherSessionsRevocationError",  {    currentSessionId: AuthSessionId,    ...sessionCredentialInternalErrorContext,  },) {  override get message(): string {    return "Failed to revoke other sessions.";  }} export const SessionCredentialInternalError = Schema.Union([  SessionClaimsEncodingError,  SessionCredentialIssueError,  SessionCredentialVerificationError,  WebSocketTokenIssueError,  WebSocketTokenVerificationError,  ActiveSessionsListError,  SessionRevocationError,  OtherSessionsRevocationError,]);export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type;export const isSessionCredentialInternalError = Schema.is(SessionCredentialInternalError); export const SessionCredentialError = Schema.Union([  SessionCredentialInvalidError,  SessionCredentialInternalError,]);export type SessionCredentialError = typeof SessionCredentialError.Type;export const isSessionCredentialError = Schema.is(SessionCredentialError); export class SessionStore extends Context.Service<  SessionStore,  {    readonly cookieName: string;    readonly legacyCookieName: string | undefined;    readonly issue: (input?: {      readonly ttl?: Duration.Duration;      readonly subject?: string;      readonly method?: ServerAuthSessionMethod;      readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;      readonly client?: AuthClientMetadata;      readonly proofKeyThumbprint?: string;      /**       * Atomically revoke active sessions with the same subject and method       * before storing this session.       */      readonly replaceActiveForSubjectAndMethod?: boolean;    }) => 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>;    readonly recordClientConnection: (      sessionId: AuthSessionId,      client: {        readonly surface?: ClientSurface | undefined;        readonly appVersion?: string | undefined;      },    ) => Effect.Effect<void, never>;  }>()("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,  };} export const make = Effect.gen(function* () {  const crypto = yield* Crypto.Crypto;  const serverConfig = yield* ServerConfig.ServerConfig;  const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity;  const secretStore = yield* ServerSecretStore.ServerSecretStore;  const authSessions = yield* AuthSessions.AuthSessionRepository;  const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);  const connectedSessionsRef = yield* Ref.make(new Map<AuthSessionId, number>());  const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();  const cookieInput = {    mode: serverConfig.mode,    port: serverConfig.port,    host: serverConfig.host,    instanceKey: serverConfig.stateDir,    environmentId: yield* serverEnvironment.getEnvironmentId,    development: serverConfig.devUrl !== undefined,  } as const;  const cookieName = resolveSessionCookieName(cookieInput);  const legacyCookieName = resolveLegacySessionCookieName(cookieInput);   const emitUpsert = (clientSession: AuthClientSession) =>    PubSub.publish(changesPubSub, {      type: "clientUpserted",

Lines 1–500 of 986.