Token handler
ADSP Token Handler provides the token handler pattern as a service. In the token handler pattern, a backend component acts as a confidential client (relying party) to authenticate end users, stores tokens in sessions, and adds the access token to API requests. This keeps tokens out of the frontend, but requires session management and secure use of cookies.
Token handlers are often implemented in Backend for Frontends (BFFs), but the ADSP implementation provides the capability generically so that light frontend applications can be delivered without a dedicated BFF.
Client roles
client urn:ads:platform:token-handler
| name | description |
|---|---|
| token-handler-admin | Administrator role for token handler allowed to register clients. |
Concepts
Clients
Clients correspond to OAuth clients (OIDC relying party) in access service. The token handler requires configuration of clients to support client registration in access service. Registration creates confidential clients (with client ID and secret) which are used to authenticate users.
Clients are configured in the configuration service under the platform:token-handler namespace and name. The frontend application passes the OIDC callback URL at auth initiation time via the ?callbackUrl= query parameter on the sign in endpoint (e.g. https://myapp.example.ca/token-handler/clients/my-client/auth?callbackUrl=https://myapp.example.ca/token-handler/clients/my-client/callback; see Reverse proxy). This means no callback URL needs to be stored in the token handler configuration, and the same configuration works across all environments including local development. Redirect URI validation is handled by access service; the callbackUrl provided must be registered in the Keycloak client’s Valid Redirect URIs.
Deprecated fallback: Existing configurations with
authCallbackUrlset will continue to work — the token handler extracts the path fromauthCallbackUrland combines it with the incoming request’s host and protocol when no?callbackUrl=query parameter is provided.
Core (platform) clients
Clients defined in the core configuration (the platform’s own platform:token-handler configuration) are available to every tenant, in addition to the tenant’s own clients. This allows a platform application, such as the preview in the builder app, to use the token handler without each tenant defining the client. Core clients can include description and redirectUris, and a tenant cannot change or replace them: when a tenant client has the same ID as a core client, the core client is used. The tenant administrator app prevents adding a client with the ID of a core client, so a collision can only occur for a tenant client that was added before the core client.
Each tenant still has its own registration of a core client, because registration creates a Keycloak client in the tenant’s realm and the client secret is stored per tenant. A tenant administrator registers the client once from the token handler tab (core clients are listed there as platform clients, with their targets and registration status), or by calling the register endpoint. The redirectUris of the client configuration are applied as the Valid Redirect URIs of the Keycloak client at registration, together with the callbackUrl if one is passed. Changing redirectUris later does not change clients that are already registered; those are updated in the Keycloak admin console or with the update endpoint.
The builder app’s preview uses a core client, builder-preview, that token handler registers in the core configuration on startup when the BUILDER_APP_URL environment variable (the public URL of the builder app) is set. Its redirect URI is on the builder app’s origin (<BUILDER_APP_URL>/token-handler/clients/builder-preview/callback), so each environment sets its own value. The client is written on every startup, so changes to it are made in the token handler rather than in the core configuration.
Administrators can list the clients available to their tenant, core and tenant clients together with their registration status, with GET /token-handler/v1/clients. The core property of each client indicates whether it is from the core configuration, and ?core=true returns only core clients.
Targets
Targets represent upstream services or APIs that the token handler can proxy requests to. For requests from the frontend to the configured targets, token handler will retrieve a valid access token based on the current user session and include it as a bearer token for the upstream request. Target upstream is configured as an ADSP service or API URN, and the URN must have an entry in the directory service. Targets are configured as part of clients in the configuration service under the platform:token-handler namespace and name.
The upstream request only carries the user’s access token for authentication. Token handler does not pass on the session cookie, CSRF token or tenant headers of the incoming request, and cookies set by the upstream are not passed back to the frontend. Request paths must stay within the target’s base path; paths with dot segments (. or ..) or encoded path separators are rejected as invalid, and the query string is passed through unchanged.
Target upstreams are resolved from the directory, where tenant administrators can register entries for their own namespace. The operator of token handler can restrict the hosts that target upstreams may use by setting the UPSTREAM_ALLOWED_DOMAINS environment variable to a comma-separated list of host names (e.g. form-service,api.example.ca) and wildcards for subdomains (e.g. *.apps.example.ca, which does not include apps.example.ca itself). An entry can include a port (e.g. form-service:3333), in which case the upstream must use that port (the default port for the scheme if the URL has none); an entry without a port allows any port on the host, so list the ports of internal services. When it is set, requests to a target whose upstream does not resolve to an allowed host are rejected. Upstreams in the platform namespace (urn:ads:platform:...) are not checked, because only platform administrators can register entries in it, so platform services can use cluster internal URLs without listing them; every other namespace is checked, so tenant entries cannot target internal hosts. Only http and https URLs without credentials are accepted, the scheme is not otherwise restricted, and IPv4 addresses must be listed explicitly. Avoid wildcards for public suffixes (e.g. *.co.uk), which allow any domain under them. Host names are matched as they appear in the URL and are not resolved. A wildcard matches subdomains at any depth, so *.example.ca allows www.example.ca and a.b.example.ca but not example.ca. When the value is set in a YAML file, such as a ConfigMap, quote it if it starts with *, e.g. '*.example.ca,*.example.org'. When it is not set, upstreams are not restricted and token handler logs a warning on startup. The setting is not part of the tenant configuration, so tenant administrators cannot change it.
Builder app preview
The builder app uses the token handler so that the app in its preview can make platform API requests as the signed in user, without holding a token. This is the pattern for a dedicated deployment of an app as well: a static frontend, a /token-handler proxy on the app’s own origin, a client and its targets.
- Client: token handler registers the
builder-previewcore client whenBUILDER_APP_URLis set. Its targets areform-serviceandfile-service, so those are the services that previewed apps can call. A tenant administrator registers the client once for the tenant in the token handler tab of the tenant management app; until then the builder shows a message that it is required. - Proxy: the builder’s web server proxies
/token-handler/to the token handler on the builder’s origin, so the session cookie is set for the builder’s site and the preview, which is served from the builder’s origin, gets it. - Sign in: when the builder loads without a token handler session it redirects to the client’s sign in, which returns straight back because the user is already signed in to access service. The preview code does not sign in or handle tokens.
- Requests:
/token-handler/targets/<target>/<service path>, for example/token-handler/targets/file-service/file/v1/files. The React template’sapiFetchadds the CSRF header that requests other than GET need. - Local development: the builder’s dev server proxies
/token-handlerto the dev token handler and removes theSecureflag from its cookies, because Safari does not storeSecurecookies forhttp://localhost.http://localhost:4200/token-handler/clients/builder-preview/callbackmust be in the client’s valid redirect URIs in the tenant’s realm. - Limits: the preview runs on the builder’s origin, so the app code shares the origin, and the user’s builder session, with the builder. Requests run as the builder’s user, limited by the roles that user has. Adding a target to
builder-previewmakes that service available to every previewed app.
Logout
Frontend applications log users out by navigating the browser to GET /token-handler/v1/clients/{clientId}/logout, which can be a plain link. This ends the user’s session in token handler, discarding the stored tokens, and redirects to the root of the site. By default this does not end the user’s session in access service, so signing in again from the same browser can complete without entering credentials.
Set keycloakLogout to true on the client to also end the user’s single sign on session in access service, so that tokens issued for the session can no longer be refreshed. Logout then redirects the browser to the access service end session endpoint, which returns the browser to the root of the site that the user logged in from (the root of the host of the callbackUrl used at login). The client in access service must have that URL (e.g. https://myapp.example.ca/) in its Valid post logout redirect URIs; otherwise access service shows an error page and the user is not logged out of access service. For this reason it is off by default, and should be enabled once the post logout redirect URI is configured. Sessions that were started before the ID token and site were kept in the session are logged out of token handler only. Requests that another site caused the browser to make (Sec-Fetch-Site: cross-site) only end the token handler session, so another site cannot sign users out of access service.
Cross-site request forgery (CSRF)
The token handler uses sessions and cookies which can be vulnerable to CSRF attacks. A Cookie-to-header token is used as CSRF protection. Frontend applications need to read the value of the XSRF-TOKEN cookie and include it as the value of the X-XSRF-TOKEN header in requests to targets; this behavior is built into Angular.
Reverse proxy
Frontend applications must use a reverse proxy to proxy requests to the token handler from the frontend site domain. The token handler sets a session cookie without the domain attribute, and browsers will associate the cookie with the domain of the authorization callback request. Consequently the cookie will only be included on subsequent requests to the site if that callback request is to the same domain as the rest of the site.
All routes of the token handler are under the /token-handler/v1 path of its URL, so a single proxy location is enough. The frontend can use any path for it; the examples use /token-handler. The frontend then uses these paths:
- Sign in:
/token-handler/clients/${clientId}/auth?tenant=<tenant name>&callbackUrl=<callback URL> - Sign in callback:
/token-handler/clients/${clientId}/callback; the callback URL passed when signing in is this path on the frontend site. - Sign out:
/token-handler/clients/${clientId}/logout - Session information:
/token-handler/sessions - Targets:
/token-handler/targets/${targetId}/...
The sign in endpoint needs to know the tenant, which is the tenant query parameter or the X-ADSP-TENANT header. The frontend can hardcode the name of its tenant for the parameter, since the name is the same in every environment (a tenant URN is not). The tenant is kept in the session, so the callback endpoint does not need it. The reverse proxy can set the header to restrict the frontend domain to one tenant, and the header is used instead of the query parameter if both are provided. The value is the name of the tenant (as it is, ignoring case, or in the kebab-case form that has hyphens in place of spaces, e.g. my-tenant-name) or a full tenant URN.
The proxy should also set the X-Forwarded-For and X-Forwarded-Proto headers, which the token handler uses for rate limiting and to mark cookies as secure. The Host header of the original request is not needed (except by clients that still use the deprecated authCallbackUrl fallback).
Nginx configuration example
location /token-handler/ {
proxy_pass <token handler URL>/token-handler/v1/;
proxy_ssl_server_name on;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
proxy_ssl_server_name is needed when the token handler URL is HTTPS. Use $scheme for X-Forwarded-Proto if nginx terminates TLS itself. To restrict the frontend domain to one tenant, also add proxy_set_header X-Adsp-Tenant "<tenant name>"; to the location. The client page in tenant management shows this configuration with the values of the client.
Local development
There are some special considerations for local development workflows when using the token handler.
- Webpack DevServer proxy can be used to proxy requests to the token handler. The frontend passes the
tenantquery parameter when it signs in, or the proxy can inject theX-ADSP-TENANTheader; the tenant name can be used instead of the full URN. - The frontend passes
?callbackUrl=http://localhost:<port>/token-handler/clients/<clientId>/callbackwhen redirecting to the sign in path for local development. Add this URL to the Keycloak client’s Valid Redirect URIs directly in the access service admin console — no token handler configuration change is required. - The token handler can only proxy requests to upstream services and APIs that are registered in directory service; i.e. local running instances of backends cannot be used. In practice, this means that when working with full stack applications, local development of the frontend will require a deployed instance of the backend.
Webpack DevServer proxy configuration example
{
"/token-handler": {
"target": "<token handler URL>",
"secure": true,
"changeOrigin": true,
"pathRewrite": { "^/token-handler": "/token-handler/v1" }
}
}
Code examples
Register a client
Registering a client creates a confidential client in access service and securely stores the associated client ID and secret in token handler. It is required before token handler can authenticate users using the client. The optional authCallbackUrl provided here is registered with access service as a valid redirect URI and is not stored in the token handler configuration. If it is not provided, the client is registered without redirect URIs, which must then be added to the client in access service (Keycloak) before users can sign in. Registering a client that is already registered replaces its registration: the previously registered client in access service is deleted, including any redirect URIs configured on it.
const response = await fetch(
`https://adsp.alberta.ca/api/token-handler/v1/clients/${clientId}`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
registrationToken,
authCallbackUrl: 'https://myapp.example.ca/token-handler/clients/my-client/callback',
}),
}
);
const {
registered
} = await response.json();
Update client registration
Updating a client’s registration allows changing the registered redirect URIs in access service without creating a new client or invalidating existing sessions. This uses the management token stored from the original registration, so no external token is required.
const response = await fetch(
`https://adsp.alberta.ca/api/token-handler/v1/clients/${clientId}`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
redirectUris: [
'https://myapp.example.ca/token-handler/clients/my-client/callback',
'http://localhost:4200/token-handler/clients/my-client/callback',
],
}),
}
);
const {
updated
} = await response.json();
Retrieving session information
Frontend application can determine if the user is logged in and access their session information via the sessions endpoint.
const response = await fetch(
'/sessions',
{
method: 'GET',
}
);
const [session] = await response.json();