Auth Identity Handling
Applies to
@connectum/auth1.3.0.
Does this apply to you?
You need to act if any of these is true:
- a handler, or a
propagateHeaderslist, relies onx-auth-*headers that the client sent throughcreateGatewayAuthInterceptor; trustSource.expectedValuesofcreateGatewayAuthInterceptorcontains an IPv4 CIDR block such as10.0.0.0/8;verifyCredentials(createAuthInterceptor) ormapSession(createSessionAuthInterceptor) can return anything other than a completeAuthContext, for examplenullafter a failed lookup or an object withoutscopesorclaims;- you turn
propagateHeaderson and an identity has a scope that is empty or contains a space, a subject over 512 characters, a type over 128, a name over 256, or roles, scopes or claims whose serialized value is over 8192 characters; - a header name option (
headerMapping,trustSource.header,stripHeaders, or theheaderof an internal trust source) is not a valid HTTP header name, a gatewaydefaultTypeis empty, over 128 characters or has a lone surrogate whilepropagateHeadersis on, or the client gateway interceptor gets an empty or whitespace-edgedsecret, an emptysubjector malformedroles; - a service that does not use
@connectum/authreadsx-auth-subject,x-auth-name,x-auth-typeorx-auth-scopesand can now receive users with non-Latin names.
Otherwise nothing changes for you. Identities that fit, and ASCII and Latin-1 text, are written as before, with two exceptions that are now encoded (utf-8'' plus percent-encoding). A subject, name, type or scope with a space or tab at either end used to be trimmed by the platform, so a subject of U+0020 followed by alice and the subject alice were one identity; they are now two. A text that itself starts with utf-8'' is encoded so that readers can tell it from an encoded one.
Required changes
The gateway interceptor deletes every standard x-auth-* header
After createGatewayAuthInterceptor has read its mapped input, it removes x-auth-subject, x-auth-type, x-auth-name, x-auth-roles, x-auth-scopes, x-auth-claims and x-auth-encoding, on admitted requests and on skipMethods. With propagateHeaders: true it then writes the verified identity back. A client-supplied x-auth-roles used to survive when the verified identity had no roles, so a service that trusted those headers saw the forged value. The x-auth-* names now belong to the verified identity: if you need a client value to reach a handler, have the gateway send it under another name. A gateway that maps x-auth-* names in headerMapping still reads them.
CIDR blocks in trustSource need an opt-in
A block is matched against the value of the trust header, and a caller that reaches the service directly chooses that value, so 10.0.0.0/8 or 0.0.0.0/0 admitted anyone who sent a matching address. Creating the interceptor with a well-formed block now throws, naming the block and the header. Prefer a shared secret. If a trusted upstream overwrites the header on every request and no client can reach the service without passing through it, set trustSource.allowCallerControlledCidr: true. Shared secrets, including ones that contain a /, are unchanged.
Return a complete AuthContext from your callbacks
The result needs a string subject and type, arrays roles and scopes of at most 8192 strings and without holes (a sparse list, a list with a null or a number, and a list that cannot be read item by item are all refused), an object claims, and, when present, a string name and a Date expiresAt. Any other result ends the call with Unauthenticated, with or without propagateHeaders, and is not cached. Before, null, undefined, false or {} made the call fail with internal when headers were propagated, and let the handler run without an identity when they were not.
A result that passes this check but cannot be used afterwards ends the call with Unauthenticated too: when the identity is copied for the cache or written into headers and that fails (a property or claims key whose getter throws, a proxy whose trap throws, a cyclic or BigInt claim, a toJSON that throws), the raw error no longer reaches the client as internal, and nothing is cached. The internal-auth interceptor treats a custom trust source result that is not an AuthContext like null.
Do not grant an empty scope or a scope that contains a space while propagating headers
Scopes travel joined by spaces and are split on spaces, with empty pieces dropped, so reports read admin reached the next hop as three scopes and the empty scope in a, an empty scope, b was lost. Both are now rejected with Unauthenticated (text Authentication failed) and are not cached. With propagateHeaders off the identity is admitted.
Identities that do not fit their headers are refused
With propagateHeaders on, a subject over 512 characters, a type over 128, a name over 256, or roles, scopes or claims whose serialized value is over 8192 characters (after the propagatedClaims filter) end the call with Unauthenticated and are not cached. Before, the subject was cut, so two subjects that shared their first 512 characters became one identity, and an oversize roles, scopes or claims header was left out, so a deny rule that looks at a role or claim stopped matching. Shorten the identity, filter claims with propagatedClaims, or turn propagation off. parseAuthHeaders now throws Unauthenticated for a header over its limit, the gateway interceptor rejects a claims header over 8192 characters instead of reading {} and, for the standard x-auth-* names, applies the other limits too even when propagateHeaders is off, and createClientGatewayInterceptor throws at creation for a subject over 512 characters or roles that do not fit the roles header (a roles list is measured as the writer measures it: every character above U+00FF takes six characters), and InvalidArgument per call if the roles array was changed afterwards, instead of cutting.
Non-Latin text is encoded, not rejected
A subject, name, type or scope with a character above U+00FF (Cyrillic, CJK, an emoji) used to end the call with Unauthenticated when headers were propagated. It is now admitted and written as utf-8'' plus percent-encoded UTF-8, for example Иван becomes utf-8''%D0%98%D0%B2%D0%B0%D0%BD. parseAuthHeaders and createGatewayAuthInterceptor (for the standard x-auth-* names) decode it, but only when the sender also set x-auth-encoding: percent-utf8, which the writer adds whenever it encoded a text; a literal utf-8''admin subject from an older service or another gateway, which sends no such header, stays that literal text. A service that reads these headers without @connectum/auth sees the encoded form for such users and should decode it with decodeURIComponent after removing the utf-8'' prefix, when x-auth-encoding is present. During a rolling upgrade, an older receiver shows the encoded form for users that were rejected before, and for texts with a space or tab at either end.
String options are checked when the interceptor is created
Options whose value is used as a header name or written into a header used to fail only when a request arrived, as an internal error. Creating the interceptor now throws a TypeError (or a RangeError for a length) that names the option when a header name is not a valid HTTP header name, when a gateway defaultType is empty, longer than 128 characters or holds a lone surrogate while propagateHeaders is on (with it off the value is accepted as before), and when the client gateway interceptor gets an empty secret, one with a line break, a character above U+00FF or a space or tab at either end, an empty subject or one with a line break or a lone surrogate, or roles that is not an array of strings without holes. The writer also refuses an empty subject or type, which the reader would not read back.
Cached identities are copied per request
With cache, every request receives its own copy of the cached AuthContext (roles, scopes, expiresAt, and claims with its nested plain objects, arrays and dates; other values in claims, such as a Map or a class instance, are shared). Before, the cache returned one mutable object to every request with the same credential, so getAuthContext().roles.push('admin') in one handler granted the role to later requests with the same token. Code that changes its context keeps working. Each cache hit costs one pass over the identity.
Before and after
// Before: accepted, the handler ran without an identity (or the call failed with `internal`)
createAuthInterceptor({ verifyCredentials: async (key) => await db.findByApiKey(key) });
// After: return a complete AuthContext, or throw
createAuthInterceptor({
verifyCredentials: async (key) => {
const user = await db.findByApiKey(key);
if (!user) throw new Error('Invalid API key');
return { subject: user.id, roles: user.roles, scopes: [], claims: {}, type: 'api-key' };
},
});// Before: accepted for any caller that sends a matching address
createGatewayAuthInterceptor({
headerMapping: { subject: 'x-user-id' },
trustSource: { header: 'x-forwarded-for', expectedValues: ['10.0.0.0/8'] },
});
// After: a shared secret
createGatewayAuthInterceptor({
headerMapping: { subject: 'x-user-id' },
trustSource: { header: 'x-gateway-secret', expectedValues: [process.env.GATEWAY_SECRET!] },
});Verify the upgrade
Call a gateway-protected method with a valid trust header and forged x-auth-roles, x-auth-scopes, x-auth-name and x-auth-claims headers for a user without those fields: the handler, and any service the headers are forwarded to, must see none of them. Create the gateway interceptor with your trust list in a startup test: a CIDR block throws. Return null from your verifier in a test and expect Unauthenticated. Propagate an identity with a Cyrillic name and read it back with parseAuthHeaders.