Skip to content

Commit 265a501

Browse files
committed
feat(interceptor): auth interceptor is now part of auth-js
1 parent 2b98113 commit 265a501

17 files changed

+289
-201
lines changed

apps/demo-app/web/common/components/demo-app-playground.element.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ template.innerHTML = `
6464
</div>
6565
<div class="input row">
6666
<label for="api-headers-input">Custom headers</label>
67-
<input id="api-headers-input" class="flex" placeholder='ex: "name": "value";' />
67+
<input id="api-headers-input" class="flex" placeholder='ex: name1:one,name2=two' />
6868
</div>
6969
</div>
7070
<button id="api-get-button">GET</button>

apps/demo-app/web/ngx-auth/src/app/demo/demo.component.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,17 @@ export class DemoComponent implements AfterViewInit {
5151
// --- HANDLER(s) ---
5252

5353
public callPrivateApi(event: Event): void {
54-
5554
const { url, headers } = (event as CustomEvent).detail as {
5655
url: string;
5756
headers: string;
5857
};
5958

6059
if (url) {
6160
let httpHeaders = new HttpHeaders();
62-
headers.split(';').forEach(header => {
61+
headers.split(',').forEach(header => {
6362
if (header) {
6463
const item = header.split(':');
65-
httpHeaders = httpHeaders.append(item[0]?.trim(), item[1]?.trim() || '');
64+
httpHeaders = httpHeaders.append(item[0]?.trim(), item[1]?.trim() ?? '');
6665
}
6766
});
6867

libs/auth-js/oidc/default-settings.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/* eslint-disable camelcase, @typescript-eslint/naming-convention */
2+
3+
import { LogLevel } from '../core';
4+
import type { DefaultSettings } from './models/default-settings.model';
5+
import { DesktopNavigation } from './models/desktop-navigation.enum';
6+
7+
export const REDIRECT_URL_KEY = 'auth-js:oidc_manager:redirect_url';
8+
9+
export const DEFAULT_SETTINGS: DefaultSettings = {
10+
loginRequired: false,
11+
retrieveUserSession: true,
12+
loadUserInfo: false,
13+
automaticSilentRenew: true,
14+
desktopNavigationType: DesktopNavigation.REDIRECT,
15+
scope: 'openid profile email phone',
16+
logLevel: LogLevel.NONE,
17+
automaticLoginOn401: true,
18+
automaticInjectToken: {
19+
include: (url: string): boolean => {
20+
const res = new URL(url, 'http://default-base');
21+
return res.hostname.startsWith('api') || res.pathname.startsWith('/api') || false;
22+
}
23+
},
24+
internal: {
25+
response_type: 'code',
26+
redirect_uri: '?oidc-callback=login',
27+
post_logout_redirect_uri: '?oidc-callback=logout',
28+
popup_redirect_uri: 'oidc/callback/popup_redirect.html',
29+
popup_post_logout_redirect_uri: 'oidc/callback/popup_redirect.html',
30+
silent_redirect_uri: 'oidc/callback/silent_redirect.html',
31+
mobileWindowPresentationStyle: 'popover'
32+
}
33+
};

libs/auth-js/oidc/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
66
* Copyright (C) 2018 Badisi
77
*/
88

9-
export type { AuthGuardOptions, AuthGuardValidator, AuthSubscriber, AuthSubscriberOptions, AuthSubscription, Optional } from '../core';
9+
export type {
10+
AuthGuardOptions, AuthGuardValidator, AuthSubscriber, AuthSubscriberOptions, AuthSubscription, Optional
11+
} from '../core';
1012
export { AuthManager, AuthSubscriptions, AuthUtils, LogLevel } from '../core';
1113
export { initOidc } from './main';
1214
export type { AccessToken } from './models/access-token.model';
1315
export type { LoginArgs, LogoutArgs, RenewArgs, SigninMobileArgs, SignoutMobileArgs } from './models/args.model';
1416
export { DesktopNavigation } from './models/desktop-navigation.enum';
1517
export type { IdToken } from './models/id-token.model';
18+
export type { InjectToken } from './models/inject-token.model';
19+
export type { InjectTokenPattern } from './models/inject-token-pattern.model';
1620
export type { MobileWindowParams } from './models/mobile-window-params.model';
1721
export type { OIDCAuthSettings } from './models/oidc-auth-settings.model';
1822
export { UserSession } from './models/user-session.model';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export type InjectTokenPattern =
2+
| (string | RegExp)[]
3+
| ((url: string) => boolean);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { InjectTokenPattern } from "./inject-token-pattern.model";
2+
3+
export type InjectToken =
4+
| boolean
5+
| { include?: InjectTokenPattern; exclude?: InjectTokenPattern; };

libs/auth-js/oidc/models/oidc-auth-settings.model.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { UserManagerSettings } from 'oidc-client-ts';
22

33
import type { AuthSettings as CoreAuthSettings, LogLevel } from '../../core';
44
import type { DesktopNavigation } from './desktop-navigation.enum';
5+
import type { InjectToken } from './inject-token.model';
56
import type { MobileWindowParams } from './mobile-window-params.model';
67

78
// TODO: check if `monitorSession` and `revokeAccessTokenOnSignout` might be useful too ?
@@ -14,5 +15,7 @@ export interface OIDCAuthSettings extends CoreAuthSettings, Partial<Pick<UserMan
1415
retrieveUserSession?: boolean;
1516
desktopNavigationType?: DesktopNavigation;
1617
logLevel?: LogLevel;
18+
automaticLoginOn401?: boolean;
19+
automaticInjectToken?: InjectToken;
1720
internal?: Partial<Omit<UserManagerSettings, UsefulSettings | 'authority' | 'client_id'>> & MobileWindowParams;
1821
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/* eslint-disable @typescript-eslint/unbound-method */
2+
3+
import { AuthLogger } from '@badisi/auth-js';
4+
5+
import type { InjectToken } from './models/inject-token.model';
6+
import type { OIDCAuthManager } from './oidc-auth-manager';
7+
8+
declare global {
9+
interface XMLHttpRequest {
10+
url?: string | URL;
11+
}
12+
}
13+
14+
const logger = new AuthLogger('OIDCAuthInterceptor');
15+
16+
export class OIDCAuthInterceptor {
17+
#manager: OIDCAuthManager;
18+
19+
#originalFetch = window.fetch;
20+
#originalXmlHttpRequestOpen = XMLHttpRequest.prototype.open;
21+
#originalXmlHttpRequestSend = XMLHttpRequest.prototype.send;
22+
23+
constructor(manager: OIDCAuthManager) {
24+
logger.debug('init');
25+
this.#manager = manager;
26+
this.#monkeyPathFetch();
27+
this.#monkeyPatchXmlHttpRequest();
28+
}
29+
30+
// ---- HELPER(s) ----
31+
32+
#getCompleteUrl(url: string): string {
33+
try {
34+
return new URL(url).href;
35+
} catch {
36+
return new URL(`${location.origin}${url.startsWith('/') ? '' : '/'}${url}`).href;
37+
}
38+
}
39+
40+
#isMatching(url: string, pattern: string | RegExp | ((url: string) => boolean)): boolean {
41+
const completeUrl = this.#getCompleteUrl(url);
42+
if (typeof pattern === 'function') {
43+
return pattern(completeUrl);
44+
} else if (typeof pattern === 'string') {
45+
// Make the pattern regexp friendly
46+
const match = pattern
47+
.replace(/\//g, '\\/') // escape / with \/
48+
.replace(/\./g, '\\.') // escape . with \.
49+
.replace(/\*\*/g, '*') // replace ** with *
50+
.replace(/\*/g, '.*'); // replace * with .*
51+
52+
return (new RegExp(match).exec(completeUrl) !== null);
53+
} else {
54+
return (pattern.exec(completeUrl) !== null);
55+
}
56+
}
57+
58+
#isAllowedRequest(url: string, injectToken: InjectToken): boolean {
59+
let isAllowed = false;
60+
if (typeof injectToken === 'boolean') {
61+
isAllowed = injectToken;
62+
} else {
63+
const { include, exclude } = injectToken;
64+
65+
if (Array.isArray(include)) {
66+
isAllowed = include.some((pattern: string | RegExp) => this.#isMatching(url, pattern));
67+
} else if (include) {
68+
isAllowed = this.#isMatching(url, include);
69+
}
70+
71+
if (Array.isArray(exclude)) {
72+
if (exclude.some((item: string | RegExp) => this.#isMatching(url, item))) {
73+
isAllowed = false;
74+
}
75+
} else if (exclude && this.#isMatching(url, exclude)) {
76+
isAllowed = false;
77+
}
78+
}
79+
return isAllowed;
80+
}
81+
82+
#shouldInjectAuthToken(url: string): boolean {
83+
const injectToken = this.#manager.getSettings().automaticInjectToken ?? false;
84+
return (injectToken !== false) && this.#isAllowedRequest(url, injectToken);
85+
}
86+
87+
#monkeyPathFetch(enable = true): void {
88+
const _logger = logger.createChild('monkeyPathFetch');
89+
_logger.debug(enable ? 'enabling..' : 'disabling..');
90+
91+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
92+
if (window.fetch) {
93+
if (enable) {
94+
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
95+
const url = (input instanceof Request) ? input.url : input.toString();
96+
logger.debug('received fetch url:', url);
97+
98+
// Add token to request headers
99+
if (this.#shouldInjectAuthToken(url)) {
100+
const accessToken = await this.#manager.getAccessToken();
101+
if (init && accessToken) {
102+
logger.debug('adding bearer to url:', url);
103+
init.headers = {
104+
// eslint-disable-next-line @typescript-eslint/naming-convention
105+
'Authorization': `Bearer ${accessToken}`,
106+
...init.headers
107+
};
108+
}
109+
}
110+
111+
// Proceed with request
112+
const response = await this.#originalFetch.apply(window, [input, init]);
113+
114+
// Do a login on 401
115+
if (response.status === 401) {
116+
const shouldLoginOn401 = this.#manager.getSettings().automaticLoginOn401 ?? false;
117+
if (shouldLoginOn401) {
118+
await this.#manager.login();
119+
}
120+
}
121+
122+
return response;
123+
};
124+
} else {
125+
window.fetch = this.#originalFetch;
126+
}
127+
128+
_logger.debug('done');
129+
}
130+
}
131+
132+
#monkeyPatchXmlHttpRequest(enable = true): void {
133+
const _logger = logger.createChild('monkeyPatchXmlHttpRequest');
134+
_logger.debug(enable ? 'enabling..' : 'disabling..');
135+
136+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
137+
if (XMLHttpRequest.prototype.open && XMLHttpRequest.prototype.send) {
138+
if (enable) {
139+
// eslint-disable-next-line @typescript-eslint/no-this-alias
140+
const interceptor = this;
141+
142+
XMLHttpRequest.prototype.open = function (method: string, url: string | URL, ...rest: unknown[]): void {
143+
this.url = url;
144+
// @ts-expect-error Rest should not be of type unknown
145+
interceptor.#originalXmlHttpRequestOpen.apply(this, [method, url, ...rest]);
146+
};
147+
148+
XMLHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null): void {
149+
const url = (typeof this.url === 'string') ? this.url : this.url?.href;
150+
logger.debug('received xhr url:', url);
151+
152+
// Do a login on 401
153+
const onReadyStateChange = (): void => {
154+
if (this.readyState === XMLHttpRequest.DONE) {
155+
removeListeners();
156+
157+
if (this.status === 401) {
158+
const shouldLoginOn401 = interceptor.#manager.getSettings().automaticLoginOn401 ?? false;
159+
if (shouldLoginOn401) {
160+
void interceptor.#manager.login();
161+
}
162+
}
163+
}
164+
}
165+
const removeListeners = (): void => {
166+
this.removeEventListener('readystatechange', onReadyStateChange);
167+
this.removeEventListener('timeout', removeListeners);
168+
this.removeEventListener('error', removeListeners);
169+
this.removeEventListener('abort', removeListeners);
170+
171+
};
172+
this.addEventListener('readystatechange', onReadyStateChange);
173+
this.addEventListener('timeout', removeListeners);
174+
this.addEventListener('error', removeListeners);
175+
this.addEventListener('abort', removeListeners);
176+
177+
// Add token to request headers
178+
const shouldInjectToken = url ? interceptor.#shouldInjectAuthToken(url) : false;
179+
if (this.readyState === XMLHttpRequest.OPENED && shouldInjectToken) {
180+
interceptor.#manager.getAccessToken()
181+
.then(accessToken => {
182+
if (accessToken) {
183+
logger.debug('adding bearer to url:', url);
184+
this.setRequestHeader('Authorization', `Bearer ${accessToken}`);
185+
}
186+
})
187+
.catch((error: unknown) => {
188+
logger.error(error)
189+
})
190+
.finally(() => {
191+
interceptor.#originalXmlHttpRequestSend.apply(this, [body]);
192+
})
193+
} else {
194+
interceptor.#originalXmlHttpRequestSend.apply(this, [body]);
195+
}
196+
};
197+
} else {
198+
XMLHttpRequest.prototype.open = this.#originalXmlHttpRequestOpen;
199+
XMLHttpRequest.prototype.send = this.#originalXmlHttpRequestSend;
200+
}
201+
202+
_logger.debug('done');
203+
}
204+
}
205+
}

0 commit comments

Comments
 (0)