| 1 | import { |
| 2 | AuthSessionId, |
| 3 | AuthAdministrativeScopes, |
| 4 | AuthAccessWriteScope, |
| 5 | AuthStandardClientScopes, |
| 6 | AuthEnvironmentScopes, |
| 7 | type AuthClientMetadata, |
| 8 | type AuthClientSession, |
| 9 | type AuthEnvironmentScope, |
| 10 | type ClientSurface, |
| 11 | RuntimeMode, |
| 12 | type ServerAuthSessionMethod, |
| 13 | } from "@t3tools/contracts"; |
| 14 | import * as Context from "effect/Context"; |
| 15 | import * as Crypto from "effect/Crypto"; |
| 16 | import * as DateTime from "effect/DateTime"; |
| 17 | import * as Duration from "effect/Duration"; |
| 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as Layer from "effect/Layer"; |
| 20 | import * as PubSub from "effect/PubSub"; |
| 21 | import * as Ref from "effect/Ref"; |
| 22 | import * as Schema from "effect/Schema"; |
| 23 | import * as Stream from "effect/Stream"; |
| 24 | import * as Option from "effect/Option"; |
| 25 | |
| 26 | import * as ServerConfig from "../config.ts"; |
| 27 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 28 | import * as AuthSessions from "../persistence/AuthSessions.ts"; |
| 29 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 30 | import { |
| 31 | REUSABLE_DEV_SESSION_EXPIRES_AT, |
| 32 | REUSABLE_DEV_SESSION_PREFIX, |
| 33 | resolveReusableDevAuth, |
| 34 | } from "./ReusableDevAuth.ts"; |
| 35 | import { |
| 36 | base64UrlDecodeUtf8, |
| 37 | base64UrlEncode, |
| 38 | resolveLegacySessionCookieName, |
| 39 | resolveSessionCookieName, |
| 40 | signPayload, |
| 41 | timingSafeEqualBase64Url, |
| 42 | } from "./utils.ts"; |
| 43 | |
| 44 | export interface IssuedSession { |
| 45 | readonly sessionId: AuthSessionId; |
| 46 | readonly token: string; |
| 47 | readonly method: ServerAuthSessionMethod; |
| 48 | readonly client: AuthClientMetadata; |
| 49 | readonly expiresAt: DateTime.DateTime; |
| 50 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 51 | readonly proofKeyThumbprint?: string; |
| 52 | } |
| 53 | |
| 54 | export interface VerifiedSession { |
| 55 | readonly parentSessionId?: AuthSessionId; |
| 56 | readonly deviceId?: string; |
| 57 | readonly sessionId: AuthSessionId; |
| 58 | readonly token: string; |
| 59 | readonly method: ServerAuthSessionMethod; |
| 60 | readonly client: AuthClientMetadata; |
| 61 | readonly expiresAt?: DateTime.DateTime; |
| 62 | readonly subject: string; |
| 63 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 64 | readonly proofKeyThumbprint?: string; |
| 65 | /** The most an MCP client approved through OAuth may hand to the threads it drives. */ |
| 66 | readonly runtimeModeCeiling?: RuntimeMode; |
| 67 | } |
| 68 | |
| 69 | export type SessionCredentialChange = |
| 70 | | { |
| 71 | readonly type: "clientUpserted"; |
| 72 | readonly clientSession: AuthClientSession; |
| 73 | } |
| 74 | | { |
| 75 | readonly type: "clientRemoved"; |
| 76 | readonly sessionId: AuthSessionId; |
| 77 | }; |
| 78 | |
| 79 | export class MalformedSessionTokenError extends Schema.TaggedError<MalformedSessionTokenError>()( |
| 80 | "MalformedSessionTokenError", |
| 81 | {}, |
| 82 | ) { |
| 83 | override get message(): string { |
| 84 | return "Malformed session token."; |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | export class InvalidSessionTokenSignatureError extends Schema.TaggedError<InvalidSessionTokenSignatureError>()( |
| 89 | "InvalidSessionTokenSignatureError", |
| 90 | {}, |
| 91 | ) { |
| 92 | override get message(): string { |
| 93 | return "Invalid session token signature."; |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | export class InvalidSessionTokenPayloadError extends Schema.TaggedError<InvalidSessionTokenPayloadError>()( |
| 98 | "InvalidSessionTokenPayloadError", |
| 99 | { |
| 100 | cause: Schema.Defect(), |
| 101 | }, |
| 102 | ) { |
| 103 | override get message(): string { |
| 104 | return "Invalid session token payload."; |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | export class SessionTokenExpiredError extends Schema.TaggedError<SessionTokenExpiredError>()( |
| 109 | "SessionTokenExpiredError", |
| 110 | { |
| 111 | sessionId: AuthSessionId, |
| 112 | expiresAt: Schema.DateTimeUtc, |
| 113 | observedAt: Schema.DateTimeUtc, |
| 114 | }, |
| 115 | ) { |
| 116 | override get message(): string { |
| 117 | return "Session token expired."; |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | export class UnknownSessionTokenError extends Schema.TaggedError<UnknownSessionTokenError>()( |
| 122 | "UnknownSessionTokenError", |
| 123 | { |
| 124 | sessionId: AuthSessionId, |
| 125 | }, |
| 126 | ) { |
| 127 | override get message(): string { |
| 128 | return "Unknown session token."; |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | export class SessionTokenRevokedError extends Schema.TaggedError<SessionTokenRevokedError>()( |
| 133 | "SessionTokenRevokedError", |
| 134 | { |
| 135 | sessionId: AuthSessionId, |
| 136 | revokedAt: Schema.DateTimeUtc, |
| 137 | }, |
| 138 | ) { |
| 139 | override get message(): string { |
| 140 | return "Session token revoked."; |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | export class InvalidSessionExpirationClaimError extends Schema.TaggedError<InvalidSessionExpirationClaimError>()( |
| 145 | "InvalidSessionExpirationClaimError", |
| 146 | { |
| 147 | sessionId: AuthSessionId, |
| 148 | expirationClaim: Schema.Number, |
| 149 | }, |
| 150 | ) { |
| 151 | override get message(): string { |
| 152 | return "Invalid `exp` claim"; |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | export class MalformedWebSocketTokenError extends Schema.TaggedError<MalformedWebSocketTokenError>()( |
| 157 | "MalformedWebSocketTokenError", |
| 158 | {}, |
| 159 | ) { |
| 160 | override get message(): string { |
| 161 | return "Malformed websocket token."; |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | export class InvalidWebSocketTokenSignatureError extends Schema.TaggedError<InvalidWebSocketTokenSignatureError>()( |
| 166 | "InvalidWebSocketTokenSignatureError", |
| 167 | {}, |
| 168 | ) { |
| 169 | override get message(): string { |
| 170 | return "Invalid websocket token signature."; |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | export class InvalidWebSocketTokenPayloadError extends Schema.TaggedError<InvalidWebSocketTokenPayloadError>()( |
| 175 | "InvalidWebSocketTokenPayloadError", |
| 176 | { |
| 177 | cause: Schema.Defect(), |
| 178 | }, |
| 179 | ) { |
| 180 | override get message(): string { |
| 181 | return "Invalid websocket token payload."; |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | export class WebSocketTokenExpiredError extends Schema.TaggedError<WebSocketTokenExpiredError>()( |
| 186 | "WebSocketTokenExpiredError", |
| 187 | { |
| 188 | sessionId: AuthSessionId, |
| 189 | expiresAt: Schema.DateTimeUtc, |
| 190 | observedAt: Schema.DateTimeUtc, |
| 191 | }, |
| 192 | ) { |
| 193 | override get message(): string { |
| 194 | return "Websocket token expired."; |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | export class UnknownWebSocketSessionError extends Schema.TaggedError<UnknownWebSocketSessionError>()( |
| 199 | "UnknownWebSocketSessionError", |
| 200 | { |
| 201 | sessionId: AuthSessionId, |
| 202 | }, |
| 203 | ) { |
| 204 | override get message(): string { |
| 205 | return "Unknown websocket session."; |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | export class WebSocketSessionExpiredError extends Schema.TaggedError<WebSocketSessionExpiredError>()( |
| 210 | "WebSocketSessionExpiredError", |
| 211 | { |
| 212 | sessionId: AuthSessionId, |
| 213 | expiresAt: Schema.DateTimeUtc, |
| 214 | observedAt: Schema.DateTimeUtc, |
| 215 | }, |
| 216 | ) { |
| 217 | override get message(): string { |
| 218 | return "Websocket session expired."; |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | export class WebSocketSessionRevokedError extends Schema.TaggedError<WebSocketSessionRevokedError>()( |
| 223 | "WebSocketSessionRevokedError", |
| 224 | { |
| 225 | sessionId: AuthSessionId, |
| 226 | revokedAt: Schema.DateTimeUtc, |
| 227 | }, |
| 228 | ) { |
| 229 | override get message(): string { |
| 230 | return "Websocket session revoked."; |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | export const SessionCredentialInvalidError = Schema.Union([ |
| 235 | MalformedSessionTokenError, |
| 236 | InvalidSessionTokenSignatureError, |
| 237 | InvalidSessionTokenPayloadError, |
| 238 | SessionTokenExpiredError, |
| 239 | UnknownSessionTokenError, |
| 240 | SessionTokenRevokedError, |
| 241 | InvalidSessionExpirationClaimError, |
| 242 | MalformedWebSocketTokenError, |
| 243 | InvalidWebSocketTokenSignatureError, |
| 244 | InvalidWebSocketTokenPayloadError, |
| 245 | WebSocketTokenExpiredError, |
| 246 | UnknownWebSocketSessionError, |
| 247 | WebSocketSessionExpiredError, |
| 248 | WebSocketSessionRevokedError, |
| 249 | ]); |
| 250 | export type SessionCredentialInvalidError = typeof SessionCredentialInvalidError.Type; |
| 251 | export const isSessionCredentialInvalidError = Schema.is(SessionCredentialInvalidError); |
| 252 | |
| 253 | const sessionCredentialInternalErrorContext = { |
| 254 | cause: Schema.Defect(), |
| 255 | }; |
| 256 | |
| 257 | export class SessionClaimsEncodingError extends Schema.TaggedError<SessionClaimsEncodingError>()( |
| 258 | "SessionClaimsEncodingError", |
| 259 | { |
| 260 | sessionId: AuthSessionId, |
| 261 | operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]), |
| 262 | ...sessionCredentialInternalErrorContext, |
| 263 | }, |
| 264 | ) { |
| 265 | override get message(): string { |
| 266 | return "Failed to encode claims"; |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | export class SessionCredentialIssueError extends Schema.TaggedError<SessionCredentialIssueError>()( |
| 271 | "SessionCredentialIssueError", |
| 272 | { |
| 273 | sessionId: Schema.optional(AuthSessionId), |
| 274 | ...sessionCredentialInternalErrorContext, |
| 275 | }, |
| 276 | ) { |
| 277 | override get message(): string { |
| 278 | return "Failed to issue session credential."; |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | export class SessionCredentialVerificationError extends Schema.TaggedError<SessionCredentialVerificationError>()( |
| 283 | "SessionCredentialVerificationError", |
| 284 | { |
| 285 | sessionId: AuthSessionId, |
| 286 | ...sessionCredentialInternalErrorContext, |
| 287 | }, |
| 288 | ) { |
| 289 | override get message(): string { |
| 290 | return "Failed to verify session credential."; |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | export class WebSocketTokenIssueError extends Schema.TaggedError<WebSocketTokenIssueError>()( |
| 295 | "WebSocketTokenIssueError", |
| 296 | { |
| 297 | sessionId: AuthSessionId, |
| 298 | ...sessionCredentialInternalErrorContext, |
| 299 | }, |
| 300 | ) { |
| 301 | override get message(): string { |
| 302 | return "Failed to issue websocket token."; |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | export class WebSocketTokenVerificationError extends Schema.TaggedError<WebSocketTokenVerificationError>()( |
| 307 | "WebSocketTokenVerificationError", |
| 308 | { |
| 309 | sessionId: AuthSessionId, |
| 310 | ...sessionCredentialInternalErrorContext, |
| 311 | }, |
| 312 | ) { |
| 313 | override get message(): string { |
| 314 | return "Failed to verify websocket token."; |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | export class ActiveSessionsListError extends Schema.TaggedError<ActiveSessionsListError>()( |
| 319 | "ActiveSessionsListError", |
| 320 | { |
| 321 | ...sessionCredentialInternalErrorContext, |
| 322 | }, |
| 323 | ) { |
| 324 | override get message(): string { |
| 325 | return "Failed to list active sessions."; |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | export class SessionRevocationError extends Schema.TaggedError<SessionRevocationError>()( |
| 330 | "SessionRevocationError", |
| 331 | { |
| 332 | sessionId: AuthSessionId, |
| 333 | ...sessionCredentialInternalErrorContext, |
| 334 | }, |
| 335 | ) { |
| 336 | override get message(): string { |
| 337 | return "Failed to revoke session."; |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | export class OtherSessionsRevocationError extends Schema.TaggedError<OtherSessionsRevocationError>()( |
| 342 | "OtherSessionsRevocationError", |
| 343 | { |
| 344 | currentSessionId: AuthSessionId, |
| 345 | ...sessionCredentialInternalErrorContext, |
| 346 | }, |
| 347 | ) { |
| 348 | override get message(): string { |
| 349 | return "Failed to revoke other sessions."; |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | export const SessionCredentialInternalError = Schema.Union([ |
| 354 | SessionClaimsEncodingError, |
| 355 | SessionCredentialIssueError, |
| 356 | SessionCredentialVerificationError, |
| 357 | WebSocketTokenIssueError, |
| 358 | WebSocketTokenVerificationError, |
| 359 | ActiveSessionsListError, |
| 360 | SessionRevocationError, |
| 361 | OtherSessionsRevocationError, |
| 362 | ]); |
| 363 | export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type; |
| 364 | |
| 365 | export const SessionCredentialError = Schema.Union([ |
| 366 | SessionCredentialInvalidError, |
| 367 | SessionCredentialInternalError, |
| 368 | ]); |
| 369 | export type SessionCredentialError = typeof SessionCredentialError.Type; |
| 370 | |
| 371 | export class SessionStore extends Context.Service< |
| 372 | SessionStore, |
| 373 | { |
| 374 | readonly cookieName: string; |
| 375 | readonly legacyCookieName: string | undefined; |
| 376 | readonly issue: (input?: { |
| 377 | readonly parentSessionId?: AuthSessionId; |
| 378 | readonly deviceId?: string; |
| 379 | readonly ttl?: Duration.Duration; |
| 380 | readonly subject?: string; |
| 381 | readonly method?: ServerAuthSessionMethod; |
| 382 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 383 | readonly client?: AuthClientMetadata; |
| 384 | readonly proofKeyThumbprint?: string; |
| 385 | readonly runtimeModeCeiling?: RuntimeMode; |
| 386 | /** |
| 387 | * Atomically revoke active sessions with the same subject and method |
| 388 | * before storing this session. |
| 389 | */ |
| 390 | readonly replaceActiveForSubjectAndMethod?: boolean; |
| 391 | /** Replace only this session, of the same method, in the issuance transaction. */ |
| 392 | readonly replaceSessionId?: AuthSessionId; |
| 393 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; |
| 394 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; |
| 395 | readonly verifyActive: ( |
| 396 | sessionId: AuthSessionId, |
| 397 | ) => Effect.Effect<void, SessionCredentialError>; |
| 398 | readonly issueWebSocketToken: ( |
| 399 | sessionId: AuthSessionId, |
| 400 | input?: { |
| 401 | readonly ttl?: Duration.Duration; |
| 402 | }, |
| 403 | ) => Effect.Effect< |
| 404 | { |
| 405 | readonly token: string; |
| 406 | readonly expiresAt: DateTime.DateTime; |
| 407 | }, |
| 408 | SessionCredentialInternalError |
| 409 | >; |
| 410 | readonly verifyWebSocketToken: ( |
| 411 | token: string, |
| 412 | ) => Effect.Effect<VerifiedSession, SessionCredentialError>; |
| 413 | readonly listActive: () => Effect.Effect< |
| 414 | ReadonlyArray<AuthClientSession>, |
| 415 | SessionCredentialInternalError |
| 416 | >; |
| 417 | readonly streamChanges: Stream.Stream<SessionCredentialChange>; |
| 418 | readonly awaitInvalidation: ( |
| 419 | sessionId: AuthSessionId, |
| 420 | ) => Effect.Effect<void, SessionCredentialVerificationError>; |
| 421 | readonly revoke: ( |
| 422 | sessionId: AuthSessionId, |
| 423 | ) => Effect.Effect<boolean, SessionCredentialInternalError>; |
| 424 | readonly revokeAllExcept: ( |
| 425 | sessionId: AuthSessionId, |
| 426 | ) => Effect.Effect<number, SessionCredentialInternalError>; |
| 427 | readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; |
| 428 | readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; |
| 429 | readonly recordClientConnection: ( |
| 430 | sessionId: AuthSessionId, |
| 431 | client: { |
| 432 | readonly surface?: ClientSurface | undefined; |
| 433 | readonly appVersion?: string | undefined; |
| 434 | }, |
| 435 | ) => Effect.Effect<void, never>; |
| 436 | } |
| 437 | >()("t3/auth/SessionStore") {} |
| 438 | |
| 439 | const SIGNING_SECRET_NAME = "server-signing-key"; |
| 440 | const DEFAULT_SESSION_TTL = Duration.days(30); |
| 441 | const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); |
| 442 | |
| 443 | const SessionClaims = Schema.Struct({ |
| 444 | v: Schema.Literals([1, 2]), |
| 445 | kind: Schema.Literal("session"), |
| 446 | sid: AuthSessionId, |
| 447 | sub: Schema.String, |
| 448 | scopes: AuthEnvironmentScopes, |
| 449 | method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), |
| 450 | jkt: Schema.optionalKey(Schema.String), |
| 451 | rtc: Schema.optionalKey(RuntimeMode), |
| 452 | iat: Schema.Number, |
| 453 | exp: Schema.Number, |
| 454 | }); |
| 455 | type SessionClaims = typeof SessionClaims.Type; |
| 456 | |
| 457 | const WebSocketClaims = Schema.Struct({ |
| 458 | v: Schema.Literal(1), |
| 459 | kind: Schema.Literal("websocket"), |
| 460 | sid: AuthSessionId, |
| 461 | iat: Schema.Number, |
| 462 | exp: Schema.Number, |
| 463 | }); |
| 464 | type WebSocketClaims = typeof WebSocketClaims.Type; |
| 465 | |
| 466 | const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims)); |
| 467 | const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims)); |
| 468 | |
| 469 | function createDefaultClientMetadata(): AuthClientMetadata { |
| 470 | return { |
| 471 | deviceType: "unknown", |
| 472 | }; |
| 473 | } |
| 474 | |
| 475 | function toClientMetadata(record: { |
| 476 | readonly label: string | null; |
| 477 | readonly ipAddress: string | null; |
| 478 | readonly userAgent: string | null; |
| 479 | readonly deviceType: AuthClientMetadata["deviceType"]; |
| 480 | readonly os: string | null; |
| 481 | readonly browser: string | null; |
| 482 | }): AuthClientMetadata { |
| 483 | return { |
| 484 | ...(record.label ? { label: record.label } : {}), |
| 485 | ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}), |
| 486 | ...(record.userAgent ? { userAgent: record.userAgent } : {}), |
| 487 | deviceType: record.deviceType, |
| 488 | ...(record.os ? { os: record.os } : {}), |
| 489 | ...(record.browser ? { browser: record.browser } : {}), |
| 490 | }; |
| 491 | } |
| 492 | |
| 493 | function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession { |
| 494 | return { |
| 495 | ...input, |
| 496 | current: false, |
| 497 | }; |
| 498 | } |
| 499 | |
| 500 | export const make = Effect.gen(function* () { |
| 501 | const crypto = yield* Crypto.Crypto; |
| 502 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 503 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; |
| 504 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| 505 | const authSessions = yield* AuthSessions.AuthSessionRepository; |
| 506 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); |
| 507 | const connectedSessionsRef = yield* Ref.make(new Map<AuthSessionId, number>()); |
| 508 | const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); |
| 509 | const cookieInput = { |
| 510 | mode: serverConfig.mode, |
| 511 | port: serverConfig.port, |
| 512 | host: serverConfig.host, |
| 513 | instanceKey: serverConfig.stateDir, |
| 514 | environmentId: yield* serverEnvironment.getEnvironmentId, |
| 515 | development: serverConfig.devUrl !== undefined, |
| 516 | } as const; |
| 517 | const cookieName = resolveSessionCookieName(cookieInput); |
| 518 | const legacyCookieName = resolveLegacySessionCookieName(cookieInput); |
| 519 | const devAuth = resolveReusableDevAuth(serverConfig); |
| 520 | if (devAuth) { |
| 521 | yield* authSessions |
| 522 | .createIfAbsent({ |
| 523 | sessionId: devAuth.sessionId, |
| 524 | subject: "reusable-dev-token", |
| 525 | scopes: AuthAdministrativeScopes, |
| 526 | method: "browser-session-cookie", |
| 527 | client: { |
| 528 | label: "Reusable dev token", |
| 529 | ipAddress: null, |
| 530 | userAgent: null, |
| 531 | deviceType: "unknown", |
| 532 | os: null, |
| 533 | browser: null, |
| 534 | }, |
| 535 | issuedAt: yield* DateTime.now, |
| 536 | expiresAt: REUSABLE_DEV_SESSION_EXPIRES_AT, |
| 537 | }) |
| 538 | .pipe( |
| 539 | Effect.mapError( |
| 540 | (cause) => new SessionCredentialIssueError({ sessionId: devAuth.sessionId, cause }), |
| 541 | ), |
| 542 | ); |
| 543 | } |
| 544 | |
| 545 | const emitUpsert = (clientSession: AuthClientSession) => |
| 546 | PubSub.publish(changesPubSub, { |
| 547 | type: "clientUpserted", |
| 548 | clientSession, |
| 549 | }).pipe(Effect.asVoid); |
| 550 | |
| 551 | const emitRemoved = (sessionId: AuthSessionId) => |
| 552 | PubSub.publish(changesPubSub, { |
| 553 | type: "clientRemoved", |
| 554 | sessionId, |
| 555 | }).pipe(Effect.asVoid); |
| 556 | |
| 557 | const verifyDelegationParent = Effect.fn("SessionStore.verifyDelegationParent")(function* ( |
| 558 | parentSessionId: AuthSessionId, |
| 559 | observedAt: DateTime.Utc, |
| 560 | ) { |
| 561 | const parent = yield* authSessions |
| 562 | .getById({ sessionId: parentSessionId }) |
| 563 | .pipe( |
| 564 | Effect.mapError( |
| 565 | (cause) => new SessionCredentialVerificationError({ sessionId: parentSessionId, cause }), |
| 566 | ), |
| 567 | ); |
| 568 | if (Option.isNone(parent) || parent.value.parentSessionId !== undefined) { |
| 569 | return yield* new UnknownSessionTokenError({ sessionId: parentSessionId }); |
| 570 | } |
| 571 | if (parent.value.revokedAt !== null) { |
| 572 | return yield* new SessionTokenRevokedError({ |
| 573 | sessionId: parentSessionId, |
| 574 | revokedAt: parent.value.revokedAt, |
| 575 | }); |
| 576 | } |
| 577 | if (parent.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { |
| 578 | return yield* new SessionTokenExpiredError({ |
| 579 | sessionId: parentSessionId, |
| 580 | expiresAt: parent.value.expiresAt, |
| 581 | observedAt, |
| 582 | }); |
| 583 | } |
| 584 | return parent.value; |
| 585 | }); |
| 586 | |
| 587 | const verifyActive = Effect.fn("SessionStore.verifyActive")(function* (sessionId: AuthSessionId) { |
| 588 | const observedAt = yield* DateTime.now; |
| 589 | const row = yield* authSessions |
| 590 | .getById({ sessionId }) |
| 591 | .pipe( |
| 592 | Effect.mapError((cause) => new SessionCredentialVerificationError({ sessionId, cause })), |
| 593 | ); |
| 594 | if (Option.isNone(row)) return yield* new UnknownSessionTokenError({ sessionId }); |
| 595 | if (row.value.revokedAt !== null) |
| 596 | return yield* new SessionTokenRevokedError({ sessionId, revokedAt: row.value.revokedAt }); |
| 597 | if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) |
| 598 | return yield* new SessionTokenExpiredError({ |
| 599 | sessionId, |
| 600 | expiresAt: row.value.expiresAt, |
| 601 | observedAt, |
| 602 | }); |
| 603 | if (row.value.parentSessionId !== undefined) |
| 604 | yield* verifyDelegationParent(row.value.parentSessionId, observedAt); |
| 605 | }); |
| 606 | |
| 607 | const loadActiveSession = (sessionId: AuthSessionId) => |
| 608 | Effect.gen(function* () { |
| 609 | const row = yield* authSessions.getById({ sessionId }); |
| 610 | if (Option.isNone(row) || row.value.revokedAt !== null) { |
| 611 | return Option.none<AuthClientSession>(); |
| 612 | } |
| 613 | |
| 614 | const connectedSessions = yield* Ref.get(connectedSessionsRef); |
| 615 | const connected = connectedSessions.has(row.value.sessionId); |
| 616 | const now = yield* DateTime.now; |
| 617 | if (!connected && row.value.expiresAt.epochMilliseconds <= now.epochMilliseconds) { |
| 618 | return Option.none<AuthClientSession>(); |
| 619 | } |
| 620 | if (row.value.parentSessionId !== undefined) { |
| 621 | const parent = yield* verifyDelegationParent(row.value.parentSessionId, now).pipe( |
| 622 | Effect.catchIf(isSessionCredentialInvalidError, () => Effect.succeed(undefined)), |
| 623 | ); |
| 624 | if (parent === undefined) return Option.none<AuthClientSession>(); |
| 625 | } |
| 626 | return Option.some( |
| 627 | toAuthClientSession({ |
| 628 | sessionId: row.value.sessionId, |
| 629 | subject: row.value.subject, |
| 630 | scopes: row.value.scopes, |
| 631 | method: row.value.method, |
| 632 | client: toClientMetadata(row.value.client), |
| 633 | issuedAt: row.value.issuedAt, |
| 634 | expiresAt: row.value.expiresAt, |
| 635 | lastConnectedAt: row.value.lastConnectedAt, |
| 636 | connected, |
| 637 | }), |
| 638 | ); |
| 639 | }); |
| 640 | |
| 641 | const markConnected: SessionStore["Service"]["markConnected"] = (sessionId) => |
| 642 | Ref.modify(connectedSessionsRef, (current) => { |
| 643 | const next = new Map(current); |
| 644 | const wasDisconnected = !next.has(sessionId); |
| 645 | next.set(sessionId, (next.get(sessionId) ?? 0) + 1); |
| 646 | return [wasDisconnected, next] as const; |
| 647 | }).pipe( |
| 648 | Effect.flatMap((wasDisconnected) => |
| 649 | wasDisconnected |
| 650 | ? DateTime.now.pipe( |
| 651 | Effect.flatMap((lastConnectedAt) => |
| 652 | authSessions.setLastConnectedAt({ |
| 653 | sessionId, |
| 654 | lastConnectedAt, |
| 655 | }), |
| 656 | ), |
| 657 | ) |
| 658 | : Effect.void, |
| 659 | ), |
| 660 | Effect.flatMap(() => loadActiveSession(sessionId)), |
| 661 | Effect.flatMap((session) => |
| 662 | Option.isSome(session) ? emitUpsert(session.value) : Effect.void, |
| 663 | ), |
| 664 | Effect.catchCause((cause) => |
| 665 | Effect.logError("Failed to publish connected-session auth update.").pipe( |
| 666 | Effect.annotateLogs({ |
| 667 | sessionId, |
| 668 | cause, |
| 669 | }), |
| 670 | ), |
| 671 | ), |
| 672 | Effect.withSpan("SessionStore.markConnected"), |
| 673 | ); |
| 674 | |
| 675 | // Best-effort: connection metadata must never block or fail a connect. |
| 676 | const recordClientConnection: SessionStore["Service"]["recordClientConnection"] = ( |
| 677 | sessionId, |
| 678 | client, |
| 679 | ) => |
| 680 | client.surface === undefined && client.appVersion === undefined |
| 681 | ? Effect.void |
| 682 | : authSessions |
| 683 | .setClientConnection({ |
| 684 | sessionId, |
| 685 | surface: client.surface ?? null, |
| 686 | appVersion: client.appVersion ?? null, |
| 687 | }) |
| 688 | .pipe( |
| 689 | Effect.catchCause((cause) => |
| 690 | Effect.logWarning("Failed to record session client connection metadata.").pipe( |
| 691 | Effect.annotateLogs({ sessionId, cause }), |
| 692 | ), |
| 693 | ), |
| 694 | Effect.withSpan("SessionStore.recordClientConnection"), |
| 695 | ); |
| 696 | |
| 697 | const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => |
| 698 | Ref.update(connectedSessionsRef, (current) => { |
| 699 | const next = new Map(current); |
| 700 | const remaining = (next.get(sessionId) ?? 0) - 1; |
| 701 | if (remaining > 0) { |
| 702 | next.set(sessionId, remaining); |
| 703 | } else { |
| 704 | next.delete(sessionId); |
| 705 | } |
| 706 | return next; |
| 707 | }).pipe( |
| 708 | Effect.flatMap(() => loadActiveSession(sessionId)), |
| 709 | Effect.flatMap((session) => |
| 710 | Option.isSome(session) ? emitUpsert(session.value) : emitRemoved(sessionId), |
| 711 | ), |
| 712 | Effect.catchCause((cause) => |
| 713 | Effect.logError("Failed to publish disconnected-session auth update.").pipe( |
| 714 | Effect.annotateLogs({ |
| 715 | sessionId, |
| 716 | cause, |
| 717 | }), |
| 718 | ), |
| 719 | ), |
| 720 | Effect.withSpan("SessionStore.markDisconnected"), |
| 721 | ); |
| 722 | |
| 723 | const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); |
| 724 | const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")( |
| 725 | function* (input) { |
| 726 | const sessionId = AuthSessionId.make( |
| 727 | yield* crypto |
| 728 | .randomUUIDv4() |
| 729 | .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ cause }))), |
| 730 | ); |
| 731 | const issuedAt = yield* DateTime.now; |
| 732 | const requestedExpiresAt = DateTime.add(issuedAt, { |
| 733 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), |
| 734 | }); |
| 735 | const parent = |
| 736 | input?.parentSessionId === undefined |
| 737 | ? undefined |
| 738 | : yield* verifyDelegationParent(input.parentSessionId, issuedAt).pipe( |
| 739 | Effect.flatMap((parent) => |
| 740 | parent.scopes.includes(AuthAccessWriteScope) && |
| 741 | (input.scopes ?? AuthStandardClientScopes).every((scope) => |
| 742 | parent.scopes.includes(scope), |
| 743 | ) |
| 744 | ? Effect.succeed(parent) |
| 745 | : Effect.fail(new UnknownSessionTokenError({ sessionId: parent.sessionId })), |
| 746 | ), |
| 747 | Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause })), |
| 748 | ); |
| 749 | const expiresAt = |
| 750 | parent && parent.expiresAt.epochMilliseconds < requestedExpiresAt.epochMilliseconds |
| 751 | ? parent.expiresAt |
| 752 | : requestedExpiresAt; |
| 753 | const claims: SessionClaims = { |
| 754 | v: 1, |
| 755 | kind: "session", |
| 756 | sid: sessionId, |
| 757 | sub: input?.subject ?? "browser", |
| 758 | scopes: input?.scopes ?? AuthStandardClientScopes, |
| 759 | method: input?.method ?? "browser-session-cookie", |
| 760 | ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}), |
| 761 | ...(input?.runtimeModeCeiling ? { rtc: input.runtimeModeCeiling } : {}), |
| 762 | iat: issuedAt.epochMilliseconds, |
| 763 | exp: expiresAt.epochMilliseconds, |
| 764 | }; |
| 765 | |
| 766 | const encodedPayload = yield* encodeClaims(claims).pipe( |
| 767 | Effect.map(base64UrlEncode), |
| 768 | Effect.mapError( |
| 769 | (cause) => |
| 770 | new SessionCredentialIssueError({ |
| 771 | sessionId, |
| 772 | cause: new SessionClaimsEncodingError({ |
| 773 | sessionId, |
| 774 | operation: "encode_session_claims", |
| 775 | cause, |
| 776 | }), |
| 777 | }), |
| 778 | ), |
| 779 | ); |
| 780 | const signature = signPayload(encodedPayload, signingSecret); |
| 781 | const client = input?.client ?? createDefaultClientMetadata(); |
| 782 | const deviceId = input?.deviceId ?? parent?.deviceId; |
| 783 | const sessionRecord = { |
| 784 | sessionId, |
| 785 | ...(input?.parentSessionId === undefined ? {} : { parentSessionId: input.parentSessionId }), |
| 786 | ...(deviceId === undefined ? {} : { deviceId }), |
| 787 | subject: claims.sub, |
| 788 | scopes: claims.scopes, |
| 789 | method: claims.method, |
| 790 | client: { |
| 791 | label: client.label ?? null, |
| 792 | ipAddress: client.ipAddress ?? null, |
| 793 | userAgent: client.userAgent ?? null, |
| 794 | deviceType: client.deviceType, |
| 795 | os: client.os ?? null, |
| 796 | browser: client.browser ?? null, |
| 797 | }, |
| 798 | issuedAt, |
| 799 | expiresAt, |
| 800 | } satisfies AuthSessions.CreateAuthSessionInput; |
| 801 | const replacedSessionIds = yield* ( |
| 802 | input?.replaceSessionId !== undefined || input?.replaceActiveForSubjectAndMethod |
| 803 | ? authSessions.createReplacingActive({ |
| 804 | session: sessionRecord, |
| 805 | revokedAt: issuedAt, |
| 806 | ...(input.replaceSessionId !== undefined |
| 807 | ? { replaceSessionId: input.replaceSessionId } |
| 808 | : {}), |
| 809 | }) |
| 810 | : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray<AuthSessionId>)) |
| 811 | ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); |
| 812 | if (replacedSessionIds.length > 0) { |
| 813 | yield* Ref.update(connectedSessionsRef, (current) => { |
| 814 | const next = new Map(current); |
| 815 | for (const replacedSessionId of replacedSessionIds) { |
| 816 | next.delete(replacedSessionId); |
| 817 | } |
| 818 | return next; |
| 819 | }); |
| 820 | yield* Effect.forEach(replacedSessionIds, emitRemoved, { |
| 821 | concurrency: "unbounded", |
| 822 | discard: true, |
| 823 | }); |
| 824 | } |
| 825 | yield* emitUpsert( |
| 826 | toAuthClientSession({ |
| 827 | sessionId, |
| 828 | subject: claims.sub, |
| 829 | scopes: claims.scopes, |
| 830 | method: claims.method, |
| 831 | client, |
| 832 | issuedAt, |
| 833 | expiresAt, |
| 834 | lastConnectedAt: null, |
| 835 | connected: false, |
| 836 | }), |
| 837 | ); |
| 838 | |
| 839 | return { |
| 840 | sessionId, |
| 841 | token: `${encodedPayload}.${signature}`, |
| 842 | method: claims.method, |
| 843 | client, |
| 844 | expiresAt: expiresAt, |
| 845 | scopes: claims.scopes, |
| 846 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), |
| 847 | } satisfies IssuedSession; |
| 848 | }, |
| 849 | ); |
| 850 | |
| 851 | const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( |
| 852 | function* (token) { |
| 853 | if (devAuth?.matches(token)) { |
| 854 | const row = yield* authSessions |
| 855 | .getById({ sessionId: devAuth.sessionId }) |
| 856 | .pipe( |
| 857 | Effect.mapError( |
| 858 | (cause) => |
| 859 | new SessionCredentialVerificationError({ sessionId: devAuth.sessionId, cause }), |
| 860 | ), |
| 861 | ); |
| 862 | if (Option.isNone(row)) { |
| 863 | return yield* new UnknownSessionTokenError({ sessionId: devAuth.sessionId }); |
| 864 | } |
| 865 | if (row.value.revokedAt !== null) { |
| 866 | return yield* new SessionTokenRevokedError({ |
| 867 | sessionId: devAuth.sessionId, |
| 868 | revokedAt: row.value.revokedAt, |
| 869 | }); |
| 870 | } |
| 871 | const observedAt = yield* DateTime.now; |
| 872 | if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { |
| 873 | return yield* new SessionTokenExpiredError({ |
| 874 | sessionId: devAuth.sessionId, |
| 875 | expiresAt: row.value.expiresAt, |
| 876 | observedAt, |
| 877 | }); |
| 878 | } |
| 879 | return { |
| 880 | sessionId: row.value.sessionId, |
| 881 | token, |
| 882 | method: row.value.method, |
| 883 | client: toClientMetadata(row.value.client), |
| 884 | expiresAt: row.value.expiresAt, |
| 885 | subject: row.value.subject, |
| 886 | scopes: row.value.scopes, |
| 887 | } satisfies VerifiedSession; |
| 888 | } |
| 889 | const [encodedPayload, signature] = token.split("."); |
| 890 | if (!encodedPayload || !signature) { |
| 891 | return yield* new MalformedSessionTokenError({}); |
| 892 | } |
| 893 | |
| 894 | const expectedSignature = signPayload(encodedPayload, signingSecret); |
| 895 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { |
| 896 | return yield* new InvalidSessionTokenSignatureError({}); |
| 897 | } |
| 898 | |
| 899 | const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( |
| 900 | Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })), |
| 901 | ); |
| 902 | |
| 903 | const observedAt = yield* DateTime.now; |
| 904 | const expiresAt = DateTime.make(claims.exp); |
| 905 | if (Option.isNone(expiresAt)) { |
| 906 | return yield* new InvalidSessionExpirationClaimError({ |
| 907 | sessionId: claims.sid, |
| 908 | expirationClaim: claims.exp, |
| 909 | }); |
| 910 | } |
| 911 | if (claims.exp <= observedAt.epochMilliseconds) { |
| 912 | return yield* new SessionTokenExpiredError({ |
| 913 | sessionId: claims.sid, |
| 914 | expiresAt: expiresAt.value, |
| 915 | observedAt, |
| 916 | }); |
| 917 | } |
| 918 | |
| 919 | const row = yield* authSessions |
| 920 | .getById({ sessionId: claims.sid }) |
| 921 | .pipe( |
| 922 | Effect.mapError( |
| 923 | (cause) => new SessionCredentialVerificationError({ sessionId: claims.sid, cause }), |
| 924 | ), |
| 925 | ); |
| 926 | if (Option.isNone(row)) { |
| 927 | return yield* new UnknownSessionTokenError({ sessionId: claims.sid }); |
| 928 | } |
| 929 | if (row.value.revokedAt !== null) { |
| 930 | return yield* new SessionTokenRevokedError({ |
| 931 | sessionId: claims.sid, |
| 932 | revokedAt: row.value.revokedAt, |
| 933 | }); |
| 934 | } |
| 935 | |
| 936 | if (row.value.parentSessionId !== undefined) { |
| 937 | yield* verifyDelegationParent(row.value.parentSessionId, observedAt); |
| 938 | } |
| 939 | return { |
| 940 | sessionId: claims.sid, |
| 941 | ...(row.value.parentSessionId === undefined |
| 942 | ? {} |
| 943 | : { parentSessionId: row.value.parentSessionId }), |
| 944 | ...(row.value.deviceId === undefined ? {} : { deviceId: row.value.deviceId }), |
| 945 | token, |
| 946 | method: claims.method, |
| 947 | client: toClientMetadata(row.value.client), |
| 948 | expiresAt: expiresAt.value, |
| 949 | subject: claims.sub, |
| 950 | scopes: claims.scopes, |
| 951 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), |
| 952 | ...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}), |
| 953 | } satisfies VerifiedSession; |
| 954 | }, |
| 955 | ); |
| 956 | |
| 957 | const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); |
| 958 | const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn( |
| 959 | "SessionStore.issueWebSocketToken", |
| 960 | )(function* (sessionId, input) { |
| 961 | const issuedAt = yield* DateTime.now; |
| 962 | const expiresAt = DateTime.add(issuedAt, { |
| 963 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), |
| 964 | }); |
| 965 | const claims: WebSocketClaims = { |
| 966 | v: 1, |
| 967 | kind: "websocket", |
| 968 | sid: sessionId, |
| 969 | iat: issuedAt.epochMilliseconds, |
| 970 | exp: expiresAt.epochMilliseconds, |
| 971 | }; |
| 972 | const encodedPayload = yield* encodeWsClaims(claims).pipe( |
| 973 | Effect.map(base64UrlEncode), |
| 974 | Effect.mapError( |
| 975 | (cause) => |
| 976 | new WebSocketTokenIssueError({ |
| 977 | sessionId, |
| 978 | cause: new SessionClaimsEncodingError({ |
| 979 | sessionId, |
| 980 | operation: "encode_websocket_claims", |
| 981 | cause, |
| 982 | }), |
| 983 | }), |
| 984 | ), |
| 985 | ); |
| 986 | const signature = signPayload(encodedPayload, signingSecret); |
| 987 | return { |
| 988 | token: `${encodedPayload}.${signature}`, |
| 989 | expiresAt, |
| 990 | }; |
| 991 | }); |
| 992 | |
| 993 | const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn( |
| 994 | "SessionStore.verifyWebSocketToken", |
| 995 | )(function* (token) { |
| 996 | const [encodedPayload, signature] = token.split("."); |
| 997 | if (!encodedPayload || !signature) { |
| 998 | return yield* new MalformedWebSocketTokenError({}); |
| 999 | } |
| 1000 | |
| 1001 | const expectedSignature = signPayload(encodedPayload, signingSecret); |
| 1002 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { |
| 1003 | return yield* new InvalidWebSocketTokenSignatureError({}); |
| 1004 | } |
| 1005 | |
| 1006 | const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( |
| 1007 | Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), |
| 1008 | ); |
| 1009 | if (claims.sid.startsWith(REUSABLE_DEV_SESSION_PREFIX) && claims.sid !== devAuth?.sessionId) { |
| 1010 | return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid }); |
| 1011 | } |
| 1012 | |
| 1013 | const observedAt = yield* DateTime.now; |
| 1014 | const expiresAt = DateTime.make(claims.exp); |
| 1015 | if (Option.isNone(expiresAt)) { |
| 1016 | return yield* new InvalidSessionExpirationClaimError({ |
| 1017 | sessionId: claims.sid, |
| 1018 | expirationClaim: claims.exp, |
| 1019 | }); |
| 1020 | } |
| 1021 | if (claims.exp <= observedAt.epochMilliseconds) { |
| 1022 | return yield* new WebSocketTokenExpiredError({ |
| 1023 | sessionId: claims.sid, |
| 1024 | expiresAt: expiresAt.value, |
| 1025 | observedAt, |
| 1026 | }); |
| 1027 | } |
| 1028 | |
| 1029 | const row = yield* authSessions |
| 1030 | .getById({ sessionId: claims.sid }) |
| 1031 | .pipe( |
| 1032 | Effect.mapError( |
| 1033 | (cause) => new WebSocketTokenVerificationError({ sessionId: claims.sid, cause }), |
| 1034 | ), |
| 1035 | ); |
| 1036 | if (Option.isNone(row)) { |
| 1037 | return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid }); |
| 1038 | } |
| 1039 | if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { |
| 1040 | return yield* new WebSocketSessionExpiredError({ |
| 1041 | sessionId: claims.sid, |
| 1042 | expiresAt: row.value.expiresAt, |
| 1043 | observedAt, |
| 1044 | }); |
| 1045 | } |
| 1046 | if (row.value.revokedAt !== null) { |
| 1047 | return yield* new WebSocketSessionRevokedError({ |
| 1048 | sessionId: claims.sid, |
| 1049 | revokedAt: row.value.revokedAt, |
| 1050 | }); |
| 1051 | } |
| 1052 | |
| 1053 | if (row.value.parentSessionId !== undefined) { |
| 1054 | yield* verifyDelegationParent(row.value.parentSessionId, observedAt); |
| 1055 | } |
| 1056 | return { |
| 1057 | sessionId: row.value.sessionId, |
| 1058 | ...(row.value.parentSessionId === undefined |
| 1059 | ? {} |
| 1060 | : { parentSessionId: row.value.parentSessionId }), |
| 1061 | ...(row.value.deviceId === undefined ? {} : { deviceId: row.value.deviceId }), |
| 1062 | token, |
| 1063 | method: row.value.method, |
| 1064 | client: toClientMetadata(row.value.client), |
| 1065 | expiresAt: row.value.expiresAt, |
| 1066 | subject: row.value.subject, |
| 1067 | scopes: row.value.scopes, |
| 1068 | } satisfies VerifiedSession; |
| 1069 | }); |
| 1070 | |
| 1071 | const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")( |
| 1072 | function* () { |
| 1073 | const now = yield* DateTime.now; |
| 1074 | const connectedSessions = yield* Ref.get(connectedSessionsRef); |
| 1075 | const rows = yield* authSessions.listActive({ |
| 1076 | now, |
| 1077 | connectedSessionIds: Array.from(connectedSessions.keys()), |
| 1078 | }); |
| 1079 | |
| 1080 | return rows.map((row) => |
| 1081 | toAuthClientSession({ |
| 1082 | sessionId: row.sessionId, |
| 1083 | subject: row.subject, |
| 1084 | scopes: row.scopes, |
| 1085 | method: row.method, |
| 1086 | client: toClientMetadata(row.client), |
| 1087 | issuedAt: row.issuedAt, |
| 1088 | expiresAt: row.expiresAt, |
| 1089 | lastConnectedAt: row.lastConnectedAt, |
| 1090 | connected: connectedSessions.has(row.sessionId), |
| 1091 | }), |
| 1092 | ); |
| 1093 | }, |
| 1094 | Effect.mapError((cause) => new ActiveSessionsListError({ cause })), |
| 1095 | ); |
| 1096 | |
| 1097 | const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( |
| 1098 | function* (sessionId) { |
| 1099 | const revokedAt = yield* DateTime.now; |
| 1100 | const revokedSessionIds = yield* authSessions |
| 1101 | .revoke({ |
| 1102 | sessionId, |
| 1103 | revokedAt, |
| 1104 | }) |
| 1105 | .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); |
| 1106 | if (revokedSessionIds.length > 0) { |
| 1107 | yield* Ref.update(connectedSessionsRef, (current) => { |
| 1108 | const next = new Map(current); |
| 1109 | for (const revokedSessionId of revokedSessionIds) next.delete(revokedSessionId); |
| 1110 | return next; |
| 1111 | }); |
| 1112 | yield* Effect.forEach(revokedSessionIds, emitRemoved, { |
| 1113 | concurrency: "unbounded", |
| 1114 | discard: true, |
| 1115 | }); |
| 1116 | } |
| 1117 | return revokedSessionIds.length > 0; |
| 1118 | }, |
| 1119 | ); |
| 1120 | |
| 1121 | const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( |
| 1122 | "SessionStore.revokeAllExcept", |
| 1123 | )(function* (sessionId) { |
| 1124 | const revokedAt = yield* DateTime.now; |
| 1125 | const revokedSessionIds = yield* authSessions |
| 1126 | .revokeAllExcept({ |
| 1127 | currentSessionId: sessionId, |
| 1128 | revokedAt, |
| 1129 | }) |
| 1130 | .pipe( |
| 1131 | Effect.mapError( |
| 1132 | (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }), |
| 1133 | ), |
| 1134 | ); |
| 1135 | if (revokedSessionIds.length > 0) { |
| 1136 | yield* Ref.update(connectedSessionsRef, (current) => { |
| 1137 | const next = new Map(current); |
| 1138 | for (const revokedSessionId of revokedSessionIds) { |
| 1139 | next.delete(revokedSessionId); |
| 1140 | } |
| 1141 | return next; |
| 1142 | }); |
| 1143 | yield* Effect.forEach( |
| 1144 | revokedSessionIds, |
| 1145 | (revokedSessionId) => emitRemoved(revokedSessionId), |
| 1146 | { |
| 1147 | concurrency: "unbounded", |
| 1148 | discard: true, |
| 1149 | }, |
| 1150 | ); |
| 1151 | } |
| 1152 | return revokedSessionIds.length; |
| 1153 | }); |
| 1154 | |
| 1155 | const awaitInvalidation = Effect.fn(function* (sessionId: AuthSessionId) { |
| 1156 | return yield* Effect.scoped( |
| 1157 | Effect.gen(function* () { |
| 1158 | // Subscribe before reading: revocation can race with the WebSocket upgrade. |
| 1159 | const subscription = yield* PubSub.subscribe(changesPubSub); |
| 1160 | while (true) { |
| 1161 | const row = yield* authSessions |
| 1162 | .getById({ sessionId }) |
| 1163 | .pipe( |
| 1164 | Effect.mapError( |
| 1165 | (cause) => new SessionCredentialVerificationError({ sessionId, cause }), |
| 1166 | ), |
| 1167 | ); |
| 1168 | if (Option.isNone(row) || row.value.revokedAt !== null) return; |
| 1169 | const now = yield* DateTime.now; |
| 1170 | const parent = |
| 1171 | row.value.parentSessionId === undefined |
| 1172 | ? undefined |
| 1173 | : yield* authSessions |
| 1174 | .getById({ sessionId: row.value.parentSessionId }) |
| 1175 | .pipe( |
| 1176 | Effect.mapError( |
| 1177 | (cause) => new SessionCredentialVerificationError({ sessionId, cause }), |
| 1178 | ), |
| 1179 | ); |
| 1180 | if (parent !== undefined && (Option.isNone(parent) || parent.value.revokedAt !== null)) |
| 1181 | return; |
| 1182 | const expiresAt = |
| 1183 | parent !== undefined && Option.isSome(parent) |
| 1184 | ? Math.min( |
| 1185 | row.value.expiresAt.epochMilliseconds, |
| 1186 | parent.value.expiresAt.epochMilliseconds, |
| 1187 | ) |
| 1188 | : row.value.expiresAt.epochMilliseconds; |
| 1189 | const remaining = expiresAt - now.epochMilliseconds; |
| 1190 | if (remaining <= 0) return; |
| 1191 | const removed = Stream.fromSubscription(subscription).pipe( |
| 1192 | Stream.filter( |
| 1193 | (change) => |
| 1194 | change.type === "clientRemoved" && |
| 1195 | (change.sessionId === sessionId || change.sessionId === row.value.parentSessionId), |
| 1196 | ), |
| 1197 | Stream.runHead, |
| 1198 | Effect.as(true), |
| 1199 | ); |
| 1200 | // CLI revocation writes the same database from another process. |
| 1201 | const recheck = Effect.sleep(Duration.millis(Math.min(remaining, 5_000))).pipe( |
| 1202 | Effect.as(false), |
| 1203 | ); |
| 1204 | if (yield* Effect.raceFirst(removed, recheck)) return; |
| 1205 | } |
| 1206 | }), |
| 1207 | ); |
| 1208 | }); |
| 1209 | |
| 1210 | return SessionStore.of({ |
| 1211 | cookieName, |
| 1212 | legacyCookieName, |
| 1213 | issue, |
| 1214 | verify, |
| 1215 | verifyActive, |
| 1216 | issueWebSocketToken, |
| 1217 | verifyWebSocketToken, |
| 1218 | listActive, |
| 1219 | awaitInvalidation, |
| 1220 | get streamChanges() { |
| 1221 | return Stream.fromPubSub(changesPubSub); |
| 1222 | }, |
| 1223 | revoke, |
| 1224 | revokeAllExcept, |
| 1225 | markConnected, |
| 1226 | markDisconnected, |
| 1227 | recordClientConnection, |
| 1228 | }); |
| 1229 | }); |
| 1230 | |
| 1231 | export const layer = Layer.effect(SessionStore, make).pipe(Layer.provideMerge(AuthSessions.layer)); |