@markwharton/pwa-core 4.0.1 → 4.1.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.d.ts CHANGED
@@ -417,10 +417,18 @@ export declare function listEntitiesWithFilter<T extends object>(client: TableCl
417
417
  * @returns A 32-character hex string suitable for Azure Table Storage row keys
418
418
  */
419
419
  export declare function generateRowKey(identifier: string): string;
420
+ /**
421
+ * Authentication method for session auth.
422
+ * - 'magic-link': Email-based passwordless authentication (default)
423
+ * - 'easy-auth': Azure Static Web Apps Easy Auth (reads identity from x-ms-client-principal header)
424
+ */
425
+ export type SessionAuthMethod = 'magic-link' | 'easy-auth';
420
426
  /**
421
427
  * Configuration for session-based authentication.
422
428
  */
423
429
  export interface SessionAuthConfig {
430
+ /** Authentication method (default: 'magic-link') */
431
+ authMethod?: SessionAuthMethod;
424
432
  /** Cookie name for the session (default: 'app_session') */
425
433
  cookieName?: string;
426
434
  /** Session duration in ms (default: 30 days) */
@@ -449,8 +457,8 @@ export interface SessionAuthConfig {
449
457
  resolveUser?: (user: SessionUser) => Promise<SessionUser> | SessionUser;
450
458
  /** Base URL for magic links and SWA preview URL validation */
451
459
  appBaseUrl?: string;
452
- /** Required callback to send magic link emails */
453
- sendEmail: (to: string, magicLink: string) => Promise<boolean>;
460
+ /** Callback to send magic link emails. Required for magic-link auth, ignored for easy-auth. */
461
+ sendEmail?: (to: string, magicLink: string) => Promise<boolean>;
454
462
  }
455
463
  /**
456
464
  * Initializes session-based authentication. Call once at application startup.
@@ -481,7 +489,10 @@ export declare function initSessionAuth(config: SessionAuthConfig): void;
481
489
  * });
482
490
  */
483
491
  export declare function initSessionAuthFromEnv(config: {
484
- sendEmail: (to: string, magicLink: string) => Promise<boolean>;
492
+ /** Authentication method (default: 'magic-link'). Also reads AUTH_METHOD env var. */
493
+ authMethod?: SessionAuthMethod;
494
+ /** Callback to send magic link emails. Required for magic-link auth, ignored for easy-auth. */
495
+ sendEmail?: (to: string, magicLink: string) => Promise<boolean>;
485
496
  /** Additional email validation. Called after built-in domain/admin checks reject.
486
497
  * Can only widen access, never narrow it. */
487
498
  isEmailAllowed?: (email: string) => boolean | Promise<boolean>;
@@ -597,6 +608,8 @@ export declare function validateSession<T extends SessionUser = SessionUser>(req
597
608
  }>>;
598
609
  /**
599
610
  * Convenience function: validates session and returns user with optional refresh headers.
611
+ * For magic-link auth, validates session cookie and handles sliding window refresh.
612
+ * For easy-auth, reads identity from SWA's x-ms-client-principal header.
600
613
  * @param request - Request object with headers.get() method
601
614
  * @returns Result with user and optional Set-Cookie headers
602
615
  * @example
package/dist/server.js CHANGED
@@ -783,8 +783,9 @@ const DEFAULT_COOKIE_NAME = 'app_session';
783
783
  * });
784
784
  */
785
785
  function initSessionAuth(config) {
786
- if (!config.sendEmail) {
787
- throw new Error('sendEmail callback is required for session auth');
786
+ const method = config.authMethod ?? 'magic-link';
787
+ if (method === 'magic-link' && !config.sendEmail) {
788
+ throw new Error('sendEmail callback is required for magic-link auth');
788
789
  }
789
790
  sessionAuthConfig = config;
790
791
  }
@@ -807,7 +808,11 @@ function initSessionAuth(config) {
807
808
  function initSessionAuthFromEnv(config) {
808
809
  const allowedEmailsStr = process.env.ALLOWED_EMAILS;
809
810
  const adminEmailsStr = process.env.ADMIN_EMAILS;
811
+ const authMethod = config.authMethod
812
+ ?? process.env.AUTH_METHOD
813
+ ?? 'magic-link';
810
814
  initSessionAuth({
815
+ authMethod,
811
816
  cookieName: process.env.SESSION_COOKIE_NAME,
812
817
  appBaseUrl: process.env.APP_BASE_URL,
813
818
  allowedEmails: allowedEmailsStr
@@ -1010,6 +1015,12 @@ async function checkMagicLinkRateLimit(email) {
1010
1015
  */
1011
1016
  async function createMagicLink(email, request) {
1012
1017
  const config = getSessionAuthConfig();
1018
+ if (config.authMethod === 'easy-auth') {
1019
+ return (0, shared_1.err)('Magic links are not available in easy-auth mode', shared_1.HTTP_STATUS.BAD_REQUEST);
1020
+ }
1021
+ if (!config.sendEmail) {
1022
+ return (0, shared_1.err)('sendEmail callback is not configured', shared_1.HTTP_STATUS.INTERNAL_ERROR);
1023
+ }
1013
1024
  const normalizedEmail = email.trim().toLowerCase();
1014
1025
  // Validate email format
1015
1026
  if (!isValidEmail(normalizedEmail)) {
@@ -1062,6 +1073,9 @@ async function createMagicLink(email, request) {
1062
1073
  */
1063
1074
  async function verifyMagicLink(token) {
1064
1075
  const config = getSessionAuthConfig();
1076
+ if (config.authMethod === 'easy-auth') {
1077
+ return (0, shared_1.err)('Magic links are not available in easy-auth mode', shared_1.HTTP_STATUS.BAD_REQUEST);
1078
+ }
1065
1079
  // Look up magic link
1066
1080
  const magicLinksClient = await getTableClient(MAGIC_LINKS_TABLE);
1067
1081
  const magicLinkEntity = await getEntityIfExists(magicLinksClient, MAGICLINK_PARTITION, token);
@@ -1198,9 +1212,82 @@ async function validateSession(request) {
1198
1212
  return (0, shared_1.err)('Session validation failed', shared_1.HTTP_STATUS.UNAUTHORIZED);
1199
1213
  }
1200
1214
  }
1215
+ /**
1216
+ * Parses the SWA Easy Auth x-ms-client-principal header.
1217
+ * @param request - Request object with headers.get() method
1218
+ * @returns The client principal, or null if header is missing/invalid
1219
+ */
1220
+ function parseEasyAuthPrincipal(request) {
1221
+ const header = request.headers.get('x-ms-client-principal');
1222
+ if (!header)
1223
+ return null;
1224
+ try {
1225
+ const decoded = Buffer.from(header, 'base64').toString('utf-8');
1226
+ const principal = JSON.parse(decoded);
1227
+ if (!principal.userDetails)
1228
+ return null;
1229
+ return principal;
1230
+ }
1231
+ catch {
1232
+ return null;
1233
+ }
1234
+ }
1235
+ /**
1236
+ * Validates Easy Auth identity and returns user from the Users table.
1237
+ * Creates the UserEntity on first sign-in (same as magic link flow).
1238
+ * @param request - Request object with x-ms-client-principal header
1239
+ * @returns Result with user (no session info or refresh cookies — SWA manages sessions)
1240
+ */
1241
+ async function validateEasyAuth(request) {
1242
+ const config = getSessionAuthConfig();
1243
+ const principal = parseEasyAuthPrincipal(request);
1244
+ if (!principal)
1245
+ return (0, shared_1.err)('No Easy Auth identity', shared_1.HTTP_STATUS.UNAUTHORIZED);
1246
+ const email = principal.userDetails.toLowerCase();
1247
+ const now = new Date().toISOString();
1248
+ const isAdminUser = config.adminEmails
1249
+ ? config.adminEmails.map(e => e.toLowerCase()).includes(email)
1250
+ : false;
1251
+ // Get or create user (same pattern as verifyMagicLink)
1252
+ const usersClient = await getTableClient(USERS_TABLE);
1253
+ let userEntity = await getEntityIfExists(usersClient, USER_PARTITION, email);
1254
+ if (!userEntity) {
1255
+ userEntity = {
1256
+ partitionKey: USER_PARTITION,
1257
+ rowKey: email,
1258
+ id: (0, crypto_1.randomUUID)(),
1259
+ email,
1260
+ isAdmin: isAdminUser || undefined,
1261
+ createdAt: now,
1262
+ lastLoginAt: now
1263
+ };
1264
+ await upsertEntity(usersClient, userEntity);
1265
+ }
1266
+ else {
1267
+ // Update isAdmin and lastLoginAt on each request would be too expensive.
1268
+ // Only update isAdmin if it changed.
1269
+ if ((isAdminUser || undefined) !== userEntity.isAdmin) {
1270
+ userEntity.isAdmin = isAdminUser || undefined;
1271
+ await upsertEntity(usersClient, userEntity);
1272
+ }
1273
+ }
1274
+ const baseUser = {
1275
+ id: userEntity.id,
1276
+ email: userEntity.email,
1277
+ isAdmin: userEntity.isAdmin,
1278
+ createdAt: userEntity.createdAt,
1279
+ lastLoginAt: userEntity.lastLoginAt
1280
+ };
1281
+ const user = (config.resolveUser
1282
+ ? await config.resolveUser(baseUser)
1283
+ : baseUser);
1284
+ return (0, shared_1.ok)({ user });
1285
+ }
1201
1286
  // --- Session Operations ---
1202
1287
  /**
1203
1288
  * Convenience function: validates session and returns user with optional refresh headers.
1289
+ * For magic-link auth, validates session cookie and handles sliding window refresh.
1290
+ * For easy-auth, reads identity from SWA's x-ms-client-principal header.
1204
1291
  * @param request - Request object with headers.get() method
1205
1292
  * @returns Result with user and optional Set-Cookie headers
1206
1293
  * @example
@@ -1209,6 +1296,13 @@ async function validateSession(request) {
1209
1296
  * const { user, headers } = result.data!;
1210
1297
  */
1211
1298
  async function getSessionUser(request) {
1299
+ const config = getSessionAuthConfig();
1300
+ if (config.authMethod === 'easy-auth') {
1301
+ const result = await validateEasyAuth(request);
1302
+ if (!result.ok)
1303
+ return result;
1304
+ return (0, shared_1.ok)({ user: result.data.user });
1305
+ }
1212
1306
  const result = await validateSession(request);
1213
1307
  if (!result.ok)
1214
1308
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/pwa-core",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "Shared patterns for Azure PWA projects",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",