No-auth paths and token recovery
noAuthPaths and noAuthPathRegex describe which endpoints legitimately run without a token. They are a design decision, made once, at the request service. They are not a patch for a 401. A 401 on an authenticated path is handled by the recovery contract: one refresh per burst, one retry, then onAuthFailure.
Prerequisites
You have a DefaultNetworkRequestService (usually reached through getNetworkService() on the data provider) and a DefaultAuthProvider in your application - see Network and Auth provider.
Quick Reference
| Export | Kind | Role on this page |
|---|---|---|
DefaultNetworkRequestService | class | Decides whether a request carries a token, and runs recovery on 401. Its protected hooks refreshToken, onAuthFailure, resolveAuthToken and isNoAuthPath are what you override |
DefaultRestDataProvider | class | createNetworkService() returns the service; override it to return your subclass |
INoAuthOptions | interface | noAuthPaths, noAuthPathRegex |
TNoAuthPathRegex | type | A string, a RegExp, or an array of either |
IAuthRecoveryOptions | interface | refreshTokenPath - data only |
DefaultAuthProvider | class | Owns login, logout, checkError, refreshToken |
DefaultAuthService | class | saveAuth, getAuth, getUser, cleanUp |
IAuthProviderOptions | interface | paths.signIn, paths.checkAuth, paths.afterLogin |
LocalStorageKeys | class | KEY_AUTH_TOKEN - the storage key the request service reads |
Why a missing token is an error, not a silent skip
DefaultNetworkRequestService.getRequestHeader({ resource }) builds headers for every request. If the path is not a no-auth path, it calls getRequestAuthorizationHeader(). That method reads the token through the resolveAuthToken() hook and throws a 401 before the request is sent when there is no token value:
getRequestAuthorizationHeader() {
const authToken = this.resolveAuthToken();
if (!authToken?.value) {
throw getError({ message: '[dataProvider][getAuthHeader] Invalid auth token to fetch!', statusCode: 401 });
}
...
}This is why the sign-in path must be declared as no-auth. Before login there is no token, so a sign-in request on an authenticated path never reaches the server. The same reasoning applies to the refresh path: the point of a refresh is that the current token is no longer good.
Declaring no-auth paths
The two lists are data. They come from INoAuthOptions, sit in IRestDataProviderOptions, and are fixed when the service is built. There are no setters.
declare class DefaultNetworkRequestService {
constructor(opts: INoAuthOptions & {
name: string;
baseUrl?: string;
headers?: HeadersInit;
authRecovery?: IAuthRecoveryOptions;
});
protected isNoAuthPath(opts: { resource?: string; paths?: string[] }): boolean;
}How isNoAuthPath decides, in order:
noAuthPaths- an exact string match againstresource.noAuthPathRegex- each pattern is tested againstresourceand againstpaths.join('/'). String patterns are compiled withnew RegExp; an invalid pattern is logged and skipped.lastIndexis reset before each test, so global patterns are safe.
To send no token at all - a service that talks to an API with no sign-in - set noAuthPathRegex: /./. Every path then matches, the service never attaches a token, and recovery is off with it (AuthRecovery.canRecover checks isNoAuthPath).
Keep the list small and explicit. The sign-in path and the refresh path belong there. Public, unauthenticated reads belong there. Nothing else does.
import { DefaultNetworkRequestService } from '@venizia/ardor';
const requests = new DefaultNetworkRequestService({
name: 'api',
baseUrl: 'https://api.example.com',
noAuthPaths: ['/auth/sign-in', '/auth/refresh'],
noAuthPathRegex: [/^\/public\//],
});With that service, /auth/sign-in is no-auth by exact match, /public/pricing by regex, and /orders needs a token.
isNoAuthPath is a protected hook. A rule the two lists cannot say is an override in the service subclass (see Wiring the refresh):
import { DefaultNetworkRequestService } from '@venizia/ardor';
class AppNetworkService extends DefaultNetworkRequestService {
protected override isNoAuthPath(opts: { resource?: string; paths?: string[] }): boolean {
return opts.resource?.startsWith('/open/') === true || super.isNoAuthPath(opts);
}
}Note that getRequestHeader only passes { resource } to isNoAuthPath. A regex that relies on the joined paths is only consulted where paths are supplied, such as in AuthRecovery.canRecover.
The recovery contract
Recovery has one piece of data and two hooks. The data is IAuthRecoveryOptions, set at construction. The hooks are protected methods of DefaultNetworkRequestService, which you override in a subclass.
interface IAuthRecoveryOptions {
refreshTokenPath?: string; // a request to this path never starts a refresh
}
declare class DefaultNetworkRequestService {
/** true once refreshed; false when there is nothing to refresh; throw when it failed. The default posts to refreshTokenPath. */
protected refreshToken(): Promise<boolean>;
/** Runs once when refreshToken threw. */
protected onAuthFailure(): Promise<void>;
/** The one public entry: one shared call per 401 burst. */
refresh(): Promise<boolean>;
}What the service guarantees:
- Recovery only runs when it can help.
AuthRecovery.canRecover({ paths })returnsfalsewhen the path is a no-auth path, or when the joined path containsrefreshTokenPath. A401from either surfaces immediately. A service with norefreshTokenPath(or no auth service) that does not overriderefreshTokenrefreshes nothing: the hook resolvesfalse, nothing is retried, and the401stands. - One refresh per burst.
refresh()(AuthRecovery.refresh) stores the in-flight promise. Every401that arrives while a refresh is running awaits the same promise. The slot is cleared infinally, so the next burst starts a new refresh. - One retry. When
refresh()resolvestrue, the original request is sent again once, with the new token. The service does not loop. - Failure is contained. If
refreshTokenrejects - or throws synchronously, which is why the chain starts withPromise.resolve().then(...)-onAuthFailureruns once,refresh()resolvesfalse, and the original401is what the caller sees. IfonAuthFailureitself throws, that is logged and swallowed; it never masks the401.
class AuthRecovery {
...
refresh(): Promise<boolean> {
this.refreshing ??= Promise.resolve()
.then(() => this.opts.refresh()) // the service's refreshToken hook
.catch(async (error) => {
this.logger.error('[refresh] refreshToken failed | error: %o', error);
try {
await this.opts.onFailure(); // the service's onAuthFailure hook
} catch (failure) {
this.logger.error('[refresh] onAuthFailure failed | error: %o', failure);
}
return false;
})
.finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
}refreshTokenPath and noAuthPaths are two separate guards. Setting refreshTokenPath stops a 401 from the refresh request from starting another refresh; set it in every case. Putting the refresh path in noAuthPaths stops the expired token from being attached to the refresh request; do it only when the endpoint identifies the session by other means, such as a cookie. The default refreshToken posts no body, so an endpoint that reads the session from the expired token needs the path left out of noAuthPaths.
The default refresh
When authRecovery.refreshTokenPath is set and the data provider found the auth service in the container (CoreBindings.DEFAULT_AUTH_SERVICE), the default refreshToken needs no subclass. It POSTs to that path with no body, reads the new token from the answer, and saves it through the auth service. The request carries the expired token only when the path is not in noAuthPaths; with the path in noAuthPaths the server must identify the session by other means, such as a cookie.
- The answer.
{ token: { value, type } }or{ token: '<jwt>' }; nothing else is read, because a refresh keeps the same user. An answer without a token is a failed refresh:onAuthFailureruns once and the401stands. OverridereadRefreshedToken({ data })for another shape. - The session changed meanwhile. The refresh is for the session that asked. If the user signed out or another user signed in while the request was in flight, nothing is saved and the
401stands; a refresh that fails after such a change does not runonAuthFailure, since the session it was for is gone. If the same user now holds another token (another tab refreshed, or a new sign-in), that token is kept and the request is retried with it. A401that arrives after another user signed in starts no refresh at all. - A store of your own. The default saves through the auth service, which keeps the session in
localStorage. A subclass whoseresolveAuthTokenreads another store does not see the refreshed token: overriderefreshTokenas well. - What is kept. The stored
username,referenceIdandprovider, and the roles, are not touched: the refresh saves only the token and the user id, so a refresh answer that carries roles in another shape cannot wipe them. - Who hears it. Subscribers of the auth service (the session hooks) run once after the refresh, as after
login.
Write the subclass below only for a refresh the default does not cover: a body, another answer than the token, a store that is not the auth service.
Wiring the refresh
The refresh is written once, as the refreshToken hook of a DefaultNetworkRequestService subclass. The subclass reaches the application in two steps.
First, write the subclass. refreshToken sends the refresh request, saves the new token through the auth service (it knows the keys of this application, which an application id scopes), and resolves true. onAuthFailure is the place to send the user back to sign-in:
import { DefaultNetworkRequestService, RequestMethods, RequestTypes } from '@venizia/ardor';
export class AppNetworkService extends DefaultNetworkRequestService {
protected override async refreshToken(): Promise<boolean> {
const { data } = await this.doRequest<{ token: string }>({
type: RequestTypes.SEND,
method: RequestMethods.POST,
paths: ['/auth/token/refresh'],
headers: this.getRequestHeader({ resource: '/auth/token/refresh' }),
});
const user = this.authService?.getUser();
if (!this.authService || user?.userId === undefined) {
return false;
}
this.authService.saveAuth({
userId: user.userId,
username: user.username,
referenceId: user.referenceId,
provider: user.provider,
token: { value: data.token, type: 'Bearer' },
});
return true;
}
protected override async onAuthFailure(): Promise<void> {
// send the user back to sign-in
}
}Second, return it from DefaultRestDataProvider.createNetworkService(), which is called once, on first use. The options keep only data, so the subclass takes them straight from restDataProviderOptions. The refresh path is in noAuthPaths; refreshTokenPath adds the second guard:
import { DefaultNetworkRequestService, DefaultRestDataProvider } from '@venizia/ardor';
// The subclass from the first step.
declare class AppNetworkService extends DefaultNetworkRequestService {}
export class AppRestDataProvider extends DefaultRestDataProvider {
protected override createNetworkService() {
return new AppNetworkService({
name: 'app-network-service',
baseUrl: this.restDataProviderOptions.url,
noAuthPaths: this.restDataProviderOptions.noAuthPaths,
noAuthPathRegex: this.restDataProviderOptions.noAuthPathRegex,
headers: this.restDataProviderOptions.headers,
authRecovery: this.restDataProviderOptions.authRecovery, // { refreshTokenPath: '/auth/token/refresh' }
authService: this.authService, // the session of this application, with its keys
});
}
}Bind that provider subclass under CoreBindings.DEFAULT_REST_DATA_PROVIDER, in place of DefaultRestDataProvider:
import {
type BaseArdorApplication as TArdorApplication,
configuration,
CoreBindings,
DefaultRestDataProvider,
inject,
provide,
} from '@venizia/ardor';
// The provider subclass from the second step.
declare class AppRestDataProvider extends DefaultRestDataProvider {}
@configuration()
export class ProviderConfiguration {
constructor(
@inject({ key: CoreBindings.APPLICATION_INSTANCE }) private readonly application: TArdorApplication,
) {}
@provide({ key: CoreBindings.DEFAULT_REST_DATA_PROVIDER })
restDataProvider() {
return this.application.instantiate(AppRestDataProvider).value(this.application);
}
}There is nothing to connect on the auth provider. DefaultAuthProvider.refreshToken() calls restDataProvider.getNetworkService().refresh() and resolves true or false. It is the same refresh() the 401 recovery calls, so useRefreshToken, a 401 burst and a call from code share one refresh.
You do not have to call cleanUp in onAuthFailure when the request came from react-admin. When the original 401 surfaces, react-admin calls authProvider.checkError(error) with the ApplicationError the data provider threw (statusCode: 401), and DefaultAuthProvider.checkError already calls authService.cleanUp() and redirects to login. Use onAuthFailure for work that must happen even outside react-admin's request path.
Where tokens live
What the browser stores about the user is not proof of anything. The roles (@app/auth/permission) and the identity (@app/auth/identity) can be edited by whoever controls the browser, so they only show or hide interface; the backend decides from the token on every request.
The request service stores nothing itself. It asks its resolveAuthToken() hook, which by default is DefaultNetworkRequestService.readAuthTokenFromStorage() (it delegates to AuthTokenStore.read(), which DefaultAuthService.getAuth() uses too) - localStorage under LocalStorageKeys.KEY_AUTH_TOKEN, parsed as a JSON object with a value and an optional type (defaults to Bearer) and provider. It returns undefined where there is no localStorage or the stored value does not parse.
resolveAuthToken() is the one way to supply a token from somewhere else. Override it in the service subclass to read another store or to hold the token in memory:
import { DefaultNetworkRequestService, type IAuthTokenRecord } from '@venizia/ardor';
export class MemoryTokenNetworkService extends DefaultNetworkRequestService {
protected token?: IAuthTokenRecord;
protected override resolveAuthToken(): IAuthTokenRecord | undefined {
return this.token;
}
}An HTTP repository should share both the token and the refresh. The data provider's getDataSourceAuth() hands an HttpDataSource four settings, read on every send: the session headers and the timezone, the request channel and tracing id, the token on every path but a no-auth one, and an onUnauthorized that joins the service's refresh, except on a no-auth path. So a screen reading through @venizia/ardor/repository sends what the data provider sends, and a 401 on either side triggers one refreshToken call between them:
import { CoreBindings, datasource, type IDataProvider } from '@venizia/ardor';
import { HttpDataSource } from '@venizia/ardor/repository';
import { inject } from '@venizia/ignis-inversion';
@datasource()
export class ApiDataSource extends HttpDataSource {
constructor(
@inject({ key: CoreBindings.DEFAULT_REST_DATA_PROVIDER }) dataProvider: IDataProvider,
) {
super({ baseUrl: '/api', ...dataProvider.getDataSourceAuth() });
}
}getDataSourceAuth() also sets errorRootKey: 'error', so a repository error keeps the server's normalized.code, as the data provider's does. The refresh is shared by every call in flight, so one call's abort never cancels it: the aborted call is just not retried once the refresh settles.
The data provider must be a singleton. Declare it with @provide in a @configuration() class - registerArtifacts() binds it as a singleton. A bare bind().toProvider() in bindContext() is transient, so the datasource would receive its own data provider, with its own network service, token and refresh. Without a refreshToken override, a 401 from the repository stands, as it does for the data provider.
DefaultAuthProvider writes the token in exactly one place - login, via authService.saveAuth - and clears it in exactly one way - authService.cleanUp, called from logout and from checkError on a 401. The only other writer is your refreshToken hook, which stores the refreshed token under the same key. Keep it that way: do not write the key from components, and do not clear it from anywhere but cleanUp.
Common pitfalls
- Adding a path to
noAuthPathsbecause it returned401. That hides an expired token instead of refreshing it. If the endpoint needs a user, leave it authenticated and let recovery run. - Forgetting the sign-in path.
getRequestAuthorizationHeaderthrows before the request is sent, sologinrejects withInvalid auth token to fetch!and never reaches the server.paths.signIndefaults to/auth/sign-in; whatever you configure must also be innoAuthPaths. - Forgetting the refresh path. Without it the service attaches the expired token to the refresh request. Without
refreshTokenPatha401from the refresh request starts another refresh. Declare both. - Expecting recovery with no
refreshTokenPath. Without the path the default hook resolvesfalse: nothing is refreshed, nothing is retried, and the401stands. SetauthRecovery.refreshTokenPath, or write the subclass, return it fromcreateNetworkService(), and bind that provider. - Returning
truewithout refreshing.truemeans "the token is new, retry now". The retry then fails with the same401. Resolvefalsewhen there is nothing to refresh, and throw when the refresh failed - a thrownrefreshTokenis what runsonAuthFailure. - Overriding
refreshTokenon the auth provider.DefaultAuthProvider.refreshToken()only calls the service'srefresh(). The refresh itself belongs to the service's hook, so a401anduseRefreshTokenmake it in one place. - Using
noAuthPathRegex: /./to "fix" a401. It turns off the token for every request and turns off recovery with it (AuthRecovery.canRecoverchecksisNoAuthPath, which is alwaystruethen). It is only for a service that talks to an unauthenticated API. - Using an invalid string in
noAuthPathRegex. The service logs it and drops it. The path stays authenticated and you find out from the401, not from a build error. Prefer aRegExpliteral. - Storing the token under your own key. The default
resolveAuthToken()reads the token through the auth service the request service was built with (authService.keys.token:LocalStorageKeys.KEY_AUTH_TOKEN, or the key scoped by the applicationid), orLocalStorageKeys.KEY_AUTH_TOKENwith no auth service. A token elsewhere is invisible to it unless you override the hook. - Binding the plain
DefaultRestDataProvider. OverridingcreateNetworkService()on a subclass changes nothing until that subclass is what sits underCoreBindings.DEFAULT_REST_DATA_PROVIDER.
Related
- Network -
DefaultNetworkRequestServiceand the request helpers - Data provider -
createNetworkService()andIRestDataProviderOptions - Auth provider -
DefaultAuthProviderandIAuthProviderOptions - Binding keys - the
CoreBindingsused to resolve the service and the provider - Binding keys best practices
- Services
- Migration from ra-core-infra