@markwharton/pwa-core 3.2.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -27,6 +27,9 @@ exports.unauthorizedResponse = unauthorizedResponse;
27
27
  exports.forbiddenResponse = forbiddenResponse;
28
28
  exports.notFoundResponse = notFoundResponse;
29
29
  exports.conflictResponse = conflictResponse;
30
+ exports.goneResponse = goneResponse;
31
+ exports.tooManyRequestsResponse = tooManyRequestsResponse;
32
+ exports.serviceUnavailableResponse = serviceUnavailableResponse;
30
33
  exports.validateRequired = validateRequired;
31
34
  exports.initErrorHandling = initErrorHandling;
32
35
  exports.initErrorHandlingFromEnv = initErrorHandlingFromEnv;
@@ -40,7 +43,29 @@ exports.useManagedIdentity = useManagedIdentity;
40
43
  exports.getTableClient = getTableClient;
41
44
  exports.clearTableClientCache = clearTableClientCache;
42
45
  exports.getEntityIfExists = getEntityIfExists;
46
+ exports.upsertEntity = upsertEntity;
47
+ exports.deleteEntity = deleteEntity;
48
+ exports.listEntitiesByPartition = listEntitiesByPartition;
49
+ exports.listEntitiesWithFilter = listEntitiesWithFilter;
43
50
  exports.generateRowKey = generateRowKey;
51
+ exports.initSessionAuth = initSessionAuth;
52
+ exports.initSessionAuthFromEnv = initSessionAuthFromEnv;
53
+ exports.parseCookies = parseCookies;
54
+ exports.createSessionCookie = createSessionCookie;
55
+ exports.createLogoutCookie = createLogoutCookie;
56
+ exports.getTrustedOrigin = getTrustedOrigin;
57
+ exports.isValidEmail = isValidEmail;
58
+ exports.isEmailAllowed = isEmailAllowed;
59
+ exports.checkMagicLinkRateLimit = checkMagicLinkRateLimit;
60
+ exports.createMagicLink = createMagicLink;
61
+ exports.verifyMagicLink = verifyMagicLink;
62
+ exports.validateSession = validateSession;
63
+ exports.getSessionUser = getSessionUser;
64
+ exports.destroySession = destroySession;
65
+ exports.withSessionAuth = withSessionAuth;
66
+ exports.withSessionAdminAuth = withSessionAdminAuth;
67
+ exports.deleteExpiredSessions = deleteExpiredSessions;
68
+ exports.deleteExpiredMagicLinks = deleteExpiredMagicLinks;
44
69
  const crypto_1 = require("crypto");
45
70
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
46
71
  const data_tables_1 = require("@azure/data-tables");
@@ -323,6 +348,45 @@ function conflictResponse(message) {
323
348
  jsonBody: { error: message }
324
349
  };
325
350
  }
351
+ /**
352
+ * Creates a 410 Gone response.
353
+ * @param message - The error message to return
354
+ * @returns Azure Functions HttpResponseInit object
355
+ * @example
356
+ * if (linkUsed) return goneResponse('Link already used');
357
+ */
358
+ function goneResponse(message) {
359
+ return {
360
+ status: shared_1.HTTP_STATUS.GONE,
361
+ jsonBody: { error: message }
362
+ };
363
+ }
364
+ /**
365
+ * Creates a 429 Too Many Requests response.
366
+ * @param message - The error message to return
367
+ * @returns Azure Functions HttpResponseInit object
368
+ * @example
369
+ * if (rateLimited) return tooManyRequestsResponse('Too many requests');
370
+ */
371
+ function tooManyRequestsResponse(message) {
372
+ return {
373
+ status: shared_1.HTTP_STATUS.TOO_MANY_REQUESTS,
374
+ jsonBody: { error: message }
375
+ };
376
+ }
377
+ /**
378
+ * Creates a 503 Service Unavailable response.
379
+ * @param message - The error message to return
380
+ * @returns Azure Functions HttpResponseInit object
381
+ * @example
382
+ * if (!emailSent) return serviceUnavailableResponse('Email service unavailable');
383
+ */
384
+ function serviceUnavailableResponse(message) {
385
+ return {
386
+ status: shared_1.HTTP_STATUS.SERVICE_UNAVAILABLE,
387
+ jsonBody: { error: message }
388
+ };
389
+ }
326
390
  /**
327
391
  * Validates that a required parameter is present.
328
392
  * Returns an error response if missing, null if valid.
@@ -360,12 +424,14 @@ function initErrorHandling(config = {}) {
360
424
  * Initializes error handling from environment variables.
361
425
  * Currently a no-op but provides consistent API for future error config
362
426
  * (e.g., ERROR_LOG_LEVEL, ERROR_INCLUDE_STACK).
427
+ * @param callback - Optional callback invoked when handleFunctionError is called (fire-and-forget)
363
428
  * @example
364
429
  * initErrorHandlingFromEnv(); // Uses process.env automatically
430
+ * initErrorHandlingFromEnv((op, msg) => sendAlert(op, msg));
365
431
  */
366
- function initErrorHandlingFromEnv(config = {}) {
367
- // Future: read ERROR_LOG_LEVEL, ERROR_INCLUDE_STACK, etc.
368
- initErrorHandling(config);
432
+ function initErrorHandlingFromEnv(callback) {
433
+ // Future: read ERROR_LOG_LEVEL, ERROR_INCLUDE_STACK, etc. from process.env
434
+ initErrorHandling({ callback });
369
435
  }
370
436
  /**
371
437
  * Handles unexpected errors safely by logging details and returning a generic message.
@@ -581,6 +647,66 @@ async function getEntityIfExists(client, partitionKey, rowKey) {
581
647
  throw error;
582
648
  }
583
649
  }
650
+ /**
651
+ * Upserts (insert or replace) an entity in Azure Table Storage.
652
+ * @typeParam T - The entity type with partitionKey and rowKey
653
+ * @param client - The TableClient instance
654
+ * @param entity - The entity to upsert
655
+ * @example
656
+ * await upsertEntity(client, { partitionKey: 'user', rowKey: 'john', name: 'John' });
657
+ */
658
+ async function upsertEntity(client, entity) {
659
+ await client.upsertEntity(entity, 'Replace');
660
+ }
661
+ /**
662
+ * Deletes an entity from Azure Table Storage.
663
+ * Returns true on success, false on error (swallows errors).
664
+ * @param client - The TableClient instance
665
+ * @param partitionKey - The partition key
666
+ * @param rowKey - The row key
667
+ * @returns True if deleted successfully, false on error
668
+ * @example
669
+ * const deleted = await deleteEntity(client, 'session', sessionId);
670
+ */
671
+ async function deleteEntity(client, partitionKey, rowKey) {
672
+ try {
673
+ await client.deleteEntity(partitionKey, rowKey);
674
+ return true;
675
+ }
676
+ catch {
677
+ return false;
678
+ }
679
+ }
680
+ /**
681
+ * Lists all entities in a partition.
682
+ * @typeParam T - The entity type
683
+ * @param client - The TableClient instance
684
+ * @param partitionKey - The partition key to filter by
685
+ * @returns Array of entities in the partition
686
+ * @example
687
+ * const sessions = await listEntitiesByPartition<SessionEntity>(client, 'session');
688
+ */
689
+ async function listEntitiesByPartition(client, partitionKey) {
690
+ const filter = (0, data_tables_1.odata) `PartitionKey eq ${partitionKey}`;
691
+ return listEntitiesWithFilter(client, filter);
692
+ }
693
+ /**
694
+ * Lists entities matching a custom OData filter.
695
+ * @typeParam T - The entity type
696
+ * @param client - The TableClient instance
697
+ * @param filter - OData filter string (use the odata template tag from @azure/data-tables)
698
+ * @returns Array of matching entities
699
+ * @example
700
+ * const filter = odata`PartitionKey eq ${'user'} and email eq ${'john@example.com'}`;
701
+ * const users = await listEntitiesWithFilter<UserEntity>(client, filter);
702
+ */
703
+ async function listEntitiesWithFilter(client, filter) {
704
+ const entities = [];
705
+ for await (const entity of client.listEntities({ queryOptions: { filter } })) {
706
+ entities.push(entity);
707
+ }
708
+ return entities;
709
+ }
584
710
  /**
585
711
  * Generate a unique row key from an identifier string.
586
712
  * Uses SHA-256 hash for consistent, URL-safe keys.
@@ -590,6 +716,574 @@ async function getEntityIfExists(client, partitionKey, rowKey) {
590
716
  function generateRowKey(identifier) {
591
717
  return (0, crypto_1.createHash)('sha256').update(identifier).digest('hex').substring(0, 32);
592
718
  }
719
+ // Partition keys
720
+ const USER_PARTITION = 'user';
721
+ const SESSION_PARTITION = 'session';
722
+ const MAGICLINK_PARTITION = 'magiclink';
723
+ // Table names
724
+ const USERS_TABLE = 'Users';
725
+ const SESSIONS_TABLE = 'Sessions';
726
+ const MAGIC_LINKS_TABLE = 'MagicLinks';
727
+ // Session auth singleton state
728
+ let sessionAuthConfig = null;
729
+ // Default durations
730
+ const DEFAULT_SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
731
+ const DEFAULT_SESSION_REFRESH_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
732
+ const DEFAULT_MAGIC_LINK_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes
733
+ const DEFAULT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
734
+ const DEFAULT_RATE_LIMIT_MAX_REQUESTS = 5;
735
+ const DEFAULT_COOKIE_NAME = 'app_session';
736
+ /**
737
+ * Initializes session-based authentication. Call once at application startup.
738
+ * @param config - Session auth configuration
739
+ * @throws Error if sendEmail callback is not provided
740
+ * @example
741
+ * initSessionAuth({
742
+ * sendEmail: async (to, magicLink) => { await resend.emails.send(...); return true; },
743
+ * allowedEmails: ['admin@example.com'],
744
+ * appBaseUrl: 'https://myapp.com'
745
+ * });
746
+ */
747
+ function initSessionAuth(config) {
748
+ if (!config.sendEmail) {
749
+ throw new Error('sendEmail callback is required for session auth');
750
+ }
751
+ sessionAuthConfig = config;
752
+ }
753
+ /**
754
+ * Initializes session auth from environment variables.
755
+ * Reads: SESSION_COOKIE_NAME, APP_BASE_URL, ALLOWED_EMAILS, ALLOWED_DOMAIN, ADMIN_EMAILS.
756
+ * @param sendEmail - Required callback to send magic link emails
757
+ * @throws Error if sendEmail is not provided
758
+ * @example
759
+ * initSessionAuthFromEnv(async (to, magicLink) => {
760
+ * await resend.emails.send({ to, html: `<a href="${magicLink}">Sign In</a>` });
761
+ * return true;
762
+ * });
763
+ */
764
+ function initSessionAuthFromEnv(sendEmail) {
765
+ const allowedEmailsStr = process.env.ALLOWED_EMAILS;
766
+ const adminEmailsStr = process.env.ADMIN_EMAILS;
767
+ initSessionAuth({
768
+ cookieName: process.env.SESSION_COOKIE_NAME,
769
+ appBaseUrl: process.env.APP_BASE_URL,
770
+ allowedEmails: allowedEmailsStr
771
+ ? allowedEmailsStr.split(',').map(e => e.trim().toLowerCase())
772
+ : undefined,
773
+ allowedDomain: process.env.ALLOWED_DOMAIN,
774
+ adminEmails: adminEmailsStr
775
+ ? adminEmailsStr.split(',').map(e => e.trim().toLowerCase())
776
+ : undefined,
777
+ sendEmail
778
+ });
779
+ }
780
+ /**
781
+ * Gets the session auth configuration.
782
+ * @returns The session auth config
783
+ * @throws Error if initSessionAuth() has not been called
784
+ */
785
+ function getSessionAuthConfig() {
786
+ if (!sessionAuthConfig) {
787
+ throw new Error('Session auth not initialized. Call initSessionAuth() first.');
788
+ }
789
+ return sessionAuthConfig;
790
+ }
791
+ // --- Cookie Management ---
792
+ /**
793
+ * Parses cookies from a request's Cookie header.
794
+ * @param request - Request object with headers.get() method
795
+ * @returns Record of cookie name/value pairs
796
+ * @example
797
+ * const cookies = parseCookies(request);
798
+ * const sessionId = cookies['app_session'];
799
+ */
800
+ function parseCookies(request) {
801
+ const cookieHeader = request.headers.get('cookie');
802
+ if (!cookieHeader)
803
+ return {};
804
+ return cookieHeader.split(';').reduce((cookies, cookie) => {
805
+ const [name, ...rest] = cookie.trim().split('=');
806
+ if (name && rest.length > 0) {
807
+ cookies[name] = decodeURIComponent(rest.join('='));
808
+ }
809
+ return cookies;
810
+ }, {});
811
+ }
812
+ /**
813
+ * Creates a Set-Cookie header value for a session cookie.
814
+ * @param sessionId - The session ID to store in the cookie
815
+ * @returns Cookie string for the Set-Cookie header
816
+ * @example
817
+ * return { headers: { 'Set-Cookie': createSessionCookie(sessionId) } };
818
+ */
819
+ function createSessionCookie(sessionId) {
820
+ const config = getSessionAuthConfig();
821
+ const cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME;
822
+ const durationMs = config.sessionDurationMs ?? DEFAULT_SESSION_DURATION_MS;
823
+ const sameSite = config.sameSite ?? 'Strict';
824
+ const expires = new Date(Date.now() + durationMs);
825
+ const isProduction = process.env.NODE_ENV === 'production';
826
+ return [
827
+ `${cookieName}=${encodeURIComponent(sessionId)}`,
828
+ `Expires=${expires.toUTCString()}`,
829
+ 'Path=/',
830
+ 'HttpOnly',
831
+ isProduction ? 'Secure' : '',
832
+ `SameSite=${sameSite}`
833
+ ].filter(Boolean).join('; ');
834
+ }
835
+ /**
836
+ * Creates a Set-Cookie header value that clears the session cookie.
837
+ * @returns Cookie string for the Set-Cookie header
838
+ * @example
839
+ * return { headers: { 'Set-Cookie': createLogoutCookie() } };
840
+ */
841
+ function createLogoutCookie() {
842
+ const config = getSessionAuthConfig();
843
+ const cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME;
844
+ return [
845
+ `${cookieName}=`,
846
+ 'Expires=Thu, 01 Jan 1970 00:00:00 GMT',
847
+ 'Path=/',
848
+ 'HttpOnly'
849
+ ].join('; ');
850
+ }
851
+ // --- SWA Preview URL Support ---
852
+ /**
853
+ * Validates a request origin for SWA preview/staging deployments.
854
+ * Returns the trusted origin if it matches the configured appBaseUrl:
855
+ * - Exact hostname match (production)
856
+ * - Localhost-to-localhost match (local development)
857
+ * - App name prefix match for SWA preview slots
858
+ * @param origin - The request Origin header value
859
+ * @returns The trusted origin URL, or undefined if not trusted
860
+ * @example
861
+ * const trusted = getTrustedOrigin(request.headers.get('origin'));
862
+ * const baseUrl = trusted ?? config.appBaseUrl;
863
+ */
864
+ function getTrustedOrigin(origin) {
865
+ if (!origin)
866
+ return undefined;
867
+ const config = getSessionAuthConfig();
868
+ const appBaseUrl = config.appBaseUrl;
869
+ if (!appBaseUrl)
870
+ return undefined;
871
+ try {
872
+ const originUrl = new URL(origin);
873
+ const baseUrl = new URL(appBaseUrl);
874
+ // Exact hostname match
875
+ if (originUrl.hostname === baseUrl.hostname) {
876
+ return origin;
877
+ }
878
+ // Localhost-to-localhost match (local development)
879
+ if (originUrl.hostname === 'localhost' && baseUrl.hostname === 'localhost') {
880
+ return origin;
881
+ }
882
+ // SWA preview slot match: preview hostname starts with the same app name prefix
883
+ // e.g., base: witty-sand-0a9e33800.5.azurestaticapps.net
884
+ // preview: witty-sand-0a9e33800-19.5.azurestaticapps.net
885
+ const baseHostParts = baseUrl.hostname.split('.');
886
+ const originHostParts = originUrl.hostname.split('.');
887
+ if (baseHostParts.length >= 3 &&
888
+ originHostParts.length >= 3 &&
889
+ baseHostParts.slice(-2).join('.') === 'azurestaticapps.net' &&
890
+ originHostParts.slice(-2).join('.') === 'azurestaticapps.net') {
891
+ // Extract app name (part before the first dot in the subdomain)
892
+ const baseAppName = baseHostParts[0].split('-').slice(0, -1).join('-');
893
+ const originAppName = originHostParts[0].split('-').slice(0, -1).join('-');
894
+ if (baseAppName && originAppName && originAppName.startsWith(baseAppName)) {
895
+ return origin;
896
+ }
897
+ }
898
+ return undefined;
899
+ }
900
+ catch {
901
+ return undefined;
902
+ }
903
+ }
904
+ // --- Email/Domain Validation ---
905
+ /**
906
+ * Validates an email address format.
907
+ * @param email - The email address to validate
908
+ * @returns True if the email format is valid
909
+ */
910
+ function isValidEmail(email) {
911
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
912
+ }
913
+ /**
914
+ * Checks if an email is allowed by the configured allowlist.
915
+ * Checks both allowedEmails (exact match) and allowedDomain (suffix match).
916
+ * If neither is configured, allows all emails.
917
+ * @param email - The email address to check
918
+ * @returns True if the email is allowed
919
+ */
920
+ function isEmailAllowed(email) {
921
+ const config = getSessionAuthConfig();
922
+ const normalizedEmail = email.toLowerCase();
923
+ const hasAllowedEmails = config.allowedEmails && config.allowedEmails.length > 0;
924
+ const hasAllowedDomain = !!config.allowedDomain;
925
+ // If neither is configured, allow all
926
+ if (!hasAllowedEmails && !hasAllowedDomain) {
927
+ return true;
928
+ }
929
+ // Check exact email match
930
+ if (hasAllowedEmails && config.allowedEmails.includes(normalizedEmail)) {
931
+ return true;
932
+ }
933
+ // Check domain match
934
+ if (hasAllowedDomain && normalizedEmail.endsWith(config.allowedDomain.toLowerCase())) {
935
+ return true;
936
+ }
937
+ return false;
938
+ }
939
+ // --- Rate Limiting ---
940
+ /**
941
+ * Checks if a magic link request is within rate limits.
942
+ * @param email - The email address to check
943
+ * @returns True if within limits, false if rate limited
944
+ */
945
+ async function checkMagicLinkRateLimit(email) {
946
+ const config = getSessionAuthConfig();
947
+ const windowMs = config.rateLimitWindowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS;
948
+ const maxRequests = config.rateLimitMaxRequests ?? DEFAULT_RATE_LIMIT_MAX_REQUESTS;
949
+ const client = await getTableClient(MAGIC_LINKS_TABLE);
950
+ const allLinks = await listEntitiesByPartition(client, MAGICLINK_PARTITION);
951
+ const windowStart = new Date(Date.now() - windowMs).toISOString();
952
+ const normalizedEmail = email.toLowerCase();
953
+ const recentLinks = allLinks.filter(link => link.email.toLowerCase() === normalizedEmail && link.createdAt >= windowStart);
954
+ return recentLinks.length < maxRequests;
955
+ }
956
+ // --- Magic Link Token Operations ---
957
+ /**
958
+ * Creates a magic link token, stores it, and sends an email.
959
+ * @param email - The email address to send the magic link to
960
+ * @param request - Request object (used to resolve SWA preview origin)
961
+ * @returns Result with the token on success, or error with statusCode
962
+ * @example
963
+ * const result = await createMagicLink(email, request);
964
+ * if (!result.ok) return { status: result.statusCode, jsonBody: { error: result.error } };
965
+ */
966
+ async function createMagicLink(email, request) {
967
+ const config = getSessionAuthConfig();
968
+ const normalizedEmail = email.trim().toLowerCase();
969
+ // Validate email format
970
+ if (!isValidEmail(normalizedEmail)) {
971
+ return (0, shared_1.err)('Valid email required', shared_1.HTTP_STATUS.BAD_REQUEST);
972
+ }
973
+ // Check allowlist
974
+ if (!isEmailAllowed(normalizedEmail)) {
975
+ return (0, shared_1.err)('Email not allowed', shared_1.HTTP_STATUS.FORBIDDEN);
976
+ }
977
+ // Check rate limit
978
+ if (!(await checkMagicLinkRateLimit(normalizedEmail))) {
979
+ return (0, shared_1.err)('Too many requests', shared_1.HTTP_STATUS.TOO_MANY_REQUESTS);
980
+ }
981
+ const token = (0, crypto_1.randomUUID)();
982
+ const now = new Date();
983
+ const expiryMs = config.magicLinkExpiryMs ?? DEFAULT_MAGIC_LINK_EXPIRY_MS;
984
+ const expiresAt = new Date(now.getTime() + expiryMs);
985
+ const magicLinkEntity = {
986
+ partitionKey: MAGICLINK_PARTITION,
987
+ rowKey: token,
988
+ email: normalizedEmail,
989
+ createdAt: now.toISOString(),
990
+ expiresAt: expiresAt.toISOString(),
991
+ used: false
992
+ };
993
+ const client = await getTableClient(MAGIC_LINKS_TABLE);
994
+ await upsertEntity(client, magicLinkEntity);
995
+ // Resolve magic link URL using trusted origin
996
+ const trustedOrigin = getTrustedOrigin(request.headers.get('origin'));
997
+ const baseUrl = trustedOrigin ?? config.appBaseUrl ?? '';
998
+ const magicLink = `${baseUrl}/verify?token=${token}`;
999
+ const emailSent = await config.sendEmail(normalizedEmail, magicLink);
1000
+ if (!emailSent) {
1001
+ return (0, shared_1.err)('Email service unavailable', shared_1.HTTP_STATUS.SERVICE_UNAVAILABLE);
1002
+ }
1003
+ return (0, shared_1.ok)(token);
1004
+ }
1005
+ /**
1006
+ * Verifies a magic link token, creates/updates the user, and creates a session.
1007
+ * @param token - The magic link token to verify
1008
+ * @returns Result with user and sessionCookie on success, or error with statusCode
1009
+ * @example
1010
+ * const result = await verifyMagicLink(token);
1011
+ * if (!result.ok) return { status: result.statusCode, jsonBody: { error: result.error } };
1012
+ * return { headers: { 'Set-Cookie': result.data.sessionCookie }, jsonBody: { user: result.data.user } };
1013
+ */
1014
+ async function verifyMagicLink(token) {
1015
+ const config = getSessionAuthConfig();
1016
+ // Look up magic link
1017
+ const magicLinksClient = await getTableClient(MAGIC_LINKS_TABLE);
1018
+ const magicLinkEntity = await getEntityIfExists(magicLinksClient, MAGICLINK_PARTITION, token);
1019
+ if (!magicLinkEntity) {
1020
+ return (0, shared_1.err)('Invalid or expired link', shared_1.HTTP_STATUS.NOT_FOUND);
1021
+ }
1022
+ if (magicLinkEntity.used) {
1023
+ return (0, shared_1.err)('Link already used', shared_1.HTTP_STATUS.GONE);
1024
+ }
1025
+ if (new Date(magicLinkEntity.expiresAt) < new Date()) {
1026
+ return (0, shared_1.err)('Link expired', shared_1.HTTP_STATUS.GONE);
1027
+ }
1028
+ const email = magicLinkEntity.email.toLowerCase();
1029
+ const now = new Date().toISOString();
1030
+ // Get or create user
1031
+ const usersClient = await getTableClient(USERS_TABLE);
1032
+ let userEntity = await getEntityIfExists(usersClient, USER_PARTITION, email);
1033
+ const isAdminUser = config.adminEmails
1034
+ ? config.adminEmails.map(e => e.toLowerCase()).includes(email)
1035
+ : false;
1036
+ if (!userEntity) {
1037
+ userEntity = {
1038
+ partitionKey: USER_PARTITION,
1039
+ rowKey: email,
1040
+ id: (0, crypto_1.randomUUID)(),
1041
+ email,
1042
+ isAdmin: isAdminUser || undefined,
1043
+ createdAt: now,
1044
+ lastLoginAt: now
1045
+ };
1046
+ }
1047
+ else {
1048
+ userEntity.lastLoginAt = now;
1049
+ userEntity.isAdmin = isAdminUser || undefined;
1050
+ }
1051
+ await upsertEntity(usersClient, userEntity);
1052
+ // Create session
1053
+ const durationMs = config.sessionDurationMs ?? DEFAULT_SESSION_DURATION_MS;
1054
+ const sessionId = (0, crypto_1.randomUUID)();
1055
+ const sessionExpiresAt = new Date(Date.now() + durationMs).toISOString();
1056
+ const sessionEntity = {
1057
+ partitionKey: SESSION_PARTITION,
1058
+ rowKey: sessionId,
1059
+ id: sessionId,
1060
+ userId: userEntity.id,
1061
+ email: userEntity.email,
1062
+ createdAt: now,
1063
+ expiresAt: sessionExpiresAt,
1064
+ lastAccessedAt: now
1065
+ };
1066
+ const sessionsClient = await getTableClient(SESSIONS_TABLE);
1067
+ await upsertEntity(sessionsClient, sessionEntity);
1068
+ // Mark token as used
1069
+ magicLinkEntity.used = true;
1070
+ await upsertEntity(magicLinksClient, magicLinkEntity);
1071
+ const user = {
1072
+ id: userEntity.id,
1073
+ email: userEntity.email,
1074
+ isAdmin: userEntity.isAdmin,
1075
+ createdAt: userEntity.createdAt,
1076
+ lastLoginAt: userEntity.lastLoginAt
1077
+ };
1078
+ const sessionCookie = createSessionCookie(sessionId);
1079
+ return (0, shared_1.ok)({ user, sessionCookie });
1080
+ }
1081
+ // --- Session Validation ---
1082
+ /**
1083
+ * Validates a session cookie and returns the user and session info.
1084
+ * Performs sliding window refresh if the session is close to expiry.
1085
+ * @param request - Request object with headers.get() method
1086
+ * @returns User, session, and optional refreshed cookie, or null if invalid
1087
+ * @example
1088
+ * const result = await validateSession(request);
1089
+ * if (!result) return unauthorizedResponse();
1090
+ */
1091
+ async function validateSession(request) {
1092
+ const config = getSessionAuthConfig();
1093
+ const cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME;
1094
+ const cookies = parseCookies(request);
1095
+ const sessionId = cookies[cookieName];
1096
+ if (!sessionId)
1097
+ return null;
1098
+ try {
1099
+ const sessionsClient = await getTableClient(SESSIONS_TABLE);
1100
+ const sessionEntity = await getEntityIfExists(sessionsClient, SESSION_PARTITION, sessionId);
1101
+ if (!sessionEntity)
1102
+ return null;
1103
+ // Check expiry
1104
+ if (new Date(sessionEntity.expiresAt) < new Date())
1105
+ return null;
1106
+ // Get user
1107
+ const usersClient = await getTableClient(USERS_TABLE);
1108
+ const userEntity = await getEntityIfExists(usersClient, USER_PARTITION, sessionEntity.email.toLowerCase());
1109
+ if (!userEntity)
1110
+ return null;
1111
+ const user = {
1112
+ id: userEntity.id,
1113
+ email: userEntity.email,
1114
+ isAdmin: userEntity.isAdmin,
1115
+ createdAt: userEntity.createdAt,
1116
+ lastLoginAt: userEntity.lastLoginAt
1117
+ };
1118
+ const session = {
1119
+ id: sessionEntity.id,
1120
+ userId: sessionEntity.userId,
1121
+ email: sessionEntity.email,
1122
+ createdAt: sessionEntity.createdAt,
1123
+ expiresAt: sessionEntity.expiresAt,
1124
+ lastAccessedAt: sessionEntity.lastAccessedAt
1125
+ };
1126
+ // Sliding window: refresh if close to expiry
1127
+ let refreshedCookie;
1128
+ const refreshThreshold = config.sessionRefreshThresholdMs ?? DEFAULT_SESSION_REFRESH_THRESHOLD_MS;
1129
+ const durationMs = config.sessionDurationMs ?? DEFAULT_SESSION_DURATION_MS;
1130
+ const timeUntilExpiry = new Date(sessionEntity.expiresAt).getTime() - Date.now();
1131
+ if (timeUntilExpiry < refreshThreshold) {
1132
+ const now = new Date().toISOString();
1133
+ const newExpiresAt = new Date(Date.now() + durationMs).toISOString();
1134
+ const updatedSession = {
1135
+ ...sessionEntity,
1136
+ expiresAt: newExpiresAt,
1137
+ lastAccessedAt: now
1138
+ };
1139
+ await upsertEntity(sessionsClient, updatedSession);
1140
+ refreshedCookie = createSessionCookie(sessionId);
1141
+ }
1142
+ return { user, session, refreshedCookie };
1143
+ }
1144
+ catch {
1145
+ return null;
1146
+ }
1147
+ }
1148
+ // --- Session Operations ---
1149
+ /**
1150
+ * Convenience function: validates session and returns user with optional refresh headers.
1151
+ * @param request - Request object with headers.get() method
1152
+ * @returns User and optional Set-Cookie headers, or null if not authenticated
1153
+ * @example
1154
+ * const result = await getSessionUser(request);
1155
+ * if (!result) return unauthorizedResponse();
1156
+ * return { headers: result.headers, jsonBody: { user: result.user } };
1157
+ */
1158
+ async function getSessionUser(request) {
1159
+ const result = await validateSession(request);
1160
+ if (!result)
1161
+ return null;
1162
+ const headers = result.refreshedCookie
1163
+ ? { 'Set-Cookie': result.refreshedCookie }
1164
+ : undefined;
1165
+ return { user: result.user, headers };
1166
+ }
1167
+ /**
1168
+ * Destroys the current session and returns a logout cookie string.
1169
+ * @param request - Request object with headers.get() method
1170
+ * @returns Cookie string for the Set-Cookie header
1171
+ * @example
1172
+ * const cookie = await destroySession(request);
1173
+ * return { headers: { 'Set-Cookie': cookie }, jsonBody: { success: true } };
1174
+ */
1175
+ async function destroySession(request) {
1176
+ const config = getSessionAuthConfig();
1177
+ const cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME;
1178
+ const cookies = parseCookies(request);
1179
+ const sessionId = cookies[cookieName];
1180
+ if (sessionId) {
1181
+ const sessionsClient = await getTableClient(SESSIONS_TABLE);
1182
+ await deleteEntity(sessionsClient, SESSION_PARTITION, sessionId);
1183
+ }
1184
+ return createLogoutCookie();
1185
+ }
1186
+ // --- Higher-Order Auth Wrappers ---
1187
+ /**
1188
+ * Wraps a handler with session authentication.
1189
+ * Returns 401 if not authenticated. Automatically handles session refresh cookies.
1190
+ * @param handler - The handler function that receives the authenticated user
1191
+ * @returns A wrapped handler function
1192
+ * @example
1193
+ * app.http('myEndpoint', {
1194
+ * handler: withSessionAuth(async (request, context, auth) => {
1195
+ * return { jsonBody: { user: auth.user } };
1196
+ * })
1197
+ * });
1198
+ */
1199
+ function withSessionAuth(handler) {
1200
+ return async (request, context) => {
1201
+ const result = await getSessionUser(request);
1202
+ if (!result) {
1203
+ return unauthorizedResponse('Not authenticated');
1204
+ }
1205
+ const response = await handler(request, context, result);
1206
+ // Merge refresh cookie headers
1207
+ if (result.headers) {
1208
+ response.headers = { ...result.headers, ...response.headers };
1209
+ }
1210
+ return response;
1211
+ };
1212
+ }
1213
+ /**
1214
+ * Wraps a handler with session admin authentication.
1215
+ * Returns 401 if not authenticated, 403 if not admin.
1216
+ * @param handler - The handler function that receives the authenticated admin user
1217
+ * @returns A wrapped handler function
1218
+ * @example
1219
+ * app.http('adminEndpoint', {
1220
+ * handler: withSessionAdminAuth(async (request, context, auth) => {
1221
+ * return { jsonBody: { admin: auth.user.email } };
1222
+ * })
1223
+ * });
1224
+ */
1225
+ function withSessionAdminAuth(handler) {
1226
+ return async (request, context) => {
1227
+ const result = await getSessionUser(request);
1228
+ if (!result) {
1229
+ return unauthorizedResponse('Not authenticated');
1230
+ }
1231
+ if (!result.user.isAdmin) {
1232
+ return forbiddenResponse('Admin access required');
1233
+ }
1234
+ const response = await handler(request, context, result);
1235
+ // Merge refresh cookie headers
1236
+ if (result.headers) {
1237
+ response.headers = { ...result.headers, ...response.headers };
1238
+ }
1239
+ return response;
1240
+ };
1241
+ }
1242
+ // --- Cleanup ---
1243
+ /**
1244
+ * Deletes expired sessions from storage. Call periodically (e.g., timer trigger).
1245
+ * @returns Number of sessions deleted
1246
+ * @example
1247
+ * // Timer trigger
1248
+ * const deleted = await deleteExpiredSessions();
1249
+ * context.log(`Cleaned up ${deleted} expired sessions`);
1250
+ */
1251
+ async function deleteExpiredSessions() {
1252
+ const client = await getTableClient(SESSIONS_TABLE);
1253
+ const sessions = await listEntitiesByPartition(client, SESSION_PARTITION);
1254
+ const now = new Date();
1255
+ let deleted = 0;
1256
+ for (const session of sessions) {
1257
+ if (new Date(session.expiresAt) < now) {
1258
+ const success = await deleteEntity(client, SESSION_PARTITION, session.rowKey);
1259
+ if (success)
1260
+ deleted++;
1261
+ }
1262
+ }
1263
+ return deleted;
1264
+ }
1265
+ /**
1266
+ * Deletes expired magic links from storage. Call periodically (e.g., timer trigger).
1267
+ * @returns Number of magic links deleted
1268
+ * @example
1269
+ * // Timer trigger
1270
+ * const deleted = await deleteExpiredMagicLinks();
1271
+ * context.log(`Cleaned up ${deleted} expired magic links`);
1272
+ */
1273
+ async function deleteExpiredMagicLinks() {
1274
+ const client = await getTableClient(MAGIC_LINKS_TABLE);
1275
+ const links = await listEntitiesByPartition(client, MAGICLINK_PARTITION);
1276
+ const now = new Date();
1277
+ let deleted = 0;
1278
+ for (const link of links) {
1279
+ if (new Date(link.expiresAt) < now || link.used) {
1280
+ const success = await deleteEntity(client, MAGICLINK_PARTITION, link.rowKey);
1281
+ if (success)
1282
+ deleted++;
1283
+ }
1284
+ }
1285
+ return deleted;
1286
+ }
593
1287
  // =============================================================================
594
1288
  // Re-exports from shared (for convenience)
595
1289
  // =============================================================================