@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/client.d.ts +116 -0
- package/dist/client.js +270 -0
- package/dist/server.d.ts +313 -6
- package/dist/server.js +697 -3
- package/dist/shared.d.ts +40 -0
- package/package.json +1 -1
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(
|
|
367
|
-
// Future: read ERROR_LOG_LEVEL, ERROR_INCLUDE_STACK, etc.
|
|
368
|
-
initErrorHandling(
|
|
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
|
// =============================================================================
|