Token Handler
Status
Proposed design for migrating the Java egress-router TokenHandler into
light-fabric as the token handler used by light-pingora and
light-gateway.
A baseline Rust token runtime already exists in light-pingora. This document
captures the Java behavior, the compatibility contract, and the design direction
for hardening it for gateway and sidecar deployments.
Purpose
The token handler obtains an OAuth 2.0 client credentials access token on behalf
of the backend service in the sidecar or gateway egress path. The token is then
attached to the outbound request before router or proxy sends the request to
the downstream API.
This is different from the PII tokenize and detokenize handlers. The
token handler deals only with service-to-service OAuth tokens.
Java Behavior To Map
The Java implementation is centered on:
egress-router/.../TokenHandler.javasidecar/.../SidecarTokenHandler.javarouter-config/.../TokenConfig.javaclient-config/.../client.yamlsidecar-config/.../sidecar.yml
token.yml controls whether the handler is active and which request paths need
token injection:
enabled: ${token.enabled:false}
appliedPathPrefixes: ${token.appliedPathPrefixes:}
The OAuth provider, client credentials, cache, timeout, proxy, HTTP/2, and
single-vs-multiple-auth-server settings live in client.yml:
oauth:
multipleAuthServers: ${client.multipleAuthServers:false}
token:
cache:
capacity: ${client.tokenCacheCapacity:200}
tokenRenewBeforeExpired: ${client.tokenRenewBeforeExpired:60000}
expiredRefreshRetryDelay: ${client.expiredRefreshRetryDelay:2000}
earlyRefreshRetryDelay: ${client.earlyRefreshRetryDelay:30000}
server_url: ${client.tokenServerUrl:}
serviceId: ${client.tokenServiceId:}
proxyHost: ${client.tokenProxyHost:}
proxyPort: ${client.tokenProxyPort:}
enableHttp2: ${client.tokenEnableHttp2:true}
client_credentials:
uri: ${client.tokenCcUri:/oauth2/token}
client_id: ${client.tokenCcClientId:}
client_secret: ${client.tokenCcClientSecret:}
scope: ${client.tokenCcScope:}
serviceIdAuthServers: ${client.tokenCcServiceIdAuthServers:}
pathPrefixServices: ${client.pathPrefixServices:}
request:
connectTimeout: ${client.connectTimeout:2000}
timeout: ${client.timeout:4000}
The Java request flow is:
- Reload
token.ymlfor the request. - Check
appliedPathPrefixeswith a string prefix match. - Read
service_idfrom the request. This header is expected to be set byPathPrefixServiceHandlerorServiceDictHandler. - Resolve the auth server configuration from
client.yml. - Get or refresh a cached client credentials JWT for the service.
- If the request has no
Authorizationheader, setAuthorization: Bearer <token>. - If the request already has
Authorization, preserve it and setX-Scope-Token: Bearer <token>. - Continue to the next handler, usually
router.
For multiple auth servers, Java reads
oauth.token.client_credentials.serviceIdAuthServers[service_id] and enriches
that entry with the global token defaults. For a single auth server, it uses the
global oauth.token.client_credentials section.
The Java cache is a static map keyed by service_id. The cached Jwt stores
the access token and its exp claim in milliseconds. OauthHelper refreshes
synchronously after expiry and attempts async refresh while the token is in the
renewal window.
SidecarTokenHandler adds an egress gate before calling TokenHandler:
sidecar.egressIngressIndicator: headerruns the token handler only when the request hasservice_idorservice_url.sidecar.egressIngressIndicator: protocolruns the token handler for HTTP requests, which is the usual in-pod sidecar egress protocol.- Any other value skips token injection.
The base Java TokenHandler still needs service_id to choose the service
token. A request with only service_url can identify egress traffic, but it
does not by itself select a service-specific token.
Goals
- Preserve the Java configuration files:
token.ymlandclient.yml. - Activate the handler with the existing
tokenid inhandler.yml. - Support config-server injection for
token.enabled,token.appliedPathPrefixes,client.multipleAuthServers,client.tokenCcServiceIdAuthServers,sidecar.egressIngressIndicator, and the rest of theclient.ymltoken fields. - Support single auth server and per-service auth server configurations.
- Support token endpoint discovery through
oauth.token.serviceIdwhen a directserver_urlis not configured. - Preserve the Java header behavior for
AuthorizationandX-Scope-Token. - Keep token retrieval fast and safe for request-path execution.
- Register configuration and token cache state with the module registry and runtime cache registry without exposing token or secret values.
- Keep the design usable by
light-gateway, future sidecar products, and BFF deployments that need to call downstream APIs.
Non-Goals
- Do not use
inventoryor dynamic plugins. Handler availability is compiled into the binary; handler activation is controlled byhandler.yml. - Do not implement authorization code, refresh token, or token exchange in this
handler. This handler only performs
client_credentials. - Do not migrate Java
SAMLTokenHandleras part of this design. - Do not use the PII tokenization table or handlers.
token,tokenize, anddetokenizeare separate concerns. - Do not send the generated access token to logs, metrics labels, module registry output, or cache summaries.
Resolved Decisions
- Use
sidecar.ymlto differentiate inbound proxy traffic from outbound router traffic before applying token injection. - Implement refresh with the same concurrency model as Java
http-client: synchronize refresh per cached token, refresh expired tokens synchronously, refresh valid tokens in the renewal window asynchronously, and use retry windows to prevent repeated failed refresh attempts.
Handler Chain
The token handler must run after service resolution and before egress routing:
handlers:
- correlation
- security
- path-prefix-service
- token
- router
chains:
sidecar-egress:
- correlation
- security
- path-prefix-service
- token
- router
paths:
- path: /v1/pets
method: GET
exec:
- sidecar-egress
path-prefix-service sets service_id from path configuration. token uses
that service id to resolve and cache the client credentials token. router
uses the same service id to select the downstream API target and should remove
routing-only headers before forwarding.
For products where only some outbound APIs need a scope token, keep one chain
with token and another without it, or use token.appliedPathPrefixes to
limit token injection inside a shared chain.
Rust Architecture
Keep the implementation in light-pingora because token injection is a
request-path gateway handler. light-gateway wires the handler into the
existing chain execution model.
Primary Rust module:
frameworks/light-pingora/src/token.rs
Primary types:
#![allow(unused)] fn main() { pub struct TokenHandlerConfig { pub enabled: bool, pub applied_path_prefixes: Vec<String>, } pub struct ClientTokenConfig { pub tls: ClientTlsConfig, pub oauth: ClientOauthConfig, pub path_prefix_services: BTreeMap<String, String>, pub request: ClientRequestConfig, } pub struct TokenRuntime { handler: TokenHandlerConfig, sidecar: SidecarTrafficConfig, client: ClientTokenConfig, cache: Arc<TokenCache>, registry_client: Option<Arc<PortalRegistryClient>>, } }
apps/light-gateway should load TokenRuntime only when the matched handler
configuration contains token. For Java compatibility, token.yml still has
enabled; therefore the handler is effective only when both conditions are
true:
handler.yml contains token
token.yml enabled is true
If token.yml enables the handler, client.yml is required and invalid
configuration should fail startup. sidecar.yml is also loaded into the token
runtime so the same handler chain can distinguish inbound proxy requests from
outbound router requests. Invalid reloads should be rejected while the last
valid runtime keeps serving traffic.
Request Flow
The Rust request flow should be:
- Resolve the active handler chain for the path and method.
- When
tokenis encountered, checkTokenHandlerConfig.enabled. - Evaluate
sidecar.ymland skip token injection for inbound proxy traffic. - Check
appliedPathPrefixeswith boundary-aware matching./v1/addressshould match/v1/address/123, but not/v1/address2. - Resolve the token service id:
- first from the
service_idrequest header, - then from
client.yml pathPrefixServices, - then from
oauth.token.serviceIdfor single-auth-server token endpoint discovery when applicable.
- first from the
- Resolve the token endpoint:
- use direct
server_urlfirst, - otherwise discover
oauth.token.serviceIdthrough portal registry.
- use direct
- Select client credentials:
- for single auth server, use
oauth.token.client_credentials, - for multiple auth servers, require
client_credentials.serviceIdAuthServers[service_id]and merge it with global token defaults.
- for single auth server, use
- Look up the token cache.
- Fetch a new token when the cache is missing, expired, or inside the refresh window.
- Add
AuthorizationorX-Scope-Tokenusing the Java-compatible rule.
The outbound token request should be Java-compatible:
POST {server_url}{uri}
Content-Type: application/x-www-form-urlencoded
Accept: application/json
Authorization: Basic base64(client_id:client_secret)
grant_type=client_credentials&scope=...
The response must contain access_token. Expiry should be derived from the JWT
exp claim when available, with expires_in as a fallback for non-JWT token
servers.
Cache And Refresh
Use a bounded async cache owned by TokenRuntime.
The cache key should include both service id and scope:
#![allow(unused)] fn main() { pub struct TokenCacheKey { pub service_id: Option<String>, pub scope: Option<String>, } }
This is stricter than the Java Map<String, Jwt> keyed only by service_id
and avoids collisions when the same service uses multiple scope sets.
Refresh policy:
- If the token is valid and outside the renewal window, use the cached token.
- If the token is expired, synchronize on that cache entry and refresh synchronously. Concurrent requests for the same service and scope should wait on the same per-entry lock, then re-check the refreshed token instead of making duplicate token endpoint calls.
- If the token is expired but another failed refresh attempt is still inside
expiredRefreshRetryDelay, fail closed with a token-not-available rejection. - If the token is in the renewal window but not expired, return the current
token and start one background refresh for that cache entry when no refresh is
already running and
earlyRefreshRetryDelayhas elapsed. - Keep refresh state per cached token: token string, expiry, scope,
renewing,expired_retry_timeout, andearly_retry_timeout.
This intentionally mirrors Java OauthHelper.populateCCToken. The Rust
implementation should use tokio locks/tasks instead of Java synchronized
and ScheduledExecutorService, but the observable behavior should stay the
same: expired tokens block the current request, early refresh does not block the
current request, and multiple concurrent requests for the same token are
coordinated through one cache entry.
On token.yml or client.yml reload, build a new TokenRuntime and discard
the old cache. This prevents tokens issued with old client credentials or old
scope configuration from being reused after a config change.
Sidecar Egress Gate
The token handler must use sidecar.yml to decide whether the current request
is outbound router traffic or inbound proxy traffic. This allows one gateway or
sidecar process to host both directions while applying token injection only to
egress calls.
Use the Java sidecar.yml contract:
egressIngressIndicator: ${sidecar.egressIngressIndicator:header}
Rust behavior:
header: runtokenonly whenservice_idorservice_urlis present.protocol: runtokenfor HTTP requests entering the sidecar listener.- any other value: skip token injection.
Even with this gate, token selection should still require either a resolved
service id or a single-auth-server configuration that can use a direct
server_url.
The sidecar config should be registered in the module registry as a framework config. Invalid values should fail startup or reject reload.
Configuration Examples
Single auth server:
# sidecar.yml
egressIngressIndicator: ${sidecar.egressIngressIndicator:header}
# token.yml
enabled: ${token.enabled:true}
appliedPathPrefixes: ${token.appliedPathPrefixes:/v1}
# client.yml
oauth:
multipleAuthServers: false
token:
server_url: ${client.tokenServerUrl:https://oauth.example.com}
tokenRenewBeforeExpired: ${client.tokenRenewBeforeExpired:60000}
client_credentials:
uri: ${client.tokenCcUri:/oauth2/token}
client_id: ${client.tokenCcClientId:gateway-client}
client_secret: ${client.tokenCcClientSecret:}
scope: ${client.tokenCcScope:petstore.r petstore.w}
Multiple auth servers:
# client.yml
oauth:
multipleAuthServers: true
token:
tokenRenewBeforeExpired: ${client.tokenRenewBeforeExpired:60000}
client_credentials:
uri: /oauth2/token
serviceIdAuthServers: ${client.tokenCcServiceIdAuthServers:}
pathPrefixServices: ${client.pathPrefixServices:}
The config server can inject client.tokenCcServiceIdAuthServers as YAML or a
JSON string:
com.networknt.petstore-1.0.0:
server_url: https://oauth-petstore.example.com
client_id: petstore-client
client_secret: ${PETSTORE_CLIENT_SECRET}
scope:
- petstore.r
- petstore.w
Rust Improvements Over Java
- Use boundary-aware path prefix matching instead of raw
startsWith. - Include scope in the cache key.
- Mask
client_secretand token values in module registry and cache output. - Fail startup for enabled but invalid token configuration.
- Use Rust async primitives to implement the same per-token synchronized refresh behavior as Java without spawning a dedicated executor per refresh attempt.
- Support direct
server_urland portal-registry discovery with the same runtime path. - Keep all config-server injected values in the normal module registry and reload model.
Observability
Record metrics and logs around the token operation, but never include the token or client secret:
- handler duration for
token, - cache hit, miss, refresh, and failure counts,
- token endpoint latency and HTTP status,
- service id and provider selection,
- refresh retry suppression counts,
- module registry entry for loaded
token.ymland maskedclient.yml, - runtime cache entry count and expiry summaries without access token strings.
Failure Behavior
Fail closed when token injection is required but cannot be completed:
- missing
service_idfor multiple auth servers, - missing
serviceIdAuthServers[service_id], - missing
client_idorclient_secret, - no direct
server_urland failed token service discovery, - token endpoint returns non-2xx,
- token response has no
access_token, - token response has neither JWT
expnorexpires_in, - invalid proxy, URL, or TLS configuration.
Requests outside appliedPathPrefixes should bypass the handler without error.
Test Plan
Unit tests in light-pingora:
- parse Java-compatible
token.ymlandclient.yml, - parse and validate Java-compatible
sidecar.yml, - parse
appliedPathPrefixesas YAML list, JSON string list, and comma list, - parse
serviceIdAuthServersas YAML map and JSON string map, - verify boundary-aware prefix matching,
- verify
sidecar.ymlheader mode applies token only to outbound requests withservice_idorservice_url, - verify
sidecar.ymlprotocol mode applies token to HTTP egress traffic, - verify single auth server option resolution,
- verify multiple auth server option merging,
- verify
AuthorizationversusX-Scope-Tokenheader selection, - verify cache key includes service id and scope,
- verify token cache summaries never include token strings,
- verify expired token refresh is synchronized across concurrent requests,
- verify early-window refresh returns the current token and starts only one background refresh.
Gateway tests in light-gateway:
- chain with
path-prefix-service -> token -> router, - inbound proxy request skips token injection according to
sidecar.yml, - outbound router request applies token injection according to
sidecar.yml, - missing service id for multiple auth servers returns a handler rejection,
- existing caller
Authorizationis preserved and scope token is added toX-Scope-Token, - token runtime reload swaps config and clears old cache,
- inactive
tokenhandler does not requiretoken.ymlorclient.yml.
Integration tests:
- mock OAuth token endpoint with client credentials Basic auth,
- mock discovered token service through portal registry,
- mock downstream service and assert the final outbound headers,
- refresh behavior with expired and near-expiry tokens.