Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,65 @@ AuthModule.forRoot({

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.

### Bypassing the AuthHttpInterceptor per request

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.

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.

```ts
import { HttpClient, HttpContext } from '@angular/common/http';
import { AUTH_INTERCEPTOR_BYPASS } from '@auth0/auth0-angular';

// Make a request without attaching an access token
this.http
.get('/api/public-data', {
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
})
.subscribe((data) => {
// Handle response
});
```

This is particularly useful in scenarios such as:

- **Public endpoints**: Making requests to public endpoints that don't require authentication, even if they match the `allowedList` pattern.
- **Dynamic control**: Conditionally bypassing authentication based on runtime logic.
- **Per-request override**: Overriding the default `allowedList` behavior for specific requests without modifying the global configuration.

```ts
import { Component } from '@angular/core';
import { HttpClient, HttpContext } from '@angular/common/http';
import { AUTH_INTERCEPTOR_BYPASS } from '@auth0/auth0-angular';

@Component({
selector: 'app-data',
templateUrl: './data.component.html',
})
export class DataComponent {
constructor(private http: HttpClient) {}

// Fetch public data without authentication
getPublicData() {
return this.http.get('/api/public', {
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
});
}

// Fetch protected data with authentication (default behavior)
getProtectedData() {
return this.http.get('/api/protected');
}

// Conditionally bypass based on user preference
getData(isPublic: boolean) {
const context = isPublic ? new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true) : new HttpContext();

return this.http.get('/api/data', { context });
}
}
```

## Handling errors

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.
Expand Down
23 changes: 23 additions & 0 deletions projects/auth0-angular/src/lib/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@auth0/auth0-spa-js';

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

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

/**
* HttpContextToken to bypass the AuthHttpInterceptor for a specific request.
*
* 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.
*
* @usageNotes
*
* ```typescript
* import { HttpClient, HttpContext } from '@angular/common/http';
* import { AUTH_INTERCEPTOR_BYPASS } from '@auth0/auth0-angular';
*
* // Bypass the interceptor for a specific request
* this.http.get('/api/public', {
* context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true)
* });
* ```
*/
export const AUTH_INTERCEPTOR_BYPASS = new HttpContextToken<boolean>(
() => false
);

/**
* Defines the type for a route config entry. Can either be:
*
Expand Down
152 changes: 152 additions & 0 deletions projects/auth0-angular/src/lib/auth.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
HTTP_INTERCEPTORS,
provideHttpClient,
withInterceptorsFromDi,
HttpContext,
} from '@angular/common/http';
import {
HttpTestingController,
Expand All @@ -17,6 +18,7 @@ import {
HttpMethod,
AuthClientConfig,
HttpInterceptorConfig,
AUTH_INTERCEPTOR_BYPASS,
} from './auth.config';
import { BehaviorSubject, Subject, throwError } from 'rxjs';
import { Auth0Client } from '@auth0/auth0-spa-js';
Expand Down Expand Up @@ -562,4 +564,154 @@ describe('The Auth HTTP Interceptor', () => {
await assertPassThruApiCallTo('https://my-api.com/api/contact', done);
}));
});

describe('HttpContext bypass token', () => {
it('bypasses interceptor when context token is set to true', fakeAsync(async (
done: () => void
) => {
httpClient
.get<Data>('https://my-api.com/api/photos', {
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
})
.subscribe(done);

flush();
await new Promise(process.nextTick);
req = httpTestingController.expectOne('https://my-api.com/api/photos');

// Assert NO Authorization header
expect(req.request.headers.get('Authorization')).toBeFalsy();
}));

it('bypasses for URLs not in allowedList', fakeAsync(async (
done: () => void
) => {
httpClient
.get<Data>('https://my-api.com/api/not-in-list', {
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
})
.subscribe(done);

flush();
await new Promise(process.nextTick);
req = httpTestingController.expectOne(
'https://my-api.com/api/not-in-list'
);

expect(req.request.headers.get('Authorization')).toBeFalsy();
}));

it('attaches token normally when context bypass is false', fakeAsync(async (
done: () => void
) => {
httpClient
.get<Data>('https://my-api.com/api/photos', {
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, false),
})
.subscribe(done);

flush();
await new Promise(process.nextTick);
req = httpTestingController.expectOne('https://my-api.com/api/photos');

expect(req.request.headers.get('Authorization')).toBe(
'Bearer access-token'
);
}));

it('defaults to normal behavior when context token is not set', fakeAsync(async (
done: () => void
) => {
await assertAuthorizedApiCallTo('https://my-api.com/api/photos', done);
}));

it('works with POST requests', fakeAsync(async (done: () => void) => {
httpClient
.post<Data>(
'https://my-api.com/api/register',
{},
{
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
}
)
.subscribe(done);

flush();
await new Promise(process.nextTick);
req = httpTestingController.expectOne('https://my-api.com/api/register');

expect(req.request.headers.get('Authorization')).toBeFalsy();
}));

it('works with PUT method', fakeAsync(async (done: () => void) => {
httpClient
.put<Data>(
'https://my-api.com/api/photos',
{},
{
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
}
)
.subscribe(done);

flush();
await new Promise(process.nextTick);
req = httpTestingController.expectOne('https://my-api.com/api/photos');
expect(req.request.headers.get('Authorization')).toBeFalsy();
}));

it('works with DELETE method', fakeAsync(async (done: () => void) => {
httpClient
.delete<Data>('https://my-api.com/api/photos', {
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
})
.subscribe(done);

flush();
await new Promise(process.nextTick);
req = httpTestingController.expectOne('https://my-api.com/api/photos');
expect(req.request.headers.get('Authorization')).toBeFalsy();
}));

it('works with PATCH method', fakeAsync(async (done: () => void) => {
httpClient
.patch<Data>(
'https://my-api.com/api/photos',
{},
{
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
}
)
.subscribe(done);

flush();
await new Promise(process.nextTick);
req = httpTestingController.expectOne('https://my-api.com/api/photos');
expect(req.request.headers.get('Authorization')).toBeFalsy();
}));

it('bypasses allowAnonymous logic when context token is set', fakeAsync(async (
done: () => void
) => {
// Mock getTokenSilently to throw an error
(
auth0Client.getTokenSilently as unknown as jest.SpyInstance
).mockReturnValue(throwError({ error: 'login_required' }));

// URL matches allowAnonymous route, but bypass should take precedence
httpClient
.get<Data>('https://my-api.com/api/orders', {
context: new HttpContext().set(AUTH_INTERCEPTOR_BYPASS, true),
})
.subscribe(done);

flush();
await new Promise(process.nextTick);
req = httpTestingController.expectOne('https://my-api.com/api/orders');

// Request should proceed without attempting to get token
expect(req.request.headers.get('Authorization')).toBeFalsy();
expect(authState.setError).not.toHaveBeenCalled();
}));
});
});
6 changes: 6 additions & 0 deletions projects/auth0-angular/src/lib/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isHttpInterceptorRouteConfig,
AuthClientConfig,
HttpInterceptorConfig,
AUTH_INTERCEPTOR_BYPASS,
} from './auth.config';

import {
Expand Down Expand Up @@ -59,6 +60,11 @@ export class AuthHttpInterceptor implements HttpInterceptor {
return next.handle(req);
}

// Early return if interceptor bypass is requested via HttpContext
if (req.context.get(AUTH_INTERCEPTOR_BYPASS)) {
return next.handle(req);
}

const isLoaded$ = this.authService.isLoading$.pipe(
filter((isLoading) => !isLoading)
);
Expand Down