@evenicanpm/storefront-core 2.4.2 → 2.5.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.
Files changed (142) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +90 -90
  3. package/src/api-manager/datasources/d365/d365-cart.datasource.ts +4 -3
  4. package/src/api-manager/datasources/d365/d365-user.datasource.ts +3 -3
  5. package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +1 -0
  6. package/src/api-manager/datasources/e4/address/e4-address.datasource.ts +5 -14
  7. package/src/api-manager/datasources/e4/address/e4-address.translator.ts +4 -3
  8. package/src/api-manager/datasources/e4/categories/e4-categories.translator.ts +1 -1
  9. package/src/api-manager/datasources/e4/graphqlRequestSdk.ts +29 -3
  10. package/src/api-manager/datasources/e4/product/e4-product.translator.ts +1 -1
  11. package/src/api-manager/index.ts +1 -1
  12. package/src/api-manager/lib/get-graphql-client.ts +1 -1
  13. package/src/api-manager/services/cart/mutations/add-discount-code.ts +0 -11
  14. package/src/api-manager/services/create-query.ts +13 -7
  15. package/src/api-manager/services/user/mutations/add-to-wishlist.ts +1 -1
  16. package/src/api-manager/services/user/mutations/remove-from-wishlist.ts +1 -1
  17. package/src/auth/AUTH GUIDE.md +880 -0
  18. package/src/cms/blocks/components/featured-categories.tsx +18 -15
  19. package/src/cms/blocks/components/featured-products.tsx +3 -2
  20. package/src/cms/blocks/components/footer/data/index.ts +0 -0
  21. package/src/cms/blocks/components/footer/index.tsx +0 -0
  22. package/src/cms/blocks/components/footer/sections/footer-app-store.tsx +0 -0
  23. package/src/cms/blocks/components/footer/sections/footer-contact.tsx +0 -2
  24. package/src/cms/blocks/components/footer/sections/footer-links.tsx +0 -0
  25. package/src/cms/blocks/components/footer/sections/footer-logo.tsx +2 -1
  26. package/src/cms/blocks/components/footer/sections/footer-social-links.tsx +0 -0
  27. package/src/cms/blocks/components/footer/styles/index.ts +0 -0
  28. package/src/cms/blocks/components/hero-carousel/index.tsx +0 -0
  29. package/src/cms/blocks/components/product-carousel/index.tsx +0 -0
  30. package/src/cms/blocks/components/shared/featured-product-card.tsx +0 -0
  31. package/src/cms/blocks/components/shared/product-category-item.tsx +0 -0
  32. package/src/cms/blocks/components/shared/product-grid.tsx +0 -0
  33. package/src/cms/blocks/components/shared/top-categories-card.tsx +0 -0
  34. package/src/cms/blocks/components/shared/top-rating-product-card.tsx +0 -0
  35. package/src/cms/blocks/icons/components/category.tsx +0 -0
  36. package/src/cms/blocks/icons/components/dotted-star.tsx +0 -0
  37. package/src/cms/blocks/icons/components/gift-box.tsx +0 -0
  38. package/src/cms/blocks/icons/components/light.tsx +0 -0
  39. package/src/cms/blocks/icons/components/new-arrival.tsx +0 -0
  40. package/src/cms/blocks/icons/components/rank-badge.tsx +0 -0
  41. package/src/components/SearchInput.stories.tsx +12 -10
  42. package/src/components/base-layout.tsx +42 -0
  43. package/src/components/categories/category-list/category-list.tsx +32 -12
  44. package/src/components/header/__tests__/header.test.tsx +0 -24
  45. package/src/components/header/components/mobile-header.tsx +16 -8
  46. package/src/components/header/header.tsx +6 -3
  47. package/src/components/mini-cart/components/bottom-actions.tsx +7 -2
  48. package/src/components/mini-cart/components/cart-item.tsx +16 -13
  49. package/src/components/mini-cart/components/empty-view.tsx +12 -6
  50. package/src/components/mini-cart/components/top-header.tsx +9 -8
  51. package/src/components/mini-cart/mini-cart-trigger.tsx +11 -5
  52. package/src/components/mini-cart/mini-cart.tsx +30 -15
  53. package/src/components/mobile-navigation/mobile-navigation-bar.tsx +6 -2
  54. package/src/components/navbar/categories.tsx +8 -2
  55. package/src/components/navbar/category-based-menu/category-based-menu.tsx +6 -2
  56. package/src/components/navbar/mega-menu/mega-menu.tsx +22 -14
  57. package/src/components/navbar/mobile-menu/mobile-menu.test.tsx +8 -11
  58. package/src/components/navbar/mobile-menu/mobile-menu.tsx +13 -11
  59. package/src/components/navbar/nav-list/nav-list.tsx +3 -2
  60. package/src/components/navbar/navbar.tsx +4 -2
  61. package/src/components/product-cards/product-card/product-card.tsx +50 -26
  62. package/src/components/product-cards/product-card-plp/product-card.tsx +37 -16
  63. package/src/components/product-cards/product-card-plp-list/product-card.tsx +38 -16
  64. package/src/components/product-cards/product-card-search/product-card.tsx +34 -13
  65. package/src/components/product-dialog/ProductDialog.stories.tsx +20 -18
  66. package/src/components/product-dialog/compound/product-dialog.tsx +7 -4
  67. package/src/components/product-dimensions/compound/dimensions-group.tsx +4 -2
  68. package/src/components/product-dimensions/compound/dimensions.tsx +15 -6
  69. package/src/components/product-quantity-buttons/compound/quantity-buttons-root.tsx +21 -13
  70. package/src/components/products-view/compound/products-grid-view.tsx +11 -4
  71. package/src/components/products-view/compound/products-list-view.tsx +11 -4
  72. package/src/components/search-bar/compound/search-bar-root.tsx +6 -4
  73. package/src/components/search-bar/compound/textfield-adornment-category.tsx +25 -8
  74. package/src/components/search-bar/hooks/use-click-outside.ts +7 -1
  75. package/src/components/search-bar/hooks/use-search.ts +3 -3
  76. package/src/components/user/index.ts +3 -0
  77. package/src/components/user/use-user-auth.ts +163 -0
  78. package/src/components/user/user.tsx +215 -0
  79. package/src/components/wishlist-dialogs/add-to-wishlist/AddToWishlist.stories.tsx +20 -15
  80. package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-dialog.tsx +11 -8
  81. package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-list.tsx +18 -3
  82. package/src/components/wishlist-dialogs/add-to-wishlist/index.tsx +0 -1
  83. package/src/components/wishlist-dialogs/create-wishlist/compound/create-wishlist-dialog.tsx +6 -2
  84. package/src/hooks/use-overflow-detect.ts +4 -4
  85. package/src/lib/graphqlRequestSdk.ts +0 -0
  86. package/src/lib/page-slugs.ts +16 -0
  87. package/src/pages/account/account-navigation.tsx +7 -14
  88. package/src/pages/account/account-routes.ts +4 -9
  89. package/src/pages/account/addresses/address-form.tsx +38 -16
  90. package/src/pages/account/addresses/address-item.tsx +18 -8
  91. package/src/pages/account/dashboard-header.tsx +20 -13
  92. package/src/pages/account/orders/order-history-filters-panel.tsx +21 -20
  93. package/src/pages/account/orders/order-history-header.tsx +14 -4
  94. package/src/pages/account/orders/order-history-root.tsx +3 -1
  95. package/src/pages/account/orders/order-history-row.tsx +12 -4
  96. package/src/pages/account/orders/order-history-search-and-filter.tsx +88 -59
  97. package/src/pages/account/orders/order-history-search-dropdown.tsx +26 -9
  98. package/src/pages/account/orders/order-history-sort.tsx +3 -1
  99. package/src/pages/account/orders/order-history-table.tsx +27 -5
  100. package/src/pages/account/orders/order-progress.tsx +12 -6
  101. package/src/pages/account/orders/order-row.tsx +18 -6
  102. package/src/pages/account/orders/order-summary.tsx +24 -8
  103. package/src/pages/account/orders/ordered-products.tsx +15 -5
  104. package/src/pages/account/profile/user-info.tsx +11 -3
  105. package/src/pages/account/table-row-skeleton.tsx +1 -1
  106. package/src/pages/account/wishlist/create-new-list.tsx +33 -9
  107. package/src/pages/account/wishlist/wishlist-item.tsx +18 -6
  108. package/src/pages/cart/cart-item.tsx +21 -7
  109. package/src/pages/cart/checkout-form.tsx +55 -22
  110. package/src/pages/cart/coupon-entry.tsx +143 -36
  111. package/src/pages/cart/estimate-shipping.tsx +45 -21
  112. package/src/pages/checkout/checkout-alt-form/checkout-form.tsx +22 -14
  113. package/src/pages/checkout/checkout-alt-form/checkout-step.tsx +37 -16
  114. package/src/pages/checkout/checkout-alt-form/steps/address/address-card.tsx +9 -3
  115. package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +84 -51
  116. package/src/pages/checkout/checkout-alt-form/steps/address/new-address-form.tsx +41 -19
  117. package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +17 -10
  118. package/src/pages/checkout/checkout-alt-form/steps/payment/payment-details.tsx +15 -6
  119. package/src/pages/checkout/checkout-alt-form/steps/shipping/delivery-options.tsx +6 -2
  120. package/src/pages/checkout/checkout-alt-summary/cart-item.tsx +12 -4
  121. package/src/pages/checkout/checkout-alt-summary/checkout-alt-summary.tsx +12 -5
  122. package/src/pages/checkout/checkout-alt-summary/list-item.tsx +17 -10
  123. package/src/pages/confirmation/address.tsx +6 -2
  124. package/src/pages/confirmation/confirmation-summary.tsx +12 -4
  125. package/src/pages/confirmation/ordered-products.tsx +12 -4
  126. package/src/pages/product-details/bopis/store-card/shop-card.tsx +2 -2
  127. package/src/pages/product-details/bopis/store-card/utils/getDaysResources.ts +1 -1
  128. package/src/pages/product-details/product-intro/compound/product-info.tsx +41 -18
  129. package/src/pages/product-details/product-intro/compound/product-intro-images.tsx +18 -12
  130. package/src/pages/product-details/product-intro/compound/product-intro.tsx +20 -10
  131. package/src/pages/product-details/product-intro/compound/thumbnail-with-skeleton.tsx +1 -1
  132. package/src/pages/product-details/product-tabs.tsx +20 -12
  133. package/src/pages/product-list/product-list-view.tsx +4 -2
  134. package/src/pages/product-list/search-bar.tsx +7 -7
  135. package/src/pages/product-list/side-nav.tsx +5 -3
  136. package/src/pages/quickorder/order-upload.tsx +1 -1
  137. package/src/pages/quickorder/provider.tsx +26 -14
  138. package/src/pages/quickorder/quick-order.tsx +49 -22
  139. package/src/providers/nav-provider/index.tsx +16 -11
  140. package/tsconfig.json +0 -5
  141. package/src/components/header/components/user.tsx +0 -322
  142. /package/src/components/{header → user}/__tests__/user.test.tsx +0 -0
@@ -0,0 +1,880 @@
1
+ # Authentication Architecture: Better Auth & Microsoft Entra External ID
2
+
3
+ This document describes the authentication system used in the E4 Storefront, which combines [Better Auth](https://betterauth.dev/) with Microsoft Entra External ID for secure, enterprise-grade user authentication.
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Overview](#overview)
8
+ 2. [Architecture](#architecture)
9
+ 3. [Better Auth Configuration](#better-auth-configuration)
10
+ 4. [Microsoft Entra External ID Setup](#microsoft-entra-external-id-setup)
11
+ 5. [Native Screen Authentication (ROPC)](#native-screen-authentication-ropc)
12
+ 6. [Token Management](#token-management)
13
+ 7. [Session Management](#session-management)
14
+ 8. [Client-Side Usage](#client-side-usage)
15
+ 9. [API Integration](#api-integration)
16
+ 10. [Deployment & Environment Configuration](#deployment--environment-configuration)
17
+ 11. [Troubleshooting](#troubleshooting)
18
+
19
+ ## Overview
20
+
21
+ The authentication system uses:
22
+ - **Better Auth**: A modern authentication library for TypeScript/JavaScript applications
23
+ - **Microsoft Entra External ID**: Enterprise identity provider for external user authentication
24
+ - **PostgreSQL**: Database for storing user accounts and sessions
25
+ - **OAuth 2.0 with PKCE**: Secure authorization flow for browser-based applications
26
+ - **Resource Owner Password Credential (ROPC)**: Support for native screen (email/password) login
27
+
28
+ ### Key Features
29
+
30
+ - ✅ OAuth 2.0 / OpenID Connect support
31
+ - ✅ Native email/password login (via Microsoft Entra ROPC)
32
+ - ✅ Automatic token refresh before expiration
33
+ - ✅ Proactive token refresh (5-minute window before expiry)
34
+ - ✅ Secure session management with HTTP-only cookies
35
+ - ✅ Support for multiple identity providers
36
+ - ✅ Graph API access tokens for Microsoft integration
37
+
38
+ ## Architecture
39
+
40
+ ```
41
+ ┌─────────────────────────────────────────────────────┐
42
+ │ Client Application (React/Next.js) │
43
+ ├─────────────────────────────────────────────────────┤
44
+ │ Auth Client │
45
+ │ (createAuthClient from better-auth/react) │
46
+ └────────────────────┬────────────────────────────────┘
47
+ │ HTTP/REST
48
+
49
+ ┌─────────────────────────────────────────────────────┐
50
+ │ Better Auth API Handler │
51
+ │ (/api/auth/[...all] - Next.js Dynamic Route) │
52
+ ├─────────────────────────────────────────────────────┤
53
+ │ • Session management │
54
+ │ • OAuth flow orchestration │
55
+ │ • Token storage & encryption │
56
+ │ • Proactive token refresh │
57
+ └────────────────────┬────────────────────────────────┘
58
+ │ │ │
59
+ ▼ ▼ ▼
60
+ ┌─────────┬──────────┬──────────────┐
61
+ │ PostgreSQL │ Microsoft │
62
+ │ Database │ Entra External │
63
+ │ (Sessions, │ ID (OIDC/OAuth2) │
64
+ │ Accounts, │ │
65
+ │ Users) │ │
66
+ └───────────────────┴──────────────────┘
67
+ ```
68
+
69
+ ## Better Auth Configuration
70
+
71
+ ### Core Setup
72
+
73
+ Better Auth is initialized in [better-auth.ts](./better-auth.ts) with the following configuration:
74
+
75
+ ```typescript
76
+ const authConfig = {
77
+ secret: process.env.BETTER_AUTH_SECRET,
78
+ baseURL: process.env.BETTER_AUTH_URL,
79
+ database: authDbPool, // PostgreSQL connection
80
+
81
+ // Enable email/password authentication
82
+ emailAndPassword: {
83
+ enabled: true,
84
+ },
85
+
86
+ // Session configuration
87
+ session: {
88
+ cookieCache: {
89
+ enabled: false, // Disable cache for fresh data
90
+ },
91
+ },
92
+
93
+ // Account storage
94
+ account: {
95
+ storeStateStrategy: "cookie",
96
+ storeAccountCookie: true,
97
+ },
98
+
99
+ plugins: [
100
+ // Generic OAuth provider for Microsoft Entra External ID
101
+ genericOAuth({ ... }),
102
+
103
+ // Custom session enrichment with token management
104
+ customSession({ ... }),
105
+
106
+ // Next.js cookie handling
107
+ nextCookies(),
108
+ ],
109
+ };
110
+ ```
111
+
112
+ ### Database Requirements
113
+
114
+ Better Auth requires a PostgreSQL database with the following tables:
115
+
116
+ - `public.user`: User profiles
117
+ - `public.account`: OAuth account linked to users (stores encrypted tokens)
118
+ - `public.session`: Active user sessions
119
+ - `public.verification`: Email verification tokens
120
+
121
+ For setup, see the [e4-Bootstrap database schema](../../../db/schema.sql).
122
+
123
+ ## Microsoft Entra External ID Setup
124
+
125
+ ### What is Entra External ID?
126
+
127
+ Microsoft Entra External ID (formerly Azure AD B2C) is a Customer Identity and Access Management (CIAM) solution that enables:
128
+ - External user registration and authentication
129
+ - Social login integration
130
+ - Custom branding and policies
131
+ - API protection with access tokens
132
+
133
+ ### Configuration
134
+
135
+ #### Environment Variables
136
+
137
+ ```bash
138
+ # Entra External ID tenant configuration
139
+ MICROSOFT_ENTRA_EXTERNAL_ID_CLIENT_ID=f6cf50bc-8d8c-4e73-be82-cc29a5ee3bb9
140
+ MICROSOFT_ENTRA_EXTERNAL_ID_TENANT_NAME=e4productdeventraexternalid
141
+ MICROSOFT_ENTRA_EXTERNAL_ID_CUSTOM_SCOPES="offline_access openid email profile User.ReadWrite"
142
+ # Scope Meanings:
143
+ # offline_access: Enables refresh token grant
144
+ # openid: Returns ID token with user claims
145
+ # email: Includes email claim in ID token
146
+ # profile: Includes profile claims (name, picture, etc.)
147
+ # User.ReadWrite: Microsoft Graph permission to read/write user profile
148
+
149
+ # Better Auth configuration
150
+ BETTER_AUTH_SECRET=dB8GBZaXL0TrSZpKVhpIOxyMnJ15ALtrtPsMOyIqDvw=
151
+ BETTER_AUTH_URL=http://storefront.e4.local # or production URL
152
+ AUTH_DB_URL=postgresql://user:password@host:port/database
153
+ ```
154
+
155
+ #### OAuth Discovery
156
+
157
+ Better Auth uses Entra's OpenID Connect discovery endpoint:
158
+
159
+ ```
160
+ https://{TENANT_NAME}.ciamlogin.com/{TENANT_NAME}.onmicrosoft.com/v2.0/.well-known/openid-configuration
161
+ ```
162
+
163
+ This endpoint provides:
164
+ - Authorization endpoint
165
+ - Token endpoint
166
+ - JWKS (JSON Web Key Set) for token verification
167
+ - User info endpoint
168
+ - Supported scopes and claims
169
+
170
+ #### Provider Configuration
171
+
172
+ ```typescript
173
+ {
174
+ providerId: "entra-external-id",
175
+ clientId: process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_ID,
176
+ discoveryUrl: `${loginAuthority}/v2.0/.well-known/openid-configuration`,
177
+ scopes: [
178
+ "offline_access", // Required for refresh tokens
179
+ "openid", // Required for ID tokens
180
+ "email", // Access email claim
181
+ "profile", // Access profile claims (name, picture, etc.)
182
+ "User.ReadWrite", // Microsoft Graph permission
183
+ ],
184
+ pkce: true, // Proof Key for Public Clients (required)
185
+ prompt: "login", // Always show login screen
186
+ }
187
+ ```
188
+
189
+ **Important**: Entra External ID is configured as a **public client** (no client secret), so PKCE is mandatory for security.
190
+
191
+ #### User Info Extraction
192
+
193
+ The `getUserInfo` function decodes the ID token's JWT payload to extract user claims:
194
+
195
+ ```typescript
196
+ getUserInfo: async (tokens) => {
197
+ const idToken = tokens.idToken;
198
+ const payload = JSON.parse(
199
+ Buffer.from(idToken.split(".")[1], "base64url").toString()
200
+ );
201
+
202
+ return {
203
+ id: payload.oid || payload.sub, // Object ID or Subject
204
+ name: payload.name, // User's display name
205
+ email: payload.email || payload.emails?.[0],
206
+ emailVerified: !!payload.email_verified,
207
+ };
208
+ };
209
+ ```
210
+
211
+ **Key Claims:**
212
+ - `oid`: Unique object ID in Entra External ID (preferred for user identification)
213
+ - `sub`: Subject claim (fallback)
214
+ - `email`: Primary email address
215
+ - `email_verified`: Whether email is verified
216
+ - `name`: User's display name
217
+
218
+ ## Native Screen Authentication (ROPC)
219
+
220
+ ### What is ROPC?
221
+
222
+ Resource Owner Password Credential (ROPC) is an OAuth 2.0 flow designed for trusted applications where users enter credentials directly into the application UI (native screens).
223
+
224
+ ### How It Works in E4
225
+
226
+ ```
227
+ ┌──────────────────────────────┐
228
+ │ User Enters Email/Password │
229
+ │ in Native Login Screen │
230
+ └──────────────┬───────────────┘
231
+
232
+
233
+ ┌────────────────────────┐
234
+ │ Entra ROPC Endpoint: │
235
+ │ POST /token │
236
+ │ - client_id │
237
+ │ - username (email) │
238
+ │ - password │
239
+ │ - scope │
240
+ └────────────┬───────────┘
241
+
242
+
243
+ ┌────────────────────────┐
244
+ │ Validate credentials │
245
+ │ Return: │
246
+ │ - access_token │
247
+ │ - refresh_token │
248
+ │ - id_token │
249
+ │ - expires_in │
250
+ └────────────┬───────────┘
251
+
252
+
253
+ ┌────────────────────────────┐
254
+ │ Better Auth: │
255
+ │ 1. Create email/password │
256
+ │ user in local DB │
257
+ │ 2. Upsert Entra account │
258
+ │ with OAuth tokens │
259
+ │ 3. Create session │
260
+ └────────────────────────────┘
261
+ ```
262
+
263
+ ### Implementation Notes
264
+
265
+ - **Email/Password User**: Better Auth stores a local email/password user for session management
266
+ - **Entra Account**: The actual OAuth tokens from Entra are stored in a separate account record
267
+ - **Account Linking**: Email/password user is linked to Entra account via user ID
268
+ - **Token Storage**: OAuth tokens are encrypted before storage using `setTokenUtil`
269
+
270
+ ### Security Considerations
271
+
272
+ ✅ **Advantages:**
273
+ - No redirect required (native UI)
274
+ - Better UX for trusted applications
275
+ - Direct password validation with Entra
276
+
277
+ ⚠️ **Implemented controls:**
278
+ - HTTPS only in production
279
+ - Secure password input (masked in UI)
280
+ - Rate limiting on the token endpoint
281
+ - Token encryption at rest in the database via Better Auth token helpers
282
+
283
+ These controls are already part of the authentication stack and should be treated as baseline behavior, not future work.
284
+
285
+ ## Token Management
286
+
287
+ ### Token Types
288
+
289
+ | Token | Purpose | Scope | Lifetime |
290
+ |-------|---------|-------|----------|
291
+ | **ID Token** | User identity (JWT) | OpenID, profile | ~1 hour |
292
+ | **Access Token** | API authorization (Graph API) | User.ReadWrite | ~1 hour |
293
+ | **Refresh Token** | Obtain new access tokens | offline_access | ~90 days |
294
+
295
+ ### Token Storage
296
+
297
+ Tokens are stored in the `public.account` table:
298
+
299
+ ```typescript
300
+ {
301
+ id: string, // Account ID
302
+ userId: string, // Linked user ID
303
+ providerId: string, // "entra-external-id"
304
+ accessToken: string, // Encrypted access token
305
+ refreshToken: string, // Encrypted refresh token
306
+ idToken: string | null, // Encrypted ID token
307
+ accessTokenExpiresAt: Date, // Absolute expiration time
308
+ refreshTokenExpiresAt: Date, // Refresh token expiration
309
+ scope: string, // Comma-separated scopes
310
+ }
311
+ ```
312
+
313
+ **Token Encryption:**
314
+ - Uses Better Auth's `setTokenUtil()` to encrypt sensitive tokens
315
+ - Uses `decryptOAuthToken()` when reading from database
316
+ - Prevents plain-text token exposure in database
317
+
318
+ ### Proactive Token Refresh
319
+
320
+ Better Auth implements **proactive refresh** to prevent users from being unexpectedly logged out:
321
+
322
+ ```typescript
323
+ const ACCESS_TOKEN_REFRESH_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
324
+
325
+ // Before returning the session, check if:
326
+ // 1. Token exists and is not yet expired
327
+ // 2. Token is within 5 minutes of expiration
328
+ // 3. A refresh token is available
329
+
330
+ if (shouldRefreshUpfront && account.refreshToken) {
331
+ const refreshed = await refreshEntraTokens(account, context);
332
+ // Update stored tokens and session
333
+ }
334
+ ```
335
+
336
+ **Why 5 Minutes?**
337
+ - Better Auth's default (~5 seconds) is too late; token may expire during API calls
338
+ - 5-minute window gives buffer for network latency and processing time
339
+ - Balances security (frequent refresh) with performance (not too frequent)
340
+
341
+ ### Token Refresh Flow
342
+
343
+ ```
344
+ Session Request
345
+
346
+
347
+ Check Token Status
348
+ │ │
349
+ ├─ Expired → Clear tokens, user signs out
350
+
351
+ ├─ Within 5 min → Attempt proactive refresh
352
+ │ │
353
+ │ ├─ Has refresh token?
354
+ │ │ │
355
+ │ │ ├─ Yes → Call Entra /token endpoint
356
+ │ │ │ Update stored tokens
357
+ │ │ │
358
+ │ │ └─ No → Keep current token
359
+ │ │
360
+ │ └─ Refresh fails? → Use current token (graceful degradation)
361
+
362
+ └─ Valid (>5 min) → Return current tokens
363
+
364
+
365
+ Return Session with Tokens
366
+ ```
367
+
368
+ ### Manual Token Refresh
369
+
370
+ When needed, manually refresh tokens using the provider's `refreshAccessToken()`:
371
+
372
+ ```typescript
373
+ const provider = await resolveEntraRefreshProvider(socialProviders);
374
+ const newTokens = await provider.refreshAccessToken(currentRefreshToken);
375
+ ```
376
+
377
+ ## Session Management
378
+
379
+ ### Session Lifecycle
380
+
381
+ ```
382
+ 1. User logs in → Create session
383
+ - Better Auth creates session record
384
+ - Sets secure HTTP-only cookie
385
+ - Returns session data
386
+
387
+ 2. User makes requests → Validate & refresh session
388
+ - Middleware checks cookie
389
+ - Session data retrieved from database
390
+ - Proactive token refresh attempted if needed
391
+
392
+ 3. User logs out → Clear session
393
+ - Session record deleted
394
+ - Cookie cleared on client
395
+ - User redirect to home page
396
+ ```
397
+
398
+ ### Session Data Structure
399
+
400
+ ```typescript
401
+ interface Session {
402
+ id: string;
403
+ userId: string;
404
+ expiresAt: Date;
405
+ token: string;
406
+ ipAddress?: string;
407
+ userAgent?: string;
408
+
409
+ // Custom fields from customSession plugin
410
+ accessToken: string; // ID token for D365
411
+ graphAccessToken: string; // Access token for Microsoft Graph
412
+ }
413
+ ```
414
+
415
+ ### Session Security
416
+
417
+ - **HTTP-only Cookies**: Session token stored in HTTP-only cookie (not accessible by JavaScript)
418
+ - **Secure Flag**: Cookies only sent over HTTPS in production
419
+ - **SameSite**: Prevents CSRF attacks
420
+ - **Session Expiration**: Configurable timeout (default: 30 days)
421
+ - **Token Encryption**: Refresh tokens encrypted in database
422
+
423
+ ### Session Validation in Middleware
424
+
425
+ ```typescript
426
+ // In Next.js proxy (src/proxy.ts)
427
+ const sessionCookie = getSessionCookie(request);
428
+
429
+ if (!sessionCookie) {
430
+ // No session: redirect to login
431
+ return NextResponse.redirect(new URL("/login", request.url));
432
+ }
433
+
434
+ // Session exists: allow request
435
+ return intlMiddleware(request);
436
+ ```
437
+
438
+ ## Client-Side Usage
439
+
440
+ ### Auth Client Setup
441
+
442
+ [auth-client.ts](./lib/auth-client.ts):
443
+
444
+ ```typescript
445
+ import { createAuthClient } from "better-auth/react";
446
+ import { genericOAuthClient, customSessionClient } from "better-auth/client/plugins";
447
+
448
+ export const authClient = createAuthClient({
449
+ plugins: [
450
+ genericOAuthClient(),
451
+ customSessionClient<typeof auth>(),
452
+ ],
453
+ });
454
+
455
+ export const { signIn, signOut, signUp, useSession, getSession } = authClient;
456
+ ```
457
+
458
+ ### Sign In with OAuth (Redirect Flow)
459
+
460
+ ```typescript
461
+ import { signIn } from "@storefront/auth/auth-client";
462
+
463
+ const handleOAuthLogin = async () => {
464
+ await signIn.social({
465
+ provider: "entra-external-id",
466
+ callbackURL: "/account", // Redirect after login
467
+ });
468
+ };
469
+
470
+ // Button
471
+ <button onClick={handleOAuthLogin}>Sign in with Entra</button>
472
+ ```
473
+
474
+ ### Sign In with Native Screen (Email/Password)
475
+
476
+ ```typescript
477
+ const handleNativeLogin = async (email: string, password: string) => {
478
+ const response = await signIn.password({
479
+ email,
480
+ password,
481
+ callbackURL: "/account",
482
+ });
483
+
484
+ if (!response.ok) {
485
+ // Handle errors: invalid email/password, etc.
486
+ console.error(response.error?.message);
487
+ }
488
+ };
489
+ ```
490
+
491
+ ### Use Session Hook
492
+
493
+ ```typescript
494
+ import { useSession } from "@storefront/auth/auth-client";
495
+
496
+ function UserProfile() {
497
+ const { data: session, isPending } = useSession();
498
+
499
+ if (isPending) return <div>Loading...</div>;
500
+
501
+ if (!session) return <div>Not signed in</div>;
502
+
503
+ return (
504
+ <div>
505
+ <h1>Hello, {session.user.name}</h1>
506
+ <p>Email: {session.user.email}</p>
507
+
508
+ {/* Custom session fields from customSession plugin */}
509
+ <p>Graph Access Token: {session.accessToken}</p>
510
+ </div>
511
+ );
512
+ }
513
+ ```
514
+
515
+ ### Sign Out
516
+
517
+ ```typescript
518
+ import signOut from "@storefront/auth/signout";
519
+
520
+ const handleLogout = async () => {
521
+ await signOut(); // Clears session, redirects to home
522
+ };
523
+ ```
524
+
525
+ ### Get Session Directly
526
+
527
+ ```typescript
528
+ import { getSession } from "@storefront/auth/auth-client";
529
+
530
+ // In server component or API route
531
+ const session = await getSession();
532
+
533
+ if (session) {
534
+ // User authenticated
535
+ // Use session.accessToken for Graph API calls
536
+ } else {
537
+ // User not authenticated
538
+ }
539
+ ```
540
+
541
+ ### Session Hook with Session Data
542
+
543
+ ```typescript
544
+ import { useSession } from "@storefront/auth/auth-client";
545
+
546
+ function GraphAPIConsumer() {
547
+ const { data: session } = useSession();
548
+
549
+ // Use graph access token for Microsoft Graph API
550
+ const graphToken = session?.graphAccessToken;
551
+
552
+ const callGraphAPI = async () => {
553
+ const response = await fetch("https://graph.microsoft.com/v1.0/me", {
554
+ headers: {
555
+ Authorization: `Bearer ${graphToken}`,
556
+ },
557
+ });
558
+ const profile = await response.json();
559
+ return profile;
560
+ };
561
+
562
+ return <button onClick={callGraphAPI}>Load Profile from Graph</button>;
563
+ }
564
+ ```
565
+
566
+ ## API Integration
567
+
568
+ ### API Route Handler
569
+
570
+ [/api/auth/[...all]/route.ts](../../../apps/storefront/src/app/api/auth/%5B...all%5D/route.ts):
571
+
572
+ ```typescript
573
+ import { auth } from "@storefront/lib/auth";
574
+ import { toNextJsHandler } from "better-auth/next-js";
575
+
576
+ export const { GET, POST } = toNextJsHandler(auth);
577
+ ```
578
+
579
+ **Endpoints Provided:**
580
+ - `POST /api/auth/sign-in` - Sign in with email/password
581
+ - `POST /api/auth/sign-out` - Sign out
582
+ - `POST /api/auth/sign-up` - Register new account
583
+ - `GET /api/auth/callback` - OAuth redirect callback
584
+ - `POST /api/auth/callback` - OAuth callback (POST)
585
+ - `POST /api/auth/refresh` - Manual session refresh
586
+ - `GET /api/auth/session` - Get current session (client-side)
587
+
588
+ ### Graph API Access Example
589
+
590
+ ```typescript
591
+ async function getUserProfileFromGraph(accessToken: string) {
592
+ const response = await fetch("https://graph.microsoft.com/v1.0/me", {
593
+ headers: {
594
+ Authorization: `Bearer ${accessToken}`,
595
+ "Content-Type": "application/json",
596
+ },
597
+ });
598
+
599
+ if (!response.ok) {
600
+ throw new Error(`Graph API error: ${response.statusText}`);
601
+ }
602
+
603
+ return response.json();
604
+ }
605
+
606
+ // In API route
607
+ export async function GET(request: Request) {
608
+ const session = await getSession();
609
+
610
+ if (!session?.graphAccessToken) {
611
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
612
+ }
613
+
614
+ const profile = await getUserProfileFromGraph(session.graphAccessToken);
615
+ return Response.json(profile);
616
+ }
617
+ ```
618
+
619
+ ## Deployment & Environment Configuration
620
+
621
+ ### Environment Variables
622
+
623
+ See [.env.example](../../../../../.env.example) for full list.
624
+
625
+ **Critical for Authentication:**
626
+
627
+ ```bash
628
+ # Better Auth
629
+ BETTER_AUTH_SECRET=<generated-secret-key-32-chars>
630
+ BETTER_AUTH_URL=https://api.example.com # Public URL of API
631
+
632
+ # Database
633
+ AUTH_DB_URL=postgresql://user:pass@host:port/auth_db
634
+
635
+ # Microsoft Entra External ID
636
+ MICROSOFT_ENTRA_EXTERNAL_ID_CLIENT_ID=<client-id>
637
+ MICROSOFT_ENTRA_EXTERNAL_ID_TENANT_NAME=<tenant-name>
638
+ MICROSOFT_ENTRA_EXTERNAL_ID_CUSTOM_SCOPES="offline_access openid email profile User.ReadWrite"
639
+
640
+ # Public (safe for client-side)
641
+ NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_ID=<same-as-above>
642
+ NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CUSTOM_SCOPES="offline_access openid email profile"
643
+ NEXT_PUBLIC_ENTRA_EXTERNAL_ID_TENANT_NAME=<tenant-name>
644
+ ```
645
+
646
+ ### Docker Compose
647
+
648
+ [compose.yml](../../../../../compose.yml) environment variables:
649
+
650
+ ```yaml
651
+ services:
652
+ storefront:
653
+ environment:
654
+ - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
655
+ - BETTER_AUTH_URL=${BETTER_AUTH_URL}
656
+ - AUTH_DB_URL=${AUTH_DB_URL}
657
+ - MICROSOFT_ENTRA_EXTERNAL_ID_CLIENT_ID=${MICROSOFT_ENTRA_EXTERNAL_ID_CLIENT_ID}
658
+ - MICROSOFT_ENTRA_EXTERNAL_ID_TENANT_NAME=${MICROSOFT_ENTRA_EXTERNAL_ID_TENANT_NAME}
659
+ - NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_ID=${MICROSOFT_ENTRA_EXTERNAL_ID_CLIENT_ID}
660
+ ```
661
+
662
+ ## Troubleshooting
663
+
664
+ ### Common Issues
665
+
666
+ #### 1. "Session not found" or constant redirects to login
667
+
668
+ **Possible Causes:**
669
+ - `BETTER_AUTH_SECRET` changed or missing
670
+ - Database connection lost
671
+ - Session database schema not initialized
672
+ - Cookie domain mismatch
673
+
674
+ **Solutions:**
675
+ 1. Verify `BETTER_AUTH_SECRET` is consistent across deployments
676
+ 2. Check database connectivity: `AUTH_DB_URL`
677
+ 3. Verify session tables exist: `public.session`, `public.user`, `public.account`
678
+ 4. Check cookie domain in compose.yml matches application URL
679
+
680
+ #### 2. "Invalid OAuth provider" or OAuth redirect not working
681
+
682
+ **Possible Causes:**
683
+ - `MICROSOFT_ENTRA_EXTERNAL_ID_CLIENT_ID` incorrect or missing
684
+ - `MICROSOFT_ENTRA_EXTERNAL_ID_TENANT_NAME` incorrect
685
+ - Entra tenant not configured to allow the redirect URI
686
+ - Discovery URL unreachable
687
+
688
+ **Solutions:**
689
+ 1. Verify Client ID matches Entra External ID app registration
690
+ 2. Verify Tenant Name is correct (without ".onmicrosoft.com")
691
+ 3. Add redirect URI to Entra app registration: `https://your-domain.com/api/auth/callback/entra-external-id`
692
+ 4. Test discovery endpoint: `https://{tenant}.ciamlogin.com/{tenant}.onmicrosoft.com/v2.0/.well-known/openid-configuration`
693
+
694
+ #### 3. Token refresh fails: "Refresh token invalid" or "Provider not found"
695
+
696
+ **Possible Causes:**
697
+ - Entra provider not configured in `socialProviders`
698
+ - Refresh token expired (90+ days old)
699
+ - Stored refresh token corrupted or incorrectly decrypted
700
+
701
+ **Solutions:**
702
+ 1. Verify `genericOAuth` plugin configured with `entra-external-id` provider
703
+ 2. Check refresh token expiration in database
704
+ 3. Clear browser cookies and re-authenticate
705
+ 4. Check database for corruption: `SELECT scope FROM public.account WHERE providerId = 'entra-external-id' LIMIT 1`
706
+
707
+ #### 4. Native screen login not working: "Invalid credentials" or "User not found"
708
+
709
+ **Possible Causes:**
710
+ - Email/password not enabled: `emailAndPassword.enabled = false`
711
+ - Entra ROPC not configured
712
+ - Entra tenant policy disables password authentication
713
+ - User not created in Entra External ID
714
+
715
+ **Solutions:**
716
+ 1. Verify `emailAndPassword.enabled: true` in auth config
717
+ 2. Test Entra ROPC endpoint directly with curl:
718
+ ```bash
719
+ curl -X POST https://{tenant}.ciamlogin.com/{tenant}.onmicrosoft.com/oauth2/v2.0/token \
720
+ -H "Content-Type: application/x-www-form-urlencoded" \
721
+ -d "client_id={clientId}&username={email}&password={password}&scope=offline_access&grant_type=password"
722
+ ```
723
+ 3. Check Entra user properties: email, enabled, password set
724
+ 4. Verify user policies allow password authentication
725
+
726
+ #### 5. "Cannot find module 'better-auth'" or import errors
727
+
728
+ **Possible Causes:**
729
+ - `better-auth` not installed in storefront package
730
+ - Node modules not installed
731
+ - TypeScript cache issues
732
+
733
+ **Solutions:**
734
+ 1. Ensure `better-auth` in storefront `package.json` dependencies
735
+ 2. Run `npm install` in storefront and root
736
+ 3. Delete `node_modules` and `.next` cache: `rm -rf node_modules .next && npm install`
737
+ 4. Restart TypeScript server in VS Code
738
+
739
+ #### 6. Graph API calls fail: "401 Unauthorized" or "Invalid access token"
740
+
741
+ **Possible Causes:**
742
+ - Access token expired and proactive refresh didn't occur
743
+ - Graph API scopes not requested in Entra
744
+ - Access token is ID token instead
745
+
746
+ **Solutions:**
747
+ 1. Verify `session.graphAccessToken` is not empty
748
+ 2. Add `User.ReadWrite` to `MICROSOFT_ENTRA_EXTERNAL_ID_CUSTOM_SCOPES`
749
+ 3. Check token type: decode JWT at [jwt.io](https://jwt.io) and verify `aud` claim is Microsoft Graph
750
+ 4. Force token refresh: implement manual refresh in session logic
751
+
752
+
753
+ ### Testing Authentication Locally
754
+
755
+ 1. **Pre-check local config (required before testing):**
756
+ ```bash
757
+ # Verify the auth env vars are present
758
+ grep -E "NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_ID|NEXT_PUBLIC_ENTRA_EXTERNAL_ID_TENANT_NAME|BETTER_AUTH_SECRET|AUTH_DB_URL" .env
759
+
760
+ # Start docker container and confirm it is healthy
761
+ # Open http://storefront.e4.local:3000
762
+ ```
763
+ Expected result:
764
+ - Storefront loads without auth-related boot errors.
765
+ - No `Missing BETTER_AUTH_SECRET` or DB connection errors in server logs.
766
+
767
+ 2. **Test storefront signup and login flow:**
768
+ ```bash
769
+ # Go to https://storefront.e4.local/login
770
+ # Sign up with a real email address
771
+ # Enter the verification code sent to the email inbox
772
+ # After verification, the user should be signed in automatically
773
+ ```
774
+ Validation checklist:
775
+ - After code verification, confirm the app lands on the authenticated session without a second login prompt.
776
+ - Open the storefront and verify catalog flows still work: Categories, PLP, and PDP pages should load normally while signed in.
777
+ - Go to the account area and open the profile page to confirm the signed-in user's details are shown.
778
+ - In browser DevTools Network tab, confirm the auth calls return 200/204 (not 401/403).
779
+ - In DB, confirm an `entra-external-id` account exists for the user:
780
+ ```sql
781
+ SELECT u.email, a.providerId, a.accessTokenExpiresAt, a.refreshTokenExpiresAt
782
+ FROM public.account a
783
+ JOIN public.user u ON u.id = a.userId
784
+ WHERE a.providerId = 'entra-external-id'
785
+ ORDER BY a.accessTokenExpiresAt DESC
786
+ LIMIT 5;
787
+ ```
788
+
789
+ 3. **Test Session Persistence (browser refresh and new request):**
790
+ ```bash
791
+ # After successful login:
792
+ # 1) Refresh the page (F5)
793
+ # 2) Navigate to another protected page
794
+ # 3) Hard refresh (Ctrl+Shift+R / Cmd+Shift+R)
795
+ ```
796
+ Expected result:
797
+ - User remains authenticated across refreshes.
798
+ - Cookie `better-auth_session` remains present and rotates only when expected.
799
+ - No redirect loop to `/login`.
800
+
801
+ 4. **Test Proactive Token Refresh (fast feedback mode):**
802
+ ```typescript
803
+ // Temporarily reduce refresh window in better-auth.ts for local testing only
804
+ const ACCESS_TOKEN_REFRESH_WINDOW_MS = 1000; // 1 second
805
+ ```
806
+ Test steps:
807
+ - Restart the app after changing the constant.
808
+ - Sign in and keep an authenticated page open.
809
+ - Trigger one or more authenticated requests (page navigation, profile fetch, Graph call).
810
+
811
+ Expected result:
812
+ - You should see refresh activity in logs.
813
+ - Session remains valid after refresh attempt.
814
+ - `accessTokenExpiresAt` advances in `public.account` after successful refresh.
815
+
816
+ Verify in DB:
817
+ ```sql
818
+ SELECT u.email, a.accessTokenExpiresAt, a.refreshTokenExpiresAt, NOW() AS now
819
+ FROM public.account a
820
+ JOIN public.user u ON u.id = a.userId
821
+ WHERE a.providerId = 'entra-external-id'
822
+ ORDER BY a.accessTokenExpiresAt DESC
823
+ LIMIT 5;
824
+ ```
825
+
826
+ 5. **Reset test-only changes:**
827
+ ```typescript
828
+ // Restore default after testing
829
+ const ACCESS_TOKEN_REFRESH_WINDOW_MS = 5 * 60 * 1000;
830
+ ```
831
+ Do not keep a 1-second window in normal development or production.
832
+
833
+ ### Useful Database Queries
834
+
835
+ ```sql
836
+ -- List all authenticated users
837
+ SELECT u.id, u.email, u.name, u.createdAt
838
+ FROM public.user u
839
+ ORDER BY u.createdAt DESC;
840
+
841
+ -- Check Entra accounts and token status
842
+ SELECT
843
+ u.email,
844
+ a.providerId,
845
+ a.accessTokenExpiresAt,
846
+ a.refreshTokenExpiresAt,
847
+ NOW() as current_time
848
+ FROM public.account a
849
+ JOIN public.user u ON a.userId = u.id
850
+ WHERE a.providerId = 'entra-external-id'
851
+ ORDER BY a.accessTokenExpiresAt;
852
+
853
+ -- Check active sessions
854
+ SELECT
855
+ s.id,
856
+ u.email,
857
+ s.createdAt,
858
+ s.expiresAt
859
+ FROM public.session s
860
+ JOIN public.user u ON s.userId = u.id
861
+ WHERE s.expiresAt > NOW()
862
+ ORDER BY s.expiresAt DESC;
863
+
864
+ -- Clear expired sessions (cleanup)
865
+ DELETE FROM public.session
866
+ WHERE expiresAt < NOW();
867
+ ```
868
+
869
+ ## Additional Resources
870
+
871
+ - [Better Auth Documentation](https://betterauth.dev/)
872
+ - [Microsoft Entra External ID Docs](https://learn.microsoft.com/en-us/entra/external-id/)
873
+ - [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749)
874
+ - [OpenID Connect Specification](https://openid.net/connect/)
875
+ - [PKCE RFC 7636](https://tools.ietf.org/html/rfc7636)
876
+ - [JWT Debugger](https://jwt.io/)
877
+
878
+ ---
879
+
880
+ Last Updated: 2026-06-23