| ... | @@ -7,7 +7,6 @@ import { | ... | @@ -7,7 +7,6 @@ import { |
| 7 | type AuthEnvironmentScope, | 7 | type AuthEnvironmentScope, |
| 8 | type ServerAuthSessionMethod, | 8 | type ServerAuthSessionMethod, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import * as Clock from "effect/Clock"; | | |
| 11 | import * as Context from "effect/Context"; | 10 | import * as Context from "effect/Context"; |
| 12 | import * as Crypto from "effect/Crypto"; | 11 | import * as Crypto from "effect/Crypto"; |
| 13 | import * as DateTime from "effect/DateTime"; | 12 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -93,7 +92,11 @@ export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass<Inv | ... | @@ -93,7 +92,11 @@ export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass<Inv |
| 93 | | 92 | |
| 94 | export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTokenExpiredError>()( | 93 | export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTokenExpiredError>()( |
| 95 | "SessionTokenExpiredError", | 94 | "SessionTokenExpiredError", |
| 96 | {}, | 95 | { |
| | 96 | sessionId: AuthSessionId, |
| | 97 | expiresAt: Schema.DateTimeUtc, |
| | 98 | observedAt: Schema.DateTimeUtc, |
| | 99 | }, |
| 97 | ) { | 100 | ) { |
| 98 | override get message(): string { | 101 | override get message(): string { |
| 99 | return "Session token expired."; | 102 | return "Session token expired."; |
| ... | @@ -102,7 +105,9 @@ export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTok | ... | @@ -102,7 +105,9 @@ export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTok |
| 102 | | 105 | |
| 103 | export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSessionTokenError>()( | 106 | export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSessionTokenError>()( |
| 104 | "UnknownSessionTokenError", | 107 | "UnknownSessionTokenError", |
| 105 | {}, | 108 | { |
| | 109 | sessionId: AuthSessionId, |
| | 110 | }, |
| 106 | ) { | 111 | ) { |
| 107 | override get message(): string { | 112 | override get message(): string { |
| 108 | return "Unknown session token."; | 113 | return "Unknown session token."; |
| ... | @@ -111,7 +116,10 @@ export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSes | ... | @@ -111,7 +116,10 @@ export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSes |
| 111 | | 116 | |
| 112 | export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTokenRevokedError>()( | 117 | export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTokenRevokedError>()( |
| 113 | "SessionTokenRevokedError", | 118 | "SessionTokenRevokedError", |
| 114 | {}, | 119 | { |
| | 120 | sessionId: AuthSessionId, |
| | 121 | revokedAt: Schema.DateTimeUtc, |
| | 122 | }, |
| 115 | ) { | 123 | ) { |
| 116 | override get message(): string { | 124 | override get message(): string { |
| 117 | return "Session token revoked."; | 125 | return "Session token revoked."; |
| ... | @@ -120,7 +128,10 @@ export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTok | ... | @@ -120,7 +128,10 @@ export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTok |
| 120 | | 128 | |
| 121 | export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass<InvalidSessionExpirationClaimError>()( | 129 | export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass<InvalidSessionExpirationClaimError>()( |
| 122 | "InvalidSessionExpirationClaimError", | 130 | "InvalidSessionExpirationClaimError", |
| 123 | {}, | 131 | { |
| | 132 | sessionId: AuthSessionId, |
| | 133 | expirationClaim: Schema.Number, |
| | 134 | }, |
| 124 | ) { | 135 | ) { |
| 125 | override get message(): string { | 136 | override get message(): string { |
| 126 | return "Invalid `exp` claim"; | 137 | return "Invalid `exp` claim"; |
| ... | @@ -158,7 +169,11 @@ export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass<I | ... | @@ -158,7 +169,11 @@ export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass<I |
| 158 | | 169 | |
| 159 | export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocketTokenExpiredError>()( | 170 | export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocketTokenExpiredError>()( |
| 160 | "WebSocketTokenExpiredError", | 171 | "WebSocketTokenExpiredError", |
| 161 | {}, | 172 | { |
| | 173 | sessionId: AuthSessionId, |
| | 174 | expiresAt: Schema.DateTimeUtc, |
| | 175 | observedAt: Schema.DateTimeUtc, |
| | 176 | }, |
| 162 | ) { | 177 | ) { |
| 163 | override get message(): string { | 178 | override get message(): string { |
| 164 | return "Websocket token expired."; | 179 | return "Websocket token expired."; |
| ... | @@ -167,7 +182,9 @@ export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocke | ... | @@ -167,7 +182,9 @@ export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocke |
| 167 | | 182 | |
| 168 | export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<UnknownWebSocketSessionError>()( | 183 | export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<UnknownWebSocketSessionError>()( |
| 169 | "UnknownWebSocketSessionError", | 184 | "UnknownWebSocketSessionError", |
| 170 | {}, | 185 | { |
| | 186 | sessionId: AuthSessionId, |
| | 187 | }, |
| 171 | ) { | 188 | ) { |
| 172 | override get message(): string { | 189 | override get message(): string { |
| 173 | return "Unknown websocket session."; | 190 | return "Unknown websocket session."; |
| ... | @@ -176,7 +193,11 @@ export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<Unknow | ... | @@ -176,7 +193,11 @@ export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<Unknow |
| 176 | | 193 | |
| 177 | export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSocketSessionExpiredError>()( | 194 | export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSocketSessionExpiredError>()( |
| 178 | "WebSocketSessionExpiredError", | 195 | "WebSocketSessionExpiredError", |
| 179 | {}, | 196 | { |
| | 197 | sessionId: AuthSessionId, |
| | 198 | expiresAt: Schema.DateTimeUtc, |
| | 199 | observedAt: Schema.DateTimeUtc, |
| | 200 | }, |
| 180 | ) { | 201 | ) { |
| 181 | override get message(): string { | 202 | override get message(): string { |
| 182 | return "Websocket session expired."; | 203 | return "Websocket session expired."; |
| ... | @@ -185,7 +206,10 @@ export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSoc | ... | @@ -185,7 +206,10 @@ export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSoc |
| 185 | | 206 | |
| 186 | export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass<WebSocketSessionRevokedError>()( | 207 | export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass<WebSocketSessionRevokedError>()( |
| 187 | "WebSocketSessionRevokedError", | 208 | "WebSocketSessionRevokedError", |
| 188 | {}, | 209 | { |
| | 210 | sessionId: AuthSessionId, |
| | 211 | revokedAt: Schema.DateTimeUtc, |
| | 212 | }, |
| 189 | ) { | 213 | ) { |
| 190 | override get message(): string { | 214 | override get message(): string { |
| 191 | return "Websocket session revoked."; | 215 | return "Websocket session revoked."; |
| ... | @@ -218,6 +242,7 @@ const sessionCredentialInternalErrorContext = { | ... | @@ -218,6 +242,7 @@ const sessionCredentialInternalErrorContext = { |
| 218 | export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionClaimsEncodingError>()( | 242 | export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionClaimsEncodingError>()( |
| 219 | "SessionClaimsEncodingError", | 243 | "SessionClaimsEncodingError", |
| 220 | { | 244 | { |
| | 245 | sessionId: AuthSessionId, |
| 221 | operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]), | 246 | operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]), |
| 222 | ...sessionCredentialInternalErrorContext, | 247 | ...sessionCredentialInternalErrorContext, |
| 223 | }, | 248 | }, |
| ... | @@ -230,6 +255,7 @@ export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionC | ... | @@ -230,6 +255,7 @@ export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionC |
| 230 | export class SessionCredentialIssueError extends Schema.TaggedErrorClass<SessionCredentialIssueError>()( | 255 | export class SessionCredentialIssueError extends Schema.TaggedErrorClass<SessionCredentialIssueError>()( |
| 231 | "SessionCredentialIssueError", | 256 | "SessionCredentialIssueError", |
| 232 | { | 257 | { |
| | 258 | sessionId: Schema.optional(AuthSessionId), |
| 233 | ...sessionCredentialInternalErrorContext, | 259 | ...sessionCredentialInternalErrorContext, |
| 234 | }, | 260 | }, |
| 235 | ) { | 261 | ) { |
| ... | @@ -241,6 +267,7 @@ export class SessionCredentialIssueError extends Schema.TaggedErrorClass<Session | ... | @@ -241,6 +267,7 @@ export class SessionCredentialIssueError extends Schema.TaggedErrorClass<Session |
| 241 | export class SessionCredentialVerificationError extends Schema.TaggedErrorClass<SessionCredentialVerificationError>()( | 267 | export class SessionCredentialVerificationError extends Schema.TaggedErrorClass<SessionCredentialVerificationError>()( |
| 242 | "SessionCredentialVerificationError", | 268 | "SessionCredentialVerificationError", |
| 243 | { | 269 | { |
| | 270 | sessionId: AuthSessionId, |
| 244 | ...sessionCredentialInternalErrorContext, | 271 | ...sessionCredentialInternalErrorContext, |
| 245 | }, | 272 | }, |
| 246 | ) { | 273 | ) { |
| ... | @@ -252,6 +279,7 @@ export class SessionCredentialVerificationError extends Schema.TaggedErrorClass< | ... | @@ -252,6 +279,7 @@ export class SessionCredentialVerificationError extends Schema.TaggedErrorClass< |
| 252 | export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketTokenIssueError>()( | 279 | export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketTokenIssueError>()( |
| 253 | "WebSocketTokenIssueError", | 280 | "WebSocketTokenIssueError", |
| 254 | { | 281 | { |
| | 282 | sessionId: AuthSessionId, |
| 255 | ...sessionCredentialInternalErrorContext, | 283 | ...sessionCredentialInternalErrorContext, |
| 256 | }, | 284 | }, |
| 257 | ) { | 285 | ) { |
| ... | @@ -263,6 +291,7 @@ export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketT | ... | @@ -263,6 +291,7 @@ export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketT |
| 263 | export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass<WebSocketTokenVerificationError>()( | 291 | export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass<WebSocketTokenVerificationError>()( |
| 264 | "WebSocketTokenVerificationError", | 292 | "WebSocketTokenVerificationError", |
| 265 | { | 293 | { |
| | 294 | sessionId: AuthSessionId, |
| 266 | ...sessionCredentialInternalErrorContext, | 295 | ...sessionCredentialInternalErrorContext, |
| 267 | }, | 296 | }, |
| 268 | ) { | 297 | ) { |
| ... | @@ -285,6 +314,7 @@ export class ActiveSessionsListError extends Schema.TaggedErrorClass<ActiveSessi | ... | @@ -285,6 +314,7 @@ export class ActiveSessionsListError extends Schema.TaggedErrorClass<ActiveSessi |
| 285 | export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevocationError>()( | 314 | export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevocationError>()( |
| 286 | "SessionRevocationError", | 315 | "SessionRevocationError", |
| 287 | { | 316 | { |
| | 317 | sessionId: AuthSessionId, |
| 288 | ...sessionCredentialInternalErrorContext, | 318 | ...sessionCredentialInternalErrorContext, |
| 289 | }, | 319 | }, |
| 290 | ) { | 320 | ) { |
| ... | @@ -296,6 +326,7 @@ export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevoc | ... | @@ -296,6 +326,7 @@ export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevoc |
| 296 | export class OtherSessionsRevocationError extends Schema.TaggedErrorClass<OtherSessionsRevocationError>()( | 326 | export class OtherSessionsRevocationError extends Schema.TaggedErrorClass<OtherSessionsRevocationError>()( |
| 297 | "OtherSessionsRevocationError", | 327 | "OtherSessionsRevocationError", |
| 298 | { | 328 | { |
| | 329 | currentSessionId: AuthSessionId, |
| 299 | ...sessionCredentialInternalErrorContext, | 330 | ...sessionCredentialInternalErrorContext, |
| 300 | }, | 331 | }, |
| 301 | ) { | 332 | ) { |
| ... | @@ -539,7 +570,11 @@ export const make = Effect.gen(function* () { | ... | @@ -539,7 +570,11 @@ export const make = Effect.gen(function* () { |
| 539 | const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); | 570 | const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); |
| 540 | const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")( | 571 | const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")( |
| 541 | function* (input) { | 572 | function* (input) { |
| 542 | const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); | 573 | const sessionId = AuthSessionId.make( |
| | 574 | yield* crypto.randomUUIDv4.pipe( |
| | 575 | Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), |
| | 576 | ), |
| | 577 | ); |
| 543 | const issuedAt = yield* DateTime.now; | 578 | const issuedAt = yield* DateTime.now; |
| 544 | const expiresAt = DateTime.add(issuedAt, { | 579 | const expiresAt = DateTime.add(issuedAt, { |
| 545 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), | 580 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), |
| ... | @@ -559,27 +594,37 @@ export const make = Effect.gen(function* () { | ... | @@ -559,27 +594,37 @@ export const make = Effect.gen(function* () { |
| 559 | const encodedPayload = yield* encodeClaims(claims).pipe( | 594 | const encodedPayload = yield* encodeClaims(claims).pipe( |
| 560 | Effect.map(base64UrlEncode), | 595 | Effect.map(base64UrlEncode), |
| 561 | Effect.mapError( | 596 | Effect.mapError( |
| 562 | (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }), | 597 | (cause) => |
| | 598 | new SessionCredentialIssueError({ |
| | 599 | sessionId, |
| | 600 | cause: new SessionClaimsEncodingError({ |
| | 601 | sessionId, |
| | 602 | operation: "encode_session_claims", |
| | 603 | cause, |
| | 604 | }), |
| | 605 | }), |
| 563 | ), | 606 | ), |
| 564 | ); | 607 | ); |
| 565 | const signature = signPayload(encodedPayload, signingSecret); | 608 | const signature = signPayload(encodedPayload, signingSecret); |
| 566 | const client = input?.client ?? createDefaultClientMetadata(); | 609 | const client = input?.client ?? createDefaultClientMetadata(); |
| 567 | yield* authSessions.create({ | 610 | yield* authSessions |
| 568 | sessionId, | 611 | .create({ |
| 569 | subject: claims.sub, | 612 | sessionId, |
| 570 | scopes: claims.scopes, | 613 | subject: claims.sub, |
| 571 | method: claims.method, | 614 | scopes: claims.scopes, |
| 572 | client: { | 615 | method: claims.method, |
| 573 | label: client.label ?? null, | 616 | client: { |
| 574 | ipAddress: client.ipAddress ?? null, | 617 | label: client.label ?? null, |
| 575 | userAgent: client.userAgent ?? null, | 618 | ipAddress: client.ipAddress ?? null, |
| 576 | deviceType: client.deviceType, | 619 | userAgent: client.userAgent ?? null, |
| 577 | os: client.os ?? null, | 620 | deviceType: client.deviceType, |
| 578 | browser: client.browser ?? null, | 621 | os: client.os ?? null, |
| 579 | }, | 622 | browser: client.browser ?? null, |
| 580 | issuedAt, | 623 | }, |
| 581 | expiresAt, | 624 | issuedAt, |
| 582 | }); | 625 | expiresAt, |
| | 626 | }) |
| | 627 | .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); |
| 583 | yield* emitUpsert( | 628 | yield* emitUpsert( |
| 584 | toAuthClientSession({ | 629 | toAuthClientSession({ |
| 585 | sessionId, | 630 | sessionId, |
| ... | @@ -604,7 +649,6 @@ export const make = Effect.gen(function* () { | ... | @@ -604,7 +649,6 @@ export const make = Effect.gen(function* () { |
| 604 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), | 649 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), |
| 605 | } satisfies IssuedSession; | 650 | } satisfies IssuedSession; |
| 606 | }, | 651 | }, |
| 607 | Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), | | |
| 608 | ); | 652 | ); |
| 609 | | 653 | |
| 610 | const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( | 654 | const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( |
| ... | @@ -623,22 +667,37 @@ export const make = Effect.gen(function* () { | ... | @@ -623,22 +667,37 @@ export const make = Effect.gen(function* () { |
| 623 | Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })), | 667 | Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })), |
| 624 | ); | 668 | ); |
| 625 | | 669 | |
| 626 | const now = yield* Clock.currentTimeMillis; | 670 | const observedAt = yield* DateTime.now; |
| 627 | if (claims.exp <= now) { | 671 | const expiresAt = DateTime.make(claims.exp); |
| 628 | return yield* new SessionTokenExpiredError({}); | 672 | if (Option.isNone(expiresAt)) { |
| | 673 | return yield* new InvalidSessionExpirationClaimError({ |
| | 674 | sessionId: claims.sid, |
| | 675 | expirationClaim: claims.exp, |
| | 676 | }); |
| | 677 | } |
| | 678 | if (claims.exp <= observedAt.epochMilliseconds) { |
| | 679 | return yield* new SessionTokenExpiredError({ |
| | 680 | sessionId: claims.sid, |
| | 681 | expiresAt: expiresAt.value, |
| | 682 | observedAt, |
| | 683 | }); |
| 629 | } | 684 | } |
| 630 | | 685 | |
| 631 | const row = yield* authSessions.getById({ sessionId: claims.sid }); | 686 | const row = yield* authSessions |
| | 687 | .getById({ sessionId: claims.sid }) |
| | 688 | .pipe( |
| | 689 | Effect.mapError( |
| | 690 | (cause) => new SessionCredentialVerificationError({ sessionId: claims.sid, cause }), |
| | 691 | ), |
| | 692 | ); |
| 632 | if (Option.isNone(row)) { | 693 | if (Option.isNone(row)) { |
| 633 | return yield* new UnknownSessionTokenError({}); | 694 | return yield* new UnknownSessionTokenError({ sessionId: claims.sid }); |
| 634 | } | 695 | } |
| 635 | if (row.value.revokedAt !== null) { | 696 | if (row.value.revokedAt !== null) { |
| 636 | return yield* new SessionTokenRevokedError({}); | 697 | return yield* new SessionTokenRevokedError({ |
| 637 | } | 698 | sessionId: claims.sid, |
| 638 | | 699 | revokedAt: row.value.revokedAt, |
| 639 | const expiresAt = DateTime.make(claims.exp); | 700 | }); |
| 640 | if (Option.isNone(expiresAt)) { | | |
| 641 | return yield* new InvalidSessionExpirationClaimError({}); | | |
| 642 | } | 701 | } |
| 643 | | 702 | |
| 644 | return { | 703 | return { |
| ... | @@ -652,95 +711,111 @@ export const make = Effect.gen(function* () { | ... | @@ -652,95 +711,111 @@ export const make = Effect.gen(function* () { |
| 652 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), | 711 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), |
| 653 | } satisfies VerifiedSession; | 712 | } satisfies VerifiedSession; |
| 654 | }, | 713 | }, |
| 655 | Effect.mapError((cause) => | | |
| 656 | isSessionCredentialInvalidError(cause) | | |
| 657 | ? cause | | |
| 658 | : new SessionCredentialVerificationError({ cause }), | | |
| 659 | ), | | |
| 660 | ); | 714 | ); |
| 661 | | 715 | |
| 662 | const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); | 716 | const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); |
| 663 | const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn( | 717 | const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn( |
| 664 | "SessionStore.issueWebSocketToken", | 718 | "SessionStore.issueWebSocketToken", |
| 665 | )( | 719 | )(function* (sessionId, input) { |
| 666 | function* (sessionId, input) { | 720 | const issuedAt = yield* DateTime.now; |
| 667 | const issuedAt = yield* DateTime.now; | 721 | const expiresAt = DateTime.add(issuedAt, { |
| 668 | const expiresAt = DateTime.add(issuedAt, { | 722 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), |
| 669 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), | 723 | }); |
| 670 | }); | 724 | const claims: WebSocketClaims = { |
| 671 | const claims: WebSocketClaims = { | 725 | v: 1, |
| 672 | v: 1, | 726 | kind: "websocket", |
| 673 | kind: "websocket", | 727 | sid: sessionId, |
| 674 | sid: sessionId, | 728 | iat: issuedAt.epochMilliseconds, |
| 675 | iat: issuedAt.epochMilliseconds, | 729 | exp: expiresAt.epochMilliseconds, |
| 676 | exp: expiresAt.epochMilliseconds, | 730 | }; |
| 677 | }; | 731 | const encodedPayload = yield* encodeWsClaims(claims).pipe( |
| 678 | const encodedPayload = yield* encodeWsClaims(claims).pipe( | 732 | Effect.map(base64UrlEncode), |
| 679 | Effect.map(base64UrlEncode), | 733 | Effect.mapError( |
| 680 | Effect.mapError( | 734 | (cause) => |
| 681 | (cause) => | 735 | new WebSocketTokenIssueError({ |
| 682 | new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }), | 736 | sessionId, |
| 683 | ), | 737 | cause: new SessionClaimsEncodingError({ |
| 684 | ); | 738 | sessionId, |
| 685 | const signature = signPayload(encodedPayload, signingSecret); | 739 | operation: "encode_websocket_claims", |
| 686 | return { | 740 | cause, |
| 687 | token: `${encodedPayload}.${signature}`, | 741 | }), |
| 688 | expiresAt, | 742 | }), |
| 689 | }; | 743 | ), |
| 690 | }, | 744 | ); |
| 691 | Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })), | 745 | const signature = signPayload(encodedPayload, signingSecret); |
| 692 | ); | 746 | return { |
| | 747 | token: `${encodedPayload}.${signature}`, |
| | 748 | expiresAt, |
| | 749 | }; |
| | 750 | }); |
| 693 | | 751 | |
| 694 | const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn( | 752 | const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn( |
| 695 | "SessionStore.verifyWebSocketToken", | 753 | "SessionStore.verifyWebSocketToken", |
| 696 | )( | 754 | )(function* (token) { |
| 697 | function* (token) { | 755 | const [encodedPayload, signature] = token.split("."); |
| 698 | const [encodedPayload, signature] = token.split("."); | 756 | if (!encodedPayload || !signature) { |
| 699 | if (!encodedPayload || !signature) { | 757 | return yield* new MalformedWebSocketTokenError({}); |
| 700 | return yield* new MalformedWebSocketTokenError({}); | 758 | } |
| 701 | } | | |
| 702 | | | |
| 703 | const expectedSignature = signPayload(encodedPayload, signingSecret); | | |
| 704 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { | | |
| 705 | return yield* new InvalidWebSocketTokenSignatureError({}); | | |
| 706 | } | | |
| 707 | | 759 | |
| 708 | const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( | 760 | const expectedSignature = signPayload(encodedPayload, signingSecret); |
| 709 | Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), | 761 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { |
| 710 | ); | 762 | return yield* new InvalidWebSocketTokenSignatureError({}); |
| | 763 | } |
| 711 | | 764 | |
| 712 | const now = yield* Clock.currentTimeMillis; | 765 | const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( |
| 713 | if (claims.exp <= now) { | 766 | Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), |
| 714 | return yield* new WebSocketTokenExpiredError({}); | 767 | ); |
| 715 | } | | |
| 716 | | 768 | |
| 717 | const row = yield* authSessions.getById({ sessionId: claims.sid }); | 769 | const observedAt = yield* DateTime.now; |
| 718 | if (Option.isNone(row)) { | 770 | const expiresAt = DateTime.make(claims.exp); |
| 719 | return yield* new UnknownWebSocketSessionError({}); | 771 | if (Option.isNone(expiresAt)) { |
| 720 | } | 772 | return yield* new InvalidSessionExpirationClaimError({ |
| 721 | if (row.value.expiresAt.epochMilliseconds <= now) { | 773 | sessionId: claims.sid, |
| 722 | return yield* new WebSocketSessionExpiredError({}); | 774 | expirationClaim: claims.exp, |
| 723 | } | 775 | }); |
| 724 | if (row.value.revokedAt !== null) { | 776 | } |
| 725 | return yield* new WebSocketSessionRevokedError({}); | 777 | if (claims.exp <= observedAt.epochMilliseconds) { |
| 726 | } | 778 | return yield* new WebSocketTokenExpiredError({ |
| | 779 | sessionId: claims.sid, |
| | 780 | expiresAt: expiresAt.value, |
| | 781 | observedAt, |
| | 782 | }); |
| | 783 | } |
| 727 | | 784 | |
| 728 | return { | 785 | const row = yield* authSessions |
| 729 | sessionId: row.value.sessionId, | 786 | .getById({ sessionId: claims.sid }) |
| 730 | token, | 787 | .pipe( |
| 731 | method: row.value.method, | 788 | Effect.mapError( |
| 732 | client: toClientMetadata(row.value.client), | 789 | (cause) => new WebSocketTokenVerificationError({ sessionId: claims.sid, cause }), |
| | 790 | ), |
| | 791 | ); |
| | 792 | if (Option.isNone(row)) { |
| | 793 | return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid }); |
| | 794 | } |
| | 795 | if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { |
| | 796 | return yield* new WebSocketSessionExpiredError({ |
| | 797 | sessionId: claims.sid, |
| 733 | expiresAt: row.value.expiresAt, | 798 | expiresAt: row.value.expiresAt, |
| 734 | subject: row.value.subject, | 799 | observedAt, |
| 735 | scopes: row.value.scopes, | 800 | }); |
| 736 | } satisfies VerifiedSession; | 801 | } |
| 737 | }, | 802 | if (row.value.revokedAt !== null) { |
| 738 | Effect.mapError((cause) => | 803 | return yield* new WebSocketSessionRevokedError({ |
| 739 | isSessionCredentialInvalidError(cause) | 804 | sessionId: claims.sid, |
| 740 | ? cause | 805 | revokedAt: row.value.revokedAt, |
| 741 | : new WebSocketTokenVerificationError({ cause }), | 806 | }); |
| 742 | ), | 807 | } |
| 743 | ); | 808 | |
| | 809 | return { |
| | 810 | sessionId: row.value.sessionId, |
| | 811 | token, |
| | 812 | method: row.value.method, |
| | 813 | client: toClientMetadata(row.value.client), |
| | 814 | expiresAt: row.value.expiresAt, |
| | 815 | subject: row.value.subject, |
| | 816 | scopes: row.value.scopes, |
| | 817 | } satisfies VerifiedSession; |
| | 818 | }); |
| 744 | | 819 | |
| 745 | const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")( | 820 | const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")( |
| 746 | function* () { | 821 | function* () { |
| ... | @@ -768,10 +843,12 @@ export const make = Effect.gen(function* () { | ... | @@ -768,10 +843,12 @@ export const make = Effect.gen(function* () { |
| 768 | const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( | 843 | const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( |
| 769 | function* (sessionId) { | 844 | function* (sessionId) { |
| 770 | const revokedAt = yield* DateTime.now; | 845 | const revokedAt = yield* DateTime.now; |
| 771 | const revoked = yield* authSessions.revoke({ | 846 | const revoked = yield* authSessions |
| 772 | sessionId, | 847 | .revoke({ |
| 773 | revokedAt, | 848 | sessionId, |
| 774 | }); | 849 | revokedAt, |
| | 850 | }) |
| | 851 | .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); |
| 775 | if (revoked) { | 852 | if (revoked) { |
| 776 | yield* Ref.update(connectedSessionsRef, (current) => { | 853 | yield* Ref.update(connectedSessionsRef, (current) => { |
| 777 | const next = new Map(current); | 854 | const next = new Map(current); |
| ... | @@ -782,39 +859,41 @@ export const make = Effect.gen(function* () { | ... | @@ -782,39 +859,41 @@ export const make = Effect.gen(function* () { |
| 782 | } | 859 | } |
| 783 | return revoked; | 860 | return revoked; |
| 784 | }, | 861 | }, |
| 785 | Effect.mapError((cause) => new SessionRevocationError({ cause })), | | |
| 786 | ); | 862 | ); |
| 787 | | 863 | |
| 788 | const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( | 864 | const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( |
| 789 | "SessionStore.revokeAllExcept", | 865 | "SessionStore.revokeAllExcept", |
| 790 | )( | 866 | )(function* (sessionId) { |
| 791 | function* (sessionId) { | 867 | const revokedAt = yield* DateTime.now; |
| 792 | const revokedAt = yield* DateTime.now; | 868 | const revokedSessionIds = yield* authSessions |
| 793 | const revokedSessionIds = yield* authSessions.revokeAllExcept({ | 869 | .revokeAllExcept({ |
| 794 | currentSessionId: sessionId, | 870 | currentSessionId: sessionId, |
| 795 | revokedAt, | 871 | revokedAt, |
| | 872 | }) |
| | 873 | .pipe( |
| | 874 | Effect.mapError( |
| | 875 | (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }), |
| | 876 | ), |
| | 877 | ); |
| | 878 | if (revokedSessionIds.length > 0) { |
| | 879 | yield* Ref.update(connectedSessionsRef, (current) => { |
| | 880 | const next = new Map(current); |
| | 881 | for (const revokedSessionId of revokedSessionIds) { |
| | 882 | next.delete(revokedSessionId); |
| | 883 | } |
| | 884 | return next; |
| 796 | }); | 885 | }); |
| 797 | if (revokedSessionIds.length > 0) { | 886 | yield* Effect.forEach( |
| 798 | yield* Ref.update(connectedSessionsRef, (current) => { | 887 | revokedSessionIds, |
| 799 | const next = new Map(current); | 888 | (revokedSessionId) => emitRemoved(revokedSessionId), |
| 800 | for (const revokedSessionId of revokedSessionIds) { | 889 | { |
| 801 | next.delete(revokedSessionId); | 890 | concurrency: "unbounded", |
| 802 | } | 891 | discard: true, |
| 803 | return next; | 892 | }, |
| 804 | }); | 893 | ); |
| 805 | yield* Effect.forEach( | 894 | } |
| 806 | revokedSessionIds, | 895 | return revokedSessionIds.length; |
| 807 | (revokedSessionId) => emitRemoved(revokedSessionId), | 896 | }); |
| 808 | { | | |
| 809 | concurrency: "unbounded", | | |
| 810 | discard: true, | | |
| 811 | }, | | |
| 812 | ); | | |
| 813 | } | | |
| 814 | return revokedSessionIds.length; | | |
| 815 | }, | | |
| 816 | Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })), | | |
| 817 | ); | | |
| 818 | | 897 | |
| 819 | return SessionStore.of({ | 898 | return SessionStore.of({ |
| 820 | cookieName, | 899 | cookieName, |