Skip to content

Commit 86c7763

Browse files
feat: add HttpContextToken for per-request AuthHttpInterceptor bypass
Introduces AUTH_INTERCEPTOR_BYPASS, an HttpContextToken that allows developers to bypass the AuthHttpInterceptor on a per-request basis. This provides dynamic control over token attachment, complementing the existing URL-based allowedList configuration. Key features: - AUTH_INTERCEPTOR_BYPASS token for use with HttpContext - Early bypass check in interceptor for optimal performance - Takes precedence over allowedList configuration - Fully backward compatible with existing functionality - Comprehensive test coverage with 9 new test cases - Documentation and usage examples in EXAMPLES.md Use cases: - Bypass authentication for public endpoints - Dynamic control based on runtime logic - Per-request override of global configuration
1 parent 37effa6 commit 86c7763

File tree

4 files changed

+240
-0
lines changed

4 files changed

+240
-0
lines changed

EXAMPLES.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,65 @@ AuthModule.forRoot({
290290
291291
You might want to do this in scenarios where you need the token on multiple endpoints, but want to exclude it from only a few other endpoints. Instead of explicitly listing all endpoints that do need a token, a uriMatcher can be used to include all but the few endpoints that do not need a token attached to its requests.
292292
293+
### Bypassing the AuthHttpInterceptor per request
294+
295+
While the `allowedList` configuration provides URL-based control over which requests receive access tokens, you may need more dynamic control on a per-request basis. The SDK provides the `AUTH_INTERCEPTOR_BYPASS` HttpContextToken that allows you to bypass the interceptor for specific requests, regardless of the `allowedList` configuration.
296+
297+
When set to `true` on an HttpRequest's context, the interceptor will not attach an access token to the request, even if the URL matches the `allowedList` configuration.
298+
299+
```ts
300+
import { HttpClient, HttpContext } from '@angular/common/http';
301+
import { AUTH_INTERCEPTOR_BYPASS } from '@auth0/auth0-angular';
302+
303+
// Make a request without attaching an access token
304+
this.http
305+
.get('/api/public-data', {
306+
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
307+
})
308+
.subscribe((data) => {
309+
// Handle response
310+
});
311+
```
312+
313+
This is particularly useful in scenarios such as:
314+
315+
- **Public endpoints**: Making requests to public endpoints that don't require authentication, even if they match the `allowedList` pattern.
316+
- **Dynamic control**: Conditionally bypassing authentication based on runtime logic.
317+
- **Per-request override**: Overriding the default `allowedList` behavior for specific requests without modifying the global configuration.
318+
319+
```ts
320+
import { Component } from '@angular/core';
321+
import { HttpClient, HttpContext } from '@angular/common/http';
322+
import { AUTH_INTERCEPTOR_BYPASS } from '@auth0/auth0-angular';
323+
324+
@Component({
325+
selector: 'app-data',
326+
templateUrl: './data.component.html',
327+
})
328+
export class DataComponent {
329+
constructor(private http: HttpClient) {}
330+
331+
// Fetch public data without authentication
332+
getPublicData() {
333+
return this.http.get('/api/public', {
334+
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
335+
});
336+
}
337+
338+
// Fetch protected data with authentication (default behavior)
339+
getProtectedData() {
340+
return this.http.get('/api/protected');
341+
}
342+
343+
// Conditionally bypass based on user preference
344+
getData(isPublic: boolean) {
345+
const context = isPublic ? new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true) : new HttpContext();
346+
347+
return this.http.get('/api/data', { context });
348+
}
349+
}
350+
```
351+
293352
## Handling errors
294353
295354
Whenever the SDK fails to retrieve an Access Token, either as part of the above interceptor or when manually calling `AuthService.getAccessTokenSilently` and `AuthService.getAccessTokenWithPopup`, it will emit the corresponding error in the `AuthService.error$` observable.

projects/auth0-angular/src/lib/auth.config.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@auth0/auth0-spa-js';
77

88
import { InjectionToken, Injectable, Optional, Inject } from '@angular/core';
9+
import { HttpContextToken } from '@angular/common/http';
910

1011
/**
1112
* Defines a common set of HTTP methods.
@@ -19,6 +20,28 @@ export const enum HttpMethod {
1920
Head = 'HEAD',
2021
}
2122

23+
/**
24+
* HttpContextToken to bypass the AuthHttpInterceptor for a specific request.
25+
*
26+
* When set to true on an HttpRequest's context, the interceptor will not attach
27+
* an access token to the request, even if the URL matches the allowedList configuration.
28+
*
29+
* @usageNotes
30+
*
31+
* ```typescript
32+
* import { HttpClient, HttpContext } from '@angular/common/http';
33+
* import { AUTH_INTERCEPTOR_BYPASS } from '@auth0/auth0-angular';
34+
*
35+
* // Bypass the interceptor for a specific request
36+
* this.http.get('/api/public', {
37+
* context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true)
38+
* });
39+
* ```
40+
*/
41+
export const AUTH_INTERCEPTOR_BYPASS = new HttpContextToken<boolean>(
42+
() => false
43+
);
44+
2245
/**
2346
* Defines the type for a route config entry. Can either be:
2447
*

projects/auth0-angular/src/lib/auth.interceptor.spec.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
HTTP_INTERCEPTORS,
66
provideHttpClient,
77
withInterceptorsFromDi,
8+
HttpContext,
89
} from '@angular/common/http';
910
import {
1011
HttpTestingController,
@@ -17,6 +18,7 @@ import {
1718
HttpMethod,
1819
AuthClientConfig,
1920
HttpInterceptorConfig,
21+
AUTH_INTERCEPTOR_BYPASS,
2022
} from './auth.config';
2123
import { BehaviorSubject, Subject, throwError } from 'rxjs';
2224
import { Auth0Client } from '@auth0/auth0-spa-js';
@@ -562,4 +564,154 @@ describe('The Auth HTTP Interceptor', () => {
562564
await assertPassThruApiCallTo('https://my-api.com/api/contact', done);
563565
}));
564566
});
567+
568+
describe('HttpContext bypass token', () => {
569+
it('bypasses interceptor when context token is set to true', fakeAsync(async (
570+
done: () => void
571+
) => {
572+
httpClient
573+
.get<Data>('https://my-api.com/api/photos', {
574+
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
575+
})
576+
.subscribe(done);
577+
578+
flush();
579+
await new Promise(process.nextTick);
580+
req = httpTestingController.expectOne('https://my-api.com/api/photos');
581+
582+
// Assert NO Authorization header
583+
expect(req.request.headers.get('Authorization')).toBeFalsy();
584+
}));
585+
586+
it('bypasses for URLs not in allowedList', fakeAsync(async (
587+
done: () => void
588+
) => {
589+
httpClient
590+
.get<Data>('https://my-api.com/api/not-in-list', {
591+
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
592+
})
593+
.subscribe(done);
594+
595+
flush();
596+
await new Promise(process.nextTick);
597+
req = httpTestingController.expectOne(
598+
'https://my-api.com/api/not-in-list'
599+
);
600+
601+
expect(req.request.headers.get('Authorization')).toBeFalsy();
602+
}));
603+
604+
it('attaches token normally when context bypass is false', fakeAsync(async (
605+
done: () => void
606+
) => {
607+
httpClient
608+
.get<Data>('https://my-api.com/api/photos', {
609+
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, false),
610+
})
611+
.subscribe(done);
612+
613+
flush();
614+
await new Promise(process.nextTick);
615+
req = httpTestingController.expectOne('https://my-api.com/api/photos');
616+
617+
expect(req.request.headers.get('Authorization')).toBe(
618+
'Bearer access-token'
619+
);
620+
}));
621+
622+
it('defaults to normal behavior when context token is not set', fakeAsync(async (
623+
done: () => void
624+
) => {
625+
await assertAuthorizedApiCallTo('https://my-api.com/api/photos', done);
626+
}));
627+
628+
it('works with POST requests', fakeAsync(async (done: () => void) => {
629+
httpClient
630+
.post<Data>(
631+
'https://my-api.com/api/register',
632+
{},
633+
{
634+
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
635+
}
636+
)
637+
.subscribe(done);
638+
639+
flush();
640+
await new Promise(process.nextTick);
641+
req = httpTestingController.expectOne('https://my-api.com/api/register');
642+
643+
expect(req.request.headers.get('Authorization')).toBeFalsy();
644+
}));
645+
646+
it('works with PUT method', fakeAsync(async (done: () => void) => {
647+
httpClient
648+
.put<Data>(
649+
'https://my-api.com/api/photos',
650+
{},
651+
{
652+
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
653+
}
654+
)
655+
.subscribe(done);
656+
657+
flush();
658+
await new Promise(process.nextTick);
659+
req = httpTestingController.expectOne('https://my-api.com/api/photos');
660+
expect(req.request.headers.get('Authorization')).toBeFalsy();
661+
}));
662+
663+
it('works with DELETE method', fakeAsync(async (done: () => void) => {
664+
httpClient
665+
.delete<Data>('https://my-api.com/api/photos', {
666+
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
667+
})
668+
.subscribe(done);
669+
670+
flush();
671+
await new Promise(process.nextTick);
672+
req = httpTestingController.expectOne('https://my-api.com/api/photos');
673+
expect(req.request.headers.get('Authorization')).toBeFalsy();
674+
}));
675+
676+
it('works with PATCH method', fakeAsync(async (done: () => void) => {
677+
httpClient
678+
.patch<Data>(
679+
'https://my-api.com/api/photos',
680+
{},
681+
{
682+
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
683+
}
684+
)
685+
.subscribe(done);
686+
687+
flush();
688+
await new Promise(process.nextTick);
689+
req = httpTestingController.expectOne('https://my-api.com/api/photos');
690+
expect(req.request.headers.get('Authorization')).toBeFalsy();
691+
}));
692+
693+
it('bypasses allowAnonymous logic when context token is set', fakeAsync(async (
694+
done: () => void
695+
) => {
696+
// Mock getTokenSilently to throw an error
697+
(
698+
auth0Client.getTokenSilently as unknown as jest.SpyInstance
699+
).mockReturnValue(throwError({ error: 'login_required' }));
700+
701+
// URL matches allowAnonymous route, but bypass should take precedence
702+
httpClient
703+
.get<Data>('https://my-api.com/api/orders', {
704+
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
705+
})
706+
.subscribe(done);
707+
708+
flush();
709+
await new Promise(process.nextTick);
710+
req = httpTestingController.expectOne('https://my-api.com/api/orders');
711+
712+
// Request should proceed without attempting to get token
713+
expect(req.request.headers.get('Authorization')).toBeFalsy();
714+
expect(authState.setError).not.toHaveBeenCalled();
715+
}));
716+
});
565717
});

projects/auth0-angular/src/lib/auth.interceptor.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isHttpInterceptorRouteConfig,
1414
AuthClientConfig,
1515
HttpInterceptorConfig,
16+
AUTH_INTERCEPTOR_BYPASS,
1617
} from './auth.config';
1718

1819
import {
@@ -59,6 +60,11 @@ export class AuthHttpInterceptor implements HttpInterceptor {
5960
return next.handle(req);
6061
}
6162

63+
// Early return if interceptor bypass is requested via HttpContext
64+
if (req.context.get(AUTH_INTERCEPTOR_BYPASS)) {
65+
return next.handle(req);
66+
}
67+
6268
const isLoaded$ = this.authService.isLoading$.pipe(
6369
filter((isLoading) => !isLoading)
6470
);

0 commit comments

Comments
 (0)