@nauth-toolkit/client-angular 0.1.64 → 0.1.66
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/esm2022/lib/auth-interceptor.shared.mjs +165 -0
- package/esm2022/lib/auth.interceptor.mjs +4 -263
- package/esm2022/ngmodule/auth.interceptor.class.mjs +10 -63
- package/esm2022/ngmodule/auth.service.mjs +54 -1
- package/fesm2022/nauth-toolkit-client-angular.mjs +216 -312
- package/fesm2022/nauth-toolkit-client-angular.mjs.map +1 -1
- package/lib/auth-interceptor.shared.d.ts +13 -0
- package/ngmodule/auth.service.d.ts +14 -0
- package/package.json +2 -2
|
@@ -2,7 +2,7 @@ import { NAuthErrorCode, NAuthClientError, NAuthClient } from '@nauth-toolkit/cl
|
|
|
2
2
|
export * from '@nauth-toolkit/client';
|
|
3
3
|
import * as i0 from '@angular/core';
|
|
4
4
|
import { InjectionToken, Injectable, Inject, inject, Optional, NgModule, PLATFORM_ID } from '@angular/core';
|
|
5
|
-
import { firstValueFrom, BehaviorSubject, Subject,
|
|
5
|
+
import { firstValueFrom, BehaviorSubject, Subject, from, switchMap, of, map, catchError, throwError, finalize, shareReplay } from 'rxjs';
|
|
6
6
|
import { filter } from 'rxjs/operators';
|
|
7
7
|
import * as i1 from '@angular/common/http';
|
|
8
8
|
import { HttpErrorResponse, HTTP_INTERCEPTORS, HttpClientModule, HttpClient } from '@angular/common/http';
|
|
@@ -535,7 +535,44 @@ class AuthService {
|
|
|
535
535
|
* ```
|
|
536
536
|
*/
|
|
537
537
|
async respondToChallenge(response) {
|
|
538
|
+
// #region agent log
|
|
539
|
+
fetch('http://127.0.0.1:7242/ingest/97f9fe53-6a8b-43e2-ae9b-4b2d0f725816', {
|
|
540
|
+
method: 'POST',
|
|
541
|
+
headers: { 'Content-Type': 'application/json' },
|
|
542
|
+
body: JSON.stringify({
|
|
543
|
+
location: 'auth.service.ts:respondToChallenge:entry',
|
|
544
|
+
message: 'RespondToChallenge called',
|
|
545
|
+
data: {
|
|
546
|
+
challengeType: response.type,
|
|
547
|
+
hasSession: !!response.session,
|
|
548
|
+
},
|
|
549
|
+
timestamp: Date.now(),
|
|
550
|
+
sessionId: 'debug-session',
|
|
551
|
+
hypothesisId: 'H7',
|
|
552
|
+
}),
|
|
553
|
+
}).catch(() => { });
|
|
554
|
+
// #endregion
|
|
538
555
|
const res = await this.client.respondToChallenge(response);
|
|
556
|
+
// #region agent log
|
|
557
|
+
fetch('http://127.0.0.1:7242/ingest/97f9fe53-6a8b-43e2-ae9b-4b2d0f725816', {
|
|
558
|
+
method: 'POST',
|
|
559
|
+
headers: { 'Content-Type': 'application/json' },
|
|
560
|
+
body: JSON.stringify({
|
|
561
|
+
location: 'auth.service.ts:respondToChallenge:response',
|
|
562
|
+
message: 'RespondToChallenge response received',
|
|
563
|
+
data: {
|
|
564
|
+
hasChallengeName: !!res.challengeName,
|
|
565
|
+
challengeName: res.challengeName,
|
|
566
|
+
hasAccessToken: !!res.accessToken,
|
|
567
|
+
hasRefreshToken: !!res.refreshToken,
|
|
568
|
+
hasUser: !!res.user,
|
|
569
|
+
},
|
|
570
|
+
timestamp: Date.now(),
|
|
571
|
+
sessionId: 'debug-session',
|
|
572
|
+
hypothesisId: 'H7',
|
|
573
|
+
}),
|
|
574
|
+
}).catch(() => { });
|
|
575
|
+
// #endregion
|
|
539
576
|
return this.updateChallengeState(res);
|
|
540
577
|
}
|
|
541
578
|
/**
|
|
@@ -604,6 +641,22 @@ class AuthService {
|
|
|
604
641
|
await this.client.clearStoredChallenge();
|
|
605
642
|
this.challengeSubject.next(null);
|
|
606
643
|
}
|
|
644
|
+
/**
|
|
645
|
+
* Get current access token (JSON mode only).
|
|
646
|
+
*
|
|
647
|
+
* This is primarily useful for consumers using Angular `HttpClient` directly
|
|
648
|
+
* (outside of the SDK methods) and relying on an interceptor to attach Bearer tokens.
|
|
649
|
+
*
|
|
650
|
+
* @returns Access token, or null if not available
|
|
651
|
+
*
|
|
652
|
+
* @example
|
|
653
|
+
* ```typescript
|
|
654
|
+
* const token = await this.auth.getAccessToken();
|
|
655
|
+
* ```
|
|
656
|
+
*/
|
|
657
|
+
async getAccessToken() {
|
|
658
|
+
return await this.client.getAccessToken();
|
|
659
|
+
}
|
|
607
660
|
// ============================================================================
|
|
608
661
|
// Social Authentication
|
|
609
662
|
// ============================================================================
|
|
@@ -946,20 +999,168 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
946
999
|
}] }, { type: AngularHttpAdapter }] });
|
|
947
1000
|
|
|
948
1001
|
/**
|
|
949
|
-
*
|
|
1002
|
+
* Shared interceptor logic for both:
|
|
1003
|
+
* - Functional interceptor (Angular 17+ standalone)
|
|
1004
|
+
* - Class-based interceptor (NgModule apps)
|
|
1005
|
+
*
|
|
1006
|
+
* WHY:
|
|
1007
|
+
* - Keep one implementation for cookies + json mode behavior.
|
|
1008
|
+
* - Avoid divergence between standalone and NgModule integrations.
|
|
950
1009
|
*/
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1010
|
+
// ============================================================================
|
|
1011
|
+
// Refresh state management (module-level)
|
|
1012
|
+
// ============================================================================
|
|
1013
|
+
let isRefreshing = false;
|
|
1014
|
+
const refreshTokenSubject = new BehaviorSubject(null);
|
|
1015
|
+
let refreshInFlight$ = null;
|
|
1016
|
+
const retriedRequests = new WeakSet();
|
|
954
1017
|
/**
|
|
955
1018
|
* Get CSRF token from cookie.
|
|
956
1019
|
*/
|
|
957
|
-
function getCsrfToken
|
|
1020
|
+
function getCsrfToken(cookieName) {
|
|
958
1021
|
if (typeof document === 'undefined')
|
|
959
1022
|
return null;
|
|
960
1023
|
const match = document.cookie.match(new RegExp(`(^| )${cookieName}=([^;]+)`));
|
|
961
1024
|
return match ? decodeURIComponent(match[2]) : null;
|
|
962
1025
|
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Build retry request with appropriate auth.
|
|
1028
|
+
*
|
|
1029
|
+
* In cookies mode: Browser automatically sends updated httpOnly cookies (access/refresh tokens).
|
|
1030
|
+
* We must re-read CSRF token after refresh to avoid stale headers.
|
|
1031
|
+
*
|
|
1032
|
+
* In JSON mode: Clones the request and adds the new Bearer token.
|
|
1033
|
+
*/
|
|
1034
|
+
function buildRetryRequest(originalReq, tokenDelivery, newToken, csrfConfig) {
|
|
1035
|
+
if (tokenDelivery === 'json' && newToken && newToken !== 'success') {
|
|
1036
|
+
return originalReq.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } });
|
|
1037
|
+
}
|
|
1038
|
+
if (tokenDelivery === 'cookies' && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(originalReq.method)) {
|
|
1039
|
+
const csrfCookieName = csrfConfig?.cookieName ?? 'nauth_csrf_token';
|
|
1040
|
+
const csrfHeaderName = csrfConfig?.headerName ?? 'x-csrf-token';
|
|
1041
|
+
const freshCsrfToken = getCsrfToken(csrfCookieName);
|
|
1042
|
+
if (freshCsrfToken) {
|
|
1043
|
+
return originalReq.clone({ setHeaders: { [csrfHeaderName]: freshCsrfToken } });
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
return originalReq;
|
|
1047
|
+
}
|
|
1048
|
+
function createNAuthAuthHttpInterceptor(params) {
|
|
1049
|
+
const { config, authService, router, next, req } = params;
|
|
1050
|
+
const tokenDelivery = config.tokenDelivery;
|
|
1051
|
+
const baseUrl = config.baseUrl;
|
|
1052
|
+
const endpoints = config.endpoints ?? {};
|
|
1053
|
+
const authPathPrefix = config.authPathPrefix;
|
|
1054
|
+
// Build refresh path with authPathPrefix if configured (matches core client buildUrl logic exactly)
|
|
1055
|
+
// Use default '/refresh' if endpoints.refresh is not defined
|
|
1056
|
+
const refreshPath = endpoints?.refresh ?? '/refresh';
|
|
1057
|
+
const normalizedRefreshPath = refreshPath.startsWith('/') ? refreshPath : `/${refreshPath}`;
|
|
1058
|
+
// Check if baseUrl already ends with authPathPrefix to avoid double-prefixing
|
|
1059
|
+
// This must match the core client's buildUrl logic exactly
|
|
1060
|
+
const baseUrlEndsWithPrefix = authPathPrefix && baseUrl.endsWith(authPathPrefix);
|
|
1061
|
+
const shouldAddPrefix = authPathPrefix && !baseUrlEndsWithPrefix && !normalizedRefreshPath.startsWith(authPathPrefix);
|
|
1062
|
+
const effectiveRefreshPath = shouldAddPrefix ? `${authPathPrefix}${normalizedRefreshPath}` : normalizedRefreshPath;
|
|
1063
|
+
const loginPath = endpoints.login ?? '/login';
|
|
1064
|
+
const signupPath = endpoints.signup ?? '/signup';
|
|
1065
|
+
const socialExchangePath = endpoints.socialExchange ?? '/social/exchange';
|
|
1066
|
+
const isAuthApiRequest = req.url.includes(baseUrl);
|
|
1067
|
+
// Check if request is to refresh endpoint (using effective path with authPathPrefix)
|
|
1068
|
+
const isRefreshEndpoint = req.url.includes(effectiveRefreshPath);
|
|
1069
|
+
const isPublicEndpoint = req.url.includes(loginPath) || req.url.includes(signupPath) || req.url.includes(socialExchangePath);
|
|
1070
|
+
const shouldIntercept = isAuthApiRequest && !isRefreshEndpoint && !isPublicEndpoint;
|
|
1071
|
+
// ============================================================================
|
|
1072
|
+
// Build request for cookies mode (withCredentials + CSRF)
|
|
1073
|
+
// ============================================================================
|
|
1074
|
+
let authReq = req;
|
|
1075
|
+
if (tokenDelivery === 'cookies') {
|
|
1076
|
+
authReq = authReq.clone({ withCredentials: true });
|
|
1077
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
|
1078
|
+
const csrfCookieName = config.csrf?.cookieName ?? 'nauth_csrf_token';
|
|
1079
|
+
const csrfHeaderName = config.csrf?.headerName ?? 'x-csrf-token';
|
|
1080
|
+
const csrfToken = getCsrfToken(csrfCookieName);
|
|
1081
|
+
if (csrfToken) {
|
|
1082
|
+
authReq = authReq.clone({ setHeaders: { [csrfHeaderName]: csrfToken } });
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
// ============================================================================
|
|
1087
|
+
// JSON mode: attach Authorization header for HttpClient calls
|
|
1088
|
+
// ============================================================================
|
|
1089
|
+
// Simple approach: attach token if available, let backend validate
|
|
1090
|
+
// Handle 401 reactively (matches old working implementation)
|
|
1091
|
+
const attachJsonAuth$ = tokenDelivery === 'json' && shouldIntercept && !authReq.headers.has('Authorization')
|
|
1092
|
+
? from(authService.getAccessToken()).pipe(switchMap((token) => {
|
|
1093
|
+
if (token) {
|
|
1094
|
+
return of(authReq.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
|
|
1095
|
+
}
|
|
1096
|
+
return of(authReq);
|
|
1097
|
+
}))
|
|
1098
|
+
: of(authReq);
|
|
1099
|
+
// ============================================================================
|
|
1100
|
+
// Refresh coordination
|
|
1101
|
+
// ============================================================================
|
|
1102
|
+
const getOrStartRefresh$ = () => {
|
|
1103
|
+
if (refreshInFlight$)
|
|
1104
|
+
return refreshInFlight$;
|
|
1105
|
+
// WHY: We want to ensure only one refresh request is in flight at any time.
|
|
1106
|
+
// All requests (including those that haven't hit the backend yet) should wait for
|
|
1107
|
+
// the same refresh result to avoid a burst of 401s and potential WAF/rate-limit issues.
|
|
1108
|
+
isRefreshing = true;
|
|
1109
|
+
refreshTokenSubject.next(null);
|
|
1110
|
+
// WHY: Always refresh via the core client.
|
|
1111
|
+
// - Ensures authPathPrefix + default endpoints are applied consistently (fixes /refresh vs /auth/refresh).
|
|
1112
|
+
// - Centralizes CSRF + credentials handling in one place.
|
|
1113
|
+
const refreshRequest$ = from(authService.getClient().refreshTokens());
|
|
1114
|
+
refreshInFlight$ = refreshRequest$.pipe(map((response) => {
|
|
1115
|
+
// Cookies mode: success is enough (tokens are in httpOnly cookies).
|
|
1116
|
+
// JSON mode: we need the new access token to retry + unblock queued requests.
|
|
1117
|
+
const newToken = tokenDelivery === 'json' ? response.accessToken : 'success';
|
|
1118
|
+
if (tokenDelivery === 'json' && (!newToken || newToken === 'success')) {
|
|
1119
|
+
// ⚠️ WARNING: Without an access token we cannot safely retry requests in JSON mode.
|
|
1120
|
+
throw new Error('Token refresh did not return an access token');
|
|
1121
|
+
}
|
|
1122
|
+
refreshTokenSubject.next(newToken ?? 'success');
|
|
1123
|
+
return newToken ?? 'success';
|
|
1124
|
+
}), catchError((err) => {
|
|
1125
|
+
refreshTokenSubject.next(null);
|
|
1126
|
+
// Refresh failed -> redirect if configured
|
|
1127
|
+
if (config.redirects?.sessionExpired) {
|
|
1128
|
+
router.navigateByUrl(config.redirects.sessionExpired).catch(() => {
|
|
1129
|
+
// Ignore navigation errors
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
return throwError(() => err);
|
|
1133
|
+
}), finalize(() => {
|
|
1134
|
+
isRefreshing = false;
|
|
1135
|
+
refreshInFlight$ = null;
|
|
1136
|
+
}), shareReplay({ bufferSize: 1, refCount: false }));
|
|
1137
|
+
return refreshInFlight$;
|
|
1138
|
+
};
|
|
1139
|
+
// ============================================================================
|
|
1140
|
+
// Pre-request gating: block requests while refresh is in-flight
|
|
1141
|
+
// ============================================================================
|
|
1142
|
+
// WHY: Prevent multiple requests from hitting the backend with an expired token and returning 401.
|
|
1143
|
+
// We queue all auth API calls during refresh and release them once refresh succeeds.
|
|
1144
|
+
if (shouldIntercept && isRefreshing && refreshInFlight$) {
|
|
1145
|
+
return refreshInFlight$.pipe(switchMap((token) => {
|
|
1146
|
+
const gatedReq = buildRetryRequest(authReq, tokenDelivery, token, config.csrf);
|
|
1147
|
+
return next(gatedReq);
|
|
1148
|
+
}));
|
|
1149
|
+
}
|
|
1150
|
+
return attachJsonAuth$.pipe(switchMap((requestWithAuth) => next(requestWithAuth).pipe(catchError((error) => {
|
|
1151
|
+
const shouldHandle = error instanceof HttpErrorResponse && error.status === 401 && shouldIntercept && !retriedRequests.has(req);
|
|
1152
|
+
if (!shouldHandle) {
|
|
1153
|
+
return throwError(() => error);
|
|
1154
|
+
}
|
|
1155
|
+
retriedRequests.add(req);
|
|
1156
|
+
return getOrStartRefresh$().pipe(switchMap((token) => {
|
|
1157
|
+
const retryReq = buildRetryRequest(requestWithAuth, tokenDelivery, token, config.csrf);
|
|
1158
|
+
retriedRequests.add(retryReq);
|
|
1159
|
+
return next(retryReq);
|
|
1160
|
+
}));
|
|
1161
|
+
}))));
|
|
1162
|
+
}
|
|
1163
|
+
|
|
963
1164
|
/**
|
|
964
1165
|
* Class-based HTTP interceptor for NgModule apps (Angular < 17).
|
|
965
1166
|
*
|
|
@@ -990,52 +1191,14 @@ class AuthInterceptorClass {
|
|
|
990
1191
|
this.router = router;
|
|
991
1192
|
}
|
|
992
1193
|
intercept(req, next) {
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
|
1002
|
-
const csrfToken = getCsrfToken$1(this.config.csrf?.cookieName || 'XSRF-TOKEN');
|
|
1003
|
-
if (csrfToken) {
|
|
1004
|
-
clonedReq = clonedReq.clone({
|
|
1005
|
-
setHeaders: { [this.config.csrf?.headerName || 'X-XSRF-TOKEN']: csrfToken },
|
|
1006
|
-
});
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
return next.handle(clonedReq).pipe(catchError((error) => {
|
|
1010
|
-
if (error.status === 401 && !retriedRequests$1.has(req)) {
|
|
1011
|
-
retriedRequests$1.add(req);
|
|
1012
|
-
if (!isRefreshing$1) {
|
|
1013
|
-
isRefreshing$1 = true;
|
|
1014
|
-
refreshTokenSubject$1.next(null);
|
|
1015
|
-
return from(this.http
|
|
1016
|
-
.post(`${baseUrl}/refresh`, {}, { withCredentials: true })
|
|
1017
|
-
.toPromise()).pipe(switchMap(() => {
|
|
1018
|
-
isRefreshing$1 = false;
|
|
1019
|
-
refreshTokenSubject$1.next('refreshed');
|
|
1020
|
-
return next.handle(clonedReq);
|
|
1021
|
-
}), catchError((refreshError) => {
|
|
1022
|
-
isRefreshing$1 = false;
|
|
1023
|
-
this.authService.logout();
|
|
1024
|
-
this.router.navigate([this.config.redirects?.sessionExpired || '/login']);
|
|
1025
|
-
return throwError(() => refreshError);
|
|
1026
|
-
}));
|
|
1027
|
-
}
|
|
1028
|
-
else {
|
|
1029
|
-
return refreshTokenSubject$1.pipe(filter$1((token) => token !== null), take(1), switchMap(() => next.handle(clonedReq)));
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
return throwError(() => error);
|
|
1033
|
-
}));
|
|
1034
|
-
}
|
|
1035
|
-
// ============================================================================
|
|
1036
|
-
// JSON MODE: Delegate to SDK for token handling
|
|
1037
|
-
// ============================================================================
|
|
1038
|
-
return next.handle(req);
|
|
1194
|
+
return createNAuthAuthHttpInterceptor({
|
|
1195
|
+
config: this.config,
|
|
1196
|
+
http: this.http,
|
|
1197
|
+
authService: this.authService,
|
|
1198
|
+
router: this.router,
|
|
1199
|
+
req,
|
|
1200
|
+
next: (r) => next.handle(r),
|
|
1201
|
+
});
|
|
1039
1202
|
}
|
|
1040
1203
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AuthInterceptorClass, deps: [{ token: NAUTH_CLIENT_CONFIG }, { token: i1.HttpClient }, { token: AuthService }, { token: i3.Router }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1041
1204
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AuthInterceptorClass });
|
|
@@ -1225,25 +1388,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
1225
1388
|
}]
|
|
1226
1389
|
}] });
|
|
1227
1390
|
|
|
1228
|
-
/**
|
|
1229
|
-
* Refresh state management.
|
|
1230
|
-
* BehaviorSubject pattern is the industry-standard for token refresh.
|
|
1231
|
-
*/
|
|
1232
|
-
let isRefreshing = false;
|
|
1233
|
-
const refreshTokenSubject = new BehaviorSubject(null);
|
|
1234
|
-
/**
|
|
1235
|
-
* Track retried requests to prevent infinite loops.
|
|
1236
|
-
*/
|
|
1237
|
-
const retriedRequests = new WeakSet();
|
|
1238
|
-
/**
|
|
1239
|
-
* Get CSRF token from cookie.
|
|
1240
|
-
*/
|
|
1241
|
-
function getCsrfToken(cookieName) {
|
|
1242
|
-
if (typeof document === 'undefined')
|
|
1243
|
-
return null;
|
|
1244
|
-
const match = document.cookie.match(new RegExp(`(^| )${cookieName}=([^;]+)`));
|
|
1245
|
-
return match ? decodeURIComponent(match[2]) : null;
|
|
1246
|
-
}
|
|
1247
1391
|
/**
|
|
1248
1392
|
* Angular HTTP interceptor for nauth-toolkit.
|
|
1249
1393
|
*
|
|
@@ -1261,248 +1405,8 @@ const authInterceptor = (req, next) => {
|
|
|
1261
1405
|
if (!isBrowser) {
|
|
1262
1406
|
return next(req);
|
|
1263
1407
|
}
|
|
1264
|
-
|
|
1265
|
-
if (req.url.includes('/profile') && req.method === 'PUT') {
|
|
1266
|
-
fetch('http://127.0.0.1:7242/ingest/97f9fe53-6a8b-43e2-ae9b-4b2d0f725816', {
|
|
1267
|
-
method: 'POST',
|
|
1268
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1269
|
-
body: JSON.stringify({
|
|
1270
|
-
location: 'auth.interceptor.ts:entry',
|
|
1271
|
-
message: 'Original request entry',
|
|
1272
|
-
data: { reqBody: req.body, reqBodyType: typeof req.body, reqMethod: req.method, reqUrl: req.url },
|
|
1273
|
-
timestamp: Date.now(),
|
|
1274
|
-
sessionId: 'debug-session',
|
|
1275
|
-
hypothesisId: 'A',
|
|
1276
|
-
}),
|
|
1277
|
-
}).catch(() => { });
|
|
1278
|
-
}
|
|
1279
|
-
// #endregion
|
|
1280
|
-
const tokenDelivery = config.tokenDelivery;
|
|
1281
|
-
const baseUrl = config.baseUrl;
|
|
1282
|
-
const endpoints = config.endpoints ?? {};
|
|
1283
|
-
const refreshPath = endpoints.refresh ?? '/refresh';
|
|
1284
|
-
const loginPath = endpoints.login ?? '/login';
|
|
1285
|
-
const signupPath = endpoints.signup ?? '/signup';
|
|
1286
|
-
const socialExchangePath = endpoints.socialExchange ?? '/social/exchange';
|
|
1287
|
-
const refreshUrl = `${baseUrl}${refreshPath}`;
|
|
1288
|
-
const isAuthApiRequest = req.url.includes(baseUrl);
|
|
1289
|
-
const isRefreshEndpoint = req.url.includes(refreshPath);
|
|
1290
|
-
const isPublicEndpoint = req.url.includes(loginPath) || req.url.includes(signupPath) || req.url.includes(socialExchangePath);
|
|
1291
|
-
// Build request with credentials (cookies mode only)
|
|
1292
|
-
let authReq = req;
|
|
1293
|
-
if (tokenDelivery === 'cookies') {
|
|
1294
|
-
authReq = authReq.clone({ withCredentials: true });
|
|
1295
|
-
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
|
1296
|
-
const csrfCookieName = config.csrf?.cookieName ?? 'nauth_csrf_token';
|
|
1297
|
-
const csrfHeaderName = config.csrf?.headerName ?? 'x-csrf-token';
|
|
1298
|
-
const csrfToken = getCsrfToken(csrfCookieName);
|
|
1299
|
-
if (csrfToken) {
|
|
1300
|
-
authReq = authReq.clone({ setHeaders: { [csrfHeaderName]: csrfToken } });
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
return next(authReq).pipe(catchError((error) => {
|
|
1305
|
-
const shouldHandle = error instanceof HttpErrorResponse &&
|
|
1306
|
-
error.status === 401 &&
|
|
1307
|
-
isAuthApiRequest &&
|
|
1308
|
-
!isRefreshEndpoint &&
|
|
1309
|
-
!isPublicEndpoint &&
|
|
1310
|
-
!retriedRequests.has(req);
|
|
1311
|
-
if (!shouldHandle) {
|
|
1312
|
-
return throwError(() => error);
|
|
1313
|
-
}
|
|
1314
|
-
// Mark original request as retried to prevent infinite loops
|
|
1315
|
-
retriedRequests.add(req);
|
|
1316
|
-
if (config.debug) {
|
|
1317
|
-
console.warn('[nauth-interceptor] 401 detected:', req.url);
|
|
1318
|
-
}
|
|
1319
|
-
if (!isRefreshing) {
|
|
1320
|
-
isRefreshing = true;
|
|
1321
|
-
refreshTokenSubject.next(null);
|
|
1322
|
-
if (config.debug) {
|
|
1323
|
-
console.warn('[nauth-interceptor] Starting refresh...');
|
|
1324
|
-
}
|
|
1325
|
-
// Refresh based on mode
|
|
1326
|
-
const refresh$ = tokenDelivery === 'cookies'
|
|
1327
|
-
? http.post(refreshUrl, {}, { withCredentials: true })
|
|
1328
|
-
: from(authService.refresh());
|
|
1329
|
-
return refresh$.pipe(switchMap((response) => {
|
|
1330
|
-
if (config.debug) {
|
|
1331
|
-
console.warn('[nauth-interceptor] Refresh successful');
|
|
1332
|
-
}
|
|
1333
|
-
isRefreshing = false;
|
|
1334
|
-
// Get new token (JSON mode) or signal success (cookies mode)
|
|
1335
|
-
const newToken = 'accessToken' in response ? response.accessToken : 'success';
|
|
1336
|
-
refreshTokenSubject.next(newToken ?? 'success');
|
|
1337
|
-
// #region agent log
|
|
1338
|
-
fetch('http://127.0.0.1:7242/ingest/97f9fe53-6a8b-43e2-ae9b-4b2d0f725816', {
|
|
1339
|
-
method: 'POST',
|
|
1340
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1341
|
-
body: JSON.stringify({
|
|
1342
|
-
location: 'auth.interceptor.ts:125',
|
|
1343
|
-
message: 'Before buildRetryRequest',
|
|
1344
|
-
data: {
|
|
1345
|
-
authReqBody: authReq.body,
|
|
1346
|
-
authReqMethod: authReq.method,
|
|
1347
|
-
authReqUrl: authReq.url,
|
|
1348
|
-
authReqBodyType: typeof authReq.body,
|
|
1349
|
-
},
|
|
1350
|
-
timestamp: Date.now(),
|
|
1351
|
-
sessionId: 'debug-session',
|
|
1352
|
-
hypothesisId: 'A',
|
|
1353
|
-
}),
|
|
1354
|
-
}).catch(() => { });
|
|
1355
|
-
// #endregion
|
|
1356
|
-
// Build retry request with fresh CSRF token (re-read from cookie after refresh)
|
|
1357
|
-
const retryReq = buildRetryRequest(authReq, tokenDelivery, newToken, config.csrf);
|
|
1358
|
-
// #region agent log
|
|
1359
|
-
fetch('http://127.0.0.1:7242/ingest/97f9fe53-6a8b-43e2-ae9b-4b2d0f725816', {
|
|
1360
|
-
method: 'POST',
|
|
1361
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1362
|
-
body: JSON.stringify({
|
|
1363
|
-
location: 'auth.interceptor.ts:130',
|
|
1364
|
-
message: 'After buildRetryRequest',
|
|
1365
|
-
data: {
|
|
1366
|
-
retryReqBody: retryReq.body,
|
|
1367
|
-
retryReqMethod: retryReq.method,
|
|
1368
|
-
retryReqUrl: retryReq.url,
|
|
1369
|
-
retryReqBodyType: typeof retryReq.body,
|
|
1370
|
-
headersKeys: retryReq.headers.keys(),
|
|
1371
|
-
},
|
|
1372
|
-
timestamp: Date.now(),
|
|
1373
|
-
sessionId: 'debug-session',
|
|
1374
|
-
hypothesisId: 'B',
|
|
1375
|
-
}),
|
|
1376
|
-
}).catch(() => { });
|
|
1377
|
-
// #endregion
|
|
1378
|
-
if (config.debug) {
|
|
1379
|
-
console.warn('[nauth-interceptor] Retrying:', req.url);
|
|
1380
|
-
}
|
|
1381
|
-
// Retry the request with fresh token/CSRF
|
|
1382
|
-
// IMPORTANT: Errors from the retry (e.g., 400 validation) should NOT trigger
|
|
1383
|
-
// session expiration redirect. Only the refresh failure should redirect.
|
|
1384
|
-
return next(retryReq).pipe(catchError((retryErr) => {
|
|
1385
|
-
// Retry failed (could be 400, 403, 500, etc.)
|
|
1386
|
-
// Just propagate the error - don't redirect to login
|
|
1387
|
-
if (config.debug) {
|
|
1388
|
-
console.warn('[nauth-interceptor] Retry request failed:', retryErr);
|
|
1389
|
-
}
|
|
1390
|
-
return throwError(() => retryErr);
|
|
1391
|
-
}));
|
|
1392
|
-
}), catchError((err) => {
|
|
1393
|
-
// This only catches REFRESH failures, not retry failures
|
|
1394
|
-
if (config.debug) {
|
|
1395
|
-
console.error('[nauth-interceptor] Refresh failed:', err);
|
|
1396
|
-
}
|
|
1397
|
-
isRefreshing = false;
|
|
1398
|
-
refreshTokenSubject.next(null);
|
|
1399
|
-
// Handle session expiration - redirect to configured URL
|
|
1400
|
-
// Only redirect if refresh itself failed (not if retry failed)
|
|
1401
|
-
if (config.redirects?.sessionExpired) {
|
|
1402
|
-
router.navigateByUrl(config.redirects.sessionExpired).catch((navError) => {
|
|
1403
|
-
if (config.debug) {
|
|
1404
|
-
console.error('[nauth-interceptor] Navigation failed:', navError);
|
|
1405
|
-
}
|
|
1406
|
-
});
|
|
1407
|
-
}
|
|
1408
|
-
return throwError(() => err);
|
|
1409
|
-
}));
|
|
1410
|
-
}
|
|
1411
|
-
else {
|
|
1412
|
-
// Wait for ongoing refresh
|
|
1413
|
-
if (config.debug) {
|
|
1414
|
-
console.warn('[nauth-interceptor] Waiting for refresh...');
|
|
1415
|
-
}
|
|
1416
|
-
return refreshTokenSubject.pipe(filter$1((token) => token !== null), take(1), switchMap((token) => {
|
|
1417
|
-
if (config.debug) {
|
|
1418
|
-
console.warn('[nauth-interceptor] Refresh done, retrying:', req.url);
|
|
1419
|
-
}
|
|
1420
|
-
const retryReq = buildRetryRequest(authReq, tokenDelivery, token, config.csrf);
|
|
1421
|
-
// Retry the request - errors here should propagate normally
|
|
1422
|
-
// without triggering session expiration redirect
|
|
1423
|
-
return next(retryReq).pipe(catchError((retryErr) => {
|
|
1424
|
-
if (config.debug) {
|
|
1425
|
-
console.warn('[nauth-interceptor] Retry request failed:', retryErr);
|
|
1426
|
-
}
|
|
1427
|
-
return throwError(() => retryErr);
|
|
1428
|
-
}));
|
|
1429
|
-
}));
|
|
1430
|
-
}
|
|
1431
|
-
}));
|
|
1408
|
+
return createNAuthAuthHttpInterceptor({ config, http, authService, router, next, req });
|
|
1432
1409
|
};
|
|
1433
|
-
/**
|
|
1434
|
-
* Build retry request with appropriate auth.
|
|
1435
|
-
*
|
|
1436
|
-
* CRITICAL FIX: In cookies mode, after refresh the server may send updated cookies.
|
|
1437
|
-
* We MUST re-read the CSRF token from the cookie before retrying to ensure we have
|
|
1438
|
-
* the current CSRF token that matches what the server expects.
|
|
1439
|
-
*
|
|
1440
|
-
* In JSON mode: Clones the request and adds the new Bearer token.
|
|
1441
|
-
*
|
|
1442
|
-
* @param originalReq - The base request (already has withCredentials if cookies mode)
|
|
1443
|
-
* @param tokenDelivery - 'cookies' or 'json'
|
|
1444
|
-
* @param newToken - The new access token (JSON mode only)
|
|
1445
|
-
* @param csrfConfig - CSRF configuration to re-read token from cookie
|
|
1446
|
-
* @returns The request ready for retry with fresh auth
|
|
1447
|
-
*/
|
|
1448
|
-
function buildRetryRequest(originalReq, tokenDelivery, newToken, csrfConfig) {
|
|
1449
|
-
if (tokenDelivery === 'json' && newToken && newToken !== 'success') {
|
|
1450
|
-
return originalReq.clone({
|
|
1451
|
-
setHeaders: { Authorization: `Bearer ${newToken}` },
|
|
1452
|
-
});
|
|
1453
|
-
}
|
|
1454
|
-
// Cookies mode: Browser automatically sends updated httpOnly cookies (access/refresh tokens).
|
|
1455
|
-
// However, CSRF token must match the cookie value at the moment of retry.
|
|
1456
|
-
// We ALWAYS re-read from document.cookie here (using defaults when csrfConfig
|
|
1457
|
-
// is not provided) to avoid stale header values after refresh or across tabs.
|
|
1458
|
-
if (tokenDelivery === 'cookies' && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(originalReq.method)) {
|
|
1459
|
-
const csrfCookieName = csrfConfig?.cookieName ?? 'nauth_csrf_token';
|
|
1460
|
-
const csrfHeaderName = csrfConfig?.headerName ?? 'x-csrf-token';
|
|
1461
|
-
const freshCsrfToken = getCsrfToken(csrfCookieName);
|
|
1462
|
-
// #region agent log
|
|
1463
|
-
fetch('http://127.0.0.1:7242/ingest/97f9fe53-6a8b-43e2-ae9b-4b2d0f725816', {
|
|
1464
|
-
method: 'POST',
|
|
1465
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1466
|
-
body: JSON.stringify({
|
|
1467
|
-
location: 'auth.interceptor.ts:buildRetryRequest',
|
|
1468
|
-
message: 'Inside buildRetryRequest cookies branch',
|
|
1469
|
-
data: {
|
|
1470
|
-
originalReqBody: originalReq.body,
|
|
1471
|
-
originalReqBodyType: typeof originalReq.body,
|
|
1472
|
-
freshCsrfToken: freshCsrfToken?.substring(0, 8),
|
|
1473
|
-
method: originalReq.method,
|
|
1474
|
-
},
|
|
1475
|
-
timestamp: Date.now(),
|
|
1476
|
-
sessionId: 'debug-session',
|
|
1477
|
-
hypothesisId: 'C',
|
|
1478
|
-
}),
|
|
1479
|
-
}).catch(() => { });
|
|
1480
|
-
// #endregion
|
|
1481
|
-
if (freshCsrfToken) {
|
|
1482
|
-
// Clone with fresh CSRF token in header
|
|
1483
|
-
const cloned = originalReq.clone({
|
|
1484
|
-
setHeaders: { [csrfHeaderName]: freshCsrfToken },
|
|
1485
|
-
});
|
|
1486
|
-
// #region agent log
|
|
1487
|
-
fetch('http://127.0.0.1:7242/ingest/97f9fe53-6a8b-43e2-ae9b-4b2d0f725816', {
|
|
1488
|
-
method: 'POST',
|
|
1489
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1490
|
-
body: JSON.stringify({
|
|
1491
|
-
location: 'auth.interceptor.ts:buildRetryRequest:afterClone',
|
|
1492
|
-
message: 'After clone with setHeaders',
|
|
1493
|
-
data: { clonedBody: cloned.body, clonedBodyType: typeof cloned.body, originalBody: originalReq.body },
|
|
1494
|
-
timestamp: Date.now(),
|
|
1495
|
-
sessionId: 'debug-session',
|
|
1496
|
-
hypothesisId: 'D',
|
|
1497
|
-
}),
|
|
1498
|
-
}).catch(() => { });
|
|
1499
|
-
// #endregion
|
|
1500
|
-
return cloned;
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1503
|
-
// No changes needed (GET request or no CSRF token available)
|
|
1504
|
-
return originalReq;
|
|
1505
|
-
}
|
|
1506
1410
|
/**
|
|
1507
1411
|
* Class-based interceptor for NgModule compatibility.
|
|
1508
1412
|
*/
|