@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.
- package/CHANGELOG.md +7 -0
- package/package.json +90 -90
- package/src/api-manager/datasources/d365/d365-cart.datasource.ts +4 -3
- package/src/api-manager/datasources/d365/d365-user.datasource.ts +3 -3
- package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +1 -0
- package/src/api-manager/datasources/e4/address/e4-address.datasource.ts +5 -14
- package/src/api-manager/datasources/e4/address/e4-address.translator.ts +4 -3
- package/src/api-manager/datasources/e4/categories/e4-categories.translator.ts +1 -1
- package/src/api-manager/datasources/e4/graphqlRequestSdk.ts +29 -3
- package/src/api-manager/datasources/e4/product/e4-product.translator.ts +1 -1
- package/src/api-manager/index.ts +1 -1
- package/src/api-manager/lib/get-graphql-client.ts +1 -1
- package/src/api-manager/services/cart/mutations/add-discount-code.ts +0 -11
- package/src/api-manager/services/create-query.ts +13 -7
- package/src/api-manager/services/user/mutations/add-to-wishlist.ts +1 -1
- package/src/api-manager/services/user/mutations/remove-from-wishlist.ts +1 -1
- package/src/auth/AUTH GUIDE.md +880 -0
- package/src/cms/blocks/components/featured-categories.tsx +18 -15
- package/src/cms/blocks/components/featured-products.tsx +3 -2
- package/src/cms/blocks/components/footer/data/index.ts +0 -0
- package/src/cms/blocks/components/footer/index.tsx +0 -0
- package/src/cms/blocks/components/footer/sections/footer-app-store.tsx +0 -0
- package/src/cms/blocks/components/footer/sections/footer-contact.tsx +0 -2
- package/src/cms/blocks/components/footer/sections/footer-links.tsx +0 -0
- package/src/cms/blocks/components/footer/sections/footer-logo.tsx +2 -1
- package/src/cms/blocks/components/footer/sections/footer-social-links.tsx +0 -0
- package/src/cms/blocks/components/footer/styles/index.ts +0 -0
- package/src/cms/blocks/components/hero-carousel/index.tsx +0 -0
- package/src/cms/blocks/components/product-carousel/index.tsx +0 -0
- package/src/cms/blocks/components/shared/featured-product-card.tsx +0 -0
- package/src/cms/blocks/components/shared/product-category-item.tsx +0 -0
- package/src/cms/blocks/components/shared/product-grid.tsx +0 -0
- package/src/cms/blocks/components/shared/top-categories-card.tsx +0 -0
- package/src/cms/blocks/components/shared/top-rating-product-card.tsx +0 -0
- package/src/cms/blocks/icons/components/category.tsx +0 -0
- package/src/cms/blocks/icons/components/dotted-star.tsx +0 -0
- package/src/cms/blocks/icons/components/gift-box.tsx +0 -0
- package/src/cms/blocks/icons/components/light.tsx +0 -0
- package/src/cms/blocks/icons/components/new-arrival.tsx +0 -0
- package/src/cms/blocks/icons/components/rank-badge.tsx +0 -0
- package/src/components/SearchInput.stories.tsx +12 -10
- package/src/components/base-layout.tsx +42 -0
- package/src/components/categories/category-list/category-list.tsx +32 -12
- package/src/components/header/__tests__/header.test.tsx +0 -24
- package/src/components/header/components/mobile-header.tsx +16 -8
- package/src/components/header/header.tsx +6 -3
- package/src/components/mini-cart/components/bottom-actions.tsx +7 -2
- package/src/components/mini-cart/components/cart-item.tsx +16 -13
- package/src/components/mini-cart/components/empty-view.tsx +12 -6
- package/src/components/mini-cart/components/top-header.tsx +9 -8
- package/src/components/mini-cart/mini-cart-trigger.tsx +11 -5
- package/src/components/mini-cart/mini-cart.tsx +30 -15
- package/src/components/mobile-navigation/mobile-navigation-bar.tsx +6 -2
- package/src/components/navbar/categories.tsx +8 -2
- package/src/components/navbar/category-based-menu/category-based-menu.tsx +6 -2
- package/src/components/navbar/mega-menu/mega-menu.tsx +22 -14
- package/src/components/navbar/mobile-menu/mobile-menu.test.tsx +8 -11
- package/src/components/navbar/mobile-menu/mobile-menu.tsx +13 -11
- package/src/components/navbar/nav-list/nav-list.tsx +3 -2
- package/src/components/navbar/navbar.tsx +4 -2
- package/src/components/product-cards/product-card/product-card.tsx +50 -26
- package/src/components/product-cards/product-card-plp/product-card.tsx +37 -16
- package/src/components/product-cards/product-card-plp-list/product-card.tsx +38 -16
- package/src/components/product-cards/product-card-search/product-card.tsx +34 -13
- package/src/components/product-dialog/ProductDialog.stories.tsx +20 -18
- package/src/components/product-dialog/compound/product-dialog.tsx +7 -4
- package/src/components/product-dimensions/compound/dimensions-group.tsx +4 -2
- package/src/components/product-dimensions/compound/dimensions.tsx +15 -6
- package/src/components/product-quantity-buttons/compound/quantity-buttons-root.tsx +21 -13
- package/src/components/products-view/compound/products-grid-view.tsx +11 -4
- package/src/components/products-view/compound/products-list-view.tsx +11 -4
- package/src/components/search-bar/compound/search-bar-root.tsx +6 -4
- package/src/components/search-bar/compound/textfield-adornment-category.tsx +25 -8
- package/src/components/search-bar/hooks/use-click-outside.ts +7 -1
- package/src/components/search-bar/hooks/use-search.ts +3 -3
- package/src/components/user/index.ts +3 -0
- package/src/components/user/use-user-auth.ts +163 -0
- package/src/components/user/user.tsx +215 -0
- package/src/components/wishlist-dialogs/add-to-wishlist/AddToWishlist.stories.tsx +20 -15
- package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-dialog.tsx +11 -8
- package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-list.tsx +18 -3
- package/src/components/wishlist-dialogs/add-to-wishlist/index.tsx +0 -1
- package/src/components/wishlist-dialogs/create-wishlist/compound/create-wishlist-dialog.tsx +6 -2
- package/src/hooks/use-overflow-detect.ts +4 -4
- package/src/lib/graphqlRequestSdk.ts +0 -0
- package/src/lib/page-slugs.ts +16 -0
- package/src/pages/account/account-navigation.tsx +7 -14
- package/src/pages/account/account-routes.ts +4 -9
- package/src/pages/account/addresses/address-form.tsx +38 -16
- package/src/pages/account/addresses/address-item.tsx +18 -8
- package/src/pages/account/dashboard-header.tsx +20 -13
- package/src/pages/account/orders/order-history-filters-panel.tsx +21 -20
- package/src/pages/account/orders/order-history-header.tsx +14 -4
- package/src/pages/account/orders/order-history-root.tsx +3 -1
- package/src/pages/account/orders/order-history-row.tsx +12 -4
- package/src/pages/account/orders/order-history-search-and-filter.tsx +88 -59
- package/src/pages/account/orders/order-history-search-dropdown.tsx +26 -9
- package/src/pages/account/orders/order-history-sort.tsx +3 -1
- package/src/pages/account/orders/order-history-table.tsx +27 -5
- package/src/pages/account/orders/order-progress.tsx +12 -6
- package/src/pages/account/orders/order-row.tsx +18 -6
- package/src/pages/account/orders/order-summary.tsx +24 -8
- package/src/pages/account/orders/ordered-products.tsx +15 -5
- package/src/pages/account/profile/user-info.tsx +11 -3
- package/src/pages/account/table-row-skeleton.tsx +1 -1
- package/src/pages/account/wishlist/create-new-list.tsx +33 -9
- package/src/pages/account/wishlist/wishlist-item.tsx +18 -6
- package/src/pages/cart/cart-item.tsx +21 -7
- package/src/pages/cart/checkout-form.tsx +55 -22
- package/src/pages/cart/coupon-entry.tsx +143 -36
- package/src/pages/cart/estimate-shipping.tsx +45 -21
- package/src/pages/checkout/checkout-alt-form/checkout-form.tsx +22 -14
- package/src/pages/checkout/checkout-alt-form/checkout-step.tsx +37 -16
- package/src/pages/checkout/checkout-alt-form/steps/address/address-card.tsx +9 -3
- package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +84 -51
- package/src/pages/checkout/checkout-alt-form/steps/address/new-address-form.tsx +41 -19
- package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +17 -10
- package/src/pages/checkout/checkout-alt-form/steps/payment/payment-details.tsx +15 -6
- package/src/pages/checkout/checkout-alt-form/steps/shipping/delivery-options.tsx +6 -2
- package/src/pages/checkout/checkout-alt-summary/cart-item.tsx +12 -4
- package/src/pages/checkout/checkout-alt-summary/checkout-alt-summary.tsx +12 -5
- package/src/pages/checkout/checkout-alt-summary/list-item.tsx +17 -10
- package/src/pages/confirmation/address.tsx +6 -2
- package/src/pages/confirmation/confirmation-summary.tsx +12 -4
- package/src/pages/confirmation/ordered-products.tsx +12 -4
- package/src/pages/product-details/bopis/store-card/shop-card.tsx +2 -2
- package/src/pages/product-details/bopis/store-card/utils/getDaysResources.ts +1 -1
- package/src/pages/product-details/product-intro/compound/product-info.tsx +41 -18
- package/src/pages/product-details/product-intro/compound/product-intro-images.tsx +18 -12
- package/src/pages/product-details/product-intro/compound/product-intro.tsx +20 -10
- package/src/pages/product-details/product-intro/compound/thumbnail-with-skeleton.tsx +1 -1
- package/src/pages/product-details/product-tabs.tsx +20 -12
- package/src/pages/product-list/product-list-view.tsx +4 -2
- package/src/pages/product-list/search-bar.tsx +7 -7
- package/src/pages/product-list/side-nav.tsx +5 -3
- package/src/pages/quickorder/order-upload.tsx +1 -1
- package/src/pages/quickorder/provider.tsx +26 -14
- package/src/pages/quickorder/quick-order.tsx +49 -22
- package/src/providers/nav-provider/index.tsx +16 -11
- package/tsconfig.json +0 -5
- package/src/components/header/components/user.tsx +0 -322
- /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
|