auth-backend: add omitIdentityTokenOwnershipClaim flag

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-04-24 00:39:56 +02:00
parent 332e934112
commit 0d606aca23
13 changed files with 273 additions and 20 deletions
+30
View File
@@ -0,0 +1,30 @@
---
'@backstage/plugin-auth-backend': patch
---
Added the configuration flag `auth.omitIdentityTokenOwnershipClaim` that causes issued user tokens to no longer contain the `ent` claim that represents the ownership references of the user.
The benefit of this new flag is that issued user tokens will be much smaller in
size, but they will no longer be self-contained. This means that any consumers
of the token that require access to the ownership claims now need to call the
`/api/auth/v1/userinfo` endpoint instead. Within the Backstage ecosystem this is
done automatically, as clients will still receive the full set of claims during
authentication, while plugin backends will need to use the `UserInfoService`
which already calls the user info endpoint if necessary.
When enabling this flag, it is important that any custom sign-in resolvers directly return the result of the sign-in method. For example, the following would not work:
```ts
const { token } = await ctx.issueToken({
claims: { sub: entityRef, ent: [entityRef] },
});
return { token }; // WARNING: This will not work with the flag enabled
```
Instead, the sign-in resolver should directly return the result:
```ts
return ctx.issueToken({
claims: { sub: entityRef, ent: [entityRef] },
});
```
+44
View File
@@ -402,6 +402,50 @@ async signInResolver({ profile }, ctx) {
}
```
## Reducing the size of issued tokens
By default the auth backend will issue user identity tokens that include the
ownership references of the user in the `ent` claim of the JWT payload. This is
done to make it easier and more efficient for consumers of the token to resolve
ownership of the user. However, depending on the shape of your organization and
how you resolve ownership claims, these tokens can grow quite large.
To address this, the auth backend now supports the configuration flag
`auth.omitIdentityTokenOwnershipClaim` that causes the `ent` claim to be omitted
from the token. This can be set to `true` in the `app-config.yaml` file.
```yaml title="in app-config.yaml"
auth:
omitIdentityTokenOwnershipClaim: true
```
When this flag is set, the `ent` claim will no longer be present in the token,
and consumers of the token will need to call the `/v1/userinfo` endpoint on the
auth backend to fetch the ownership references of the user. However, there's usually no
action required for consumers. Clients will still receive the full set
of claims during authentication, and any plugin backends will already need to
use the
[`UserInfoService`](../backend-system/core-services/user-info.md) to
access the ownership references from user credentials, which already calls the
user info endpoint if necessary.
When enabling this flag, it is important that any custom sign-in resolvers directly return the result of the sign-in method. For example, the following would not work:
```ts
const { token } = await ctx.issueToken({
claims: { sub: entityRef, ent: [entityRef] },
});
return { token }; // WARNING: This will not work
```
Instead, the sign-in resolver should directly return the result:
```ts
return ctx.issueToken({
claims: { sub: entityRef, ent: [entityRef] },
});
```
## Profile Transforms
Similar to a custom sign-in resolver, you can also write a custom profile transform
+9
View File
@@ -43,6 +43,15 @@ export interface Config {
*/
identityTokenAlgorithm?: string;
/**
* Whether to omit the entity ownership references (`ent`) claim from the
* identity token. If this is enabled the `ent` claim will only be available
* via the user info endpoint and the `UserInfoService`.
*
* Defaults to `false`.
*/
omitIdentityTokenOwnershipClaim?: boolean;
/** To control how to store JWK data in auth-backend */
keyStore?: {
provider?: 'database' | 'memory' | 'firestore' | 'static';
+1
View File
@@ -69,6 +69,7 @@
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-guest-provider": "workspace:^",
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.6",
"@types/express-session": "^1.17.2",
+123
View File
@@ -17,6 +17,8 @@
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import request from 'supertest';
import { authPlugin } from './authPlugin';
import authModuleGuestProvider from '@backstage/plugin-auth-backend-module-guest-provider';
import { authServiceFactory } from '@backstage/backend-defaults/auth';
describe('authPlugin', () => {
it('should provide an OpenID configuration', async () => {
@@ -42,4 +44,125 @@ describe('authPlugin', () => {
issuer: `http://localhost:${server.port()}/api/auth`,
});
});
describe('mock provider', () => {
const mockProvidersConfig = {
environment: 'test',
providers: {
guest: {
dangerouslyAllowOutsideDevelopment: true,
userEntityRef: 'user:default/tester',
ownershipEntityRefs: [
'group:default/testers',
'group:default/testers2',
],
},
},
};
const expectedIdentity = {
type: 'user',
userEntityRef: 'user:default/tester',
ownershipEntityRefs: ['group:default/testers', 'group:default/testers2'],
};
it('should return tokens with all identity claims by default', async () => {
const { server } = await startTestBackend({
features: [
authPlugin,
authModuleGuestProvider,
authServiceFactory,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost',
},
auth: {
...mockProvidersConfig,
},
},
}),
],
});
const refreshRes = await request(server).post('/api/auth/guest/refresh');
expect(refreshRes.status).toBe(200);
expect(refreshRes.body).toMatchObject({
backstageIdentity: {
expiresInSeconds: expect.any(Number),
identity: expectedIdentity,
token: expect.any(String),
},
profile: {},
});
const token = refreshRes.body.backstageIdentity.token;
const decoded = JSON.parse(atob(token.split('.')[1]));
expect(decoded.sub).toEqual(expectedIdentity.userEntityRef);
expect(decoded.ent).toEqual(expectedIdentity.ownershipEntityRefs);
const userInfoRes = await request(server)
.get('/api/auth/v1/userinfo')
.set('Authorization', `Bearer ${token}`);
expect(userInfoRes.status).toBe(200);
expect(userInfoRes.body).toMatchObject({
claims: {
sub: expectedIdentity.userEntityRef,
ent: expectedIdentity.ownershipEntityRefs,
exp: expect.any(Number),
},
});
});
it('should omit ownership claims from the token when the config is set', async () => {
const { server } = await startTestBackend({
features: [
authPlugin,
authModuleGuestProvider,
authServiceFactory,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost',
},
auth: {
omitIdentityTokenOwnershipClaim: true,
...mockProvidersConfig,
},
},
}),
],
});
const refreshRes = await request(server).post('/api/auth/guest/refresh');
expect(refreshRes.status).toBe(200);
expect(refreshRes.body).toMatchObject({
backstageIdentity: {
expiresInSeconds: expect.any(Number),
identity: expectedIdentity,
token: expect.any(String),
},
profile: {},
});
const token = refreshRes.body.backstageIdentity.token;
const decoded = JSON.parse(atob(token.split('.')[1]));
expect(decoded.sub).toEqual(expectedIdentity.userEntityRef);
expect(decoded.ent).toBeUndefined();
const userInfoRes = await request(server)
.get('/api/auth/v1/userinfo')
.set('Authorization', `Bearer ${token}`);
expect(userInfoRes.status).toBe(200);
expect(userInfoRes.body).toMatchObject({
claims: {
sub: expectedIdentity.userEntityRef,
ent: expectedIdentity.ownershipEntityRefs,
exp: expect.any(Number),
},
});
});
});
});
@@ -83,7 +83,7 @@ describe('StaticTokenIssuer', () => {
options,
staticKeyStore as unknown as StaticKeyStore,
);
const token = await issuer.issueToken({
const { token } = await issuer.issueToken({
claims: {
sub: entityRef,
ent: [entityRef],
@@ -20,7 +20,10 @@ import { parseEntityRef } from '@backstage/catalog-model';
import { AuthenticationError } from '@backstage/errors';
import { LoggerService } from '@backstage/backend-plugin-api';
import { StaticKeyStore } from './StaticKeyStore';
import { TokenParams } from '@backstage/plugin-auth-node';
import {
BackstageSignInResult,
TokenParams,
} from '@backstage/plugin-auth-node';
const MS_IN_S = 1000;
@@ -56,7 +59,7 @@ export class StaticTokenIssuer implements TokenIssuer {
this.keyStore = keyStore;
}
public async issueToken(params: TokenParams): Promise<string> {
public async issueToken(params: TokenParams): Promise<BackstageSignInResult> {
const key = await this.getSigningKey();
// TODO: code shared with TokenFactory.ts
@@ -81,7 +84,15 @@ export class StaticTokenIssuer implements TokenIssuer {
throw new AuthenticationError('No algorithm was provided in the key');
}
return new SignJWT({ ...additionalClaims, iss, sub, ent, aud, iat, exp })
const token = await new SignJWT({
...additionalClaims,
iss,
sub,
ent,
aud,
iat,
exp,
})
.setProtectedHeader({ alg: key.alg, kid: key.kid })
.setIssuer(iss)
.setAudience(aud)
@@ -89,6 +100,7 @@ export class StaticTokenIssuer implements TokenIssuer {
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(await importJWK(key));
return { token };
}
private async getSigningKey(): Promise<JWK> {
@@ -60,7 +60,7 @@ describe('TokenFactory', () => {
});
await expect(factory.listPublicKeys()).resolves.toEqual({ keys: [] });
const token = await factory.issueToken({
const { token, identity } = await factory.issueToken({
claims: {
sub: entityRef,
ent: [entityRef],
@@ -68,6 +68,11 @@ describe('TokenFactory', () => {
aud: 'this value will be overridden',
},
});
expect(identity).toEqual({
type: 'user',
userEntityRef: entityRef,
ownershipEntityRefs: [entityRef],
});
const { keys } = await factory.listPublicKeys();
const keyStore = createLocalJWKSet({ keys: keys });
@@ -137,10 +142,10 @@ describe('TokenFactory', () => {
userInfoDatabaseHandler: mockUserInfoDatabaseHandler,
});
const token1 = await factory.issueToken({
const { token: token1 } = await factory.issueToken({
claims: { sub: entityRef },
});
const token2 = await factory.issueToken({
const { token: token2 } = await factory.issueToken({
claims: { sub: entityRef },
});
expect(jwtKid(token1)).toBe(jwtKid(token2));
@@ -159,7 +164,7 @@ describe('TokenFactory', () => {
keys: [],
});
const token3 = await factory.issueToken({
const { token: token3 } = await factory.issueToken({
claims: { sub: entityRef },
});
expect(jwtKid(token3)).not.toBe(jwtKid(token2));
@@ -236,7 +241,7 @@ describe('TokenFactory', () => {
userInfoDatabaseHandler: mockUserInfoDatabaseHandler,
});
const token = await factory.issueToken({
const { token } = await factory.issueToken({
claims: { sub: entityRef, ent: [entityRef] },
});
@@ -29,7 +29,11 @@ import { omit } from 'lodash';
import { DateTime } from 'luxon';
import { v4 as uuid } from 'uuid';
import { LoggerService } from '@backstage/backend-plugin-api';
import { TokenParams, tokenTypes } from '@backstage/plugin-auth-node';
import {
BackstageSignInResult,
TokenParams,
tokenTypes,
} from '@backstage/plugin-auth-node';
import { AnyJWK, KeyStore, TokenIssuer } from './types';
import { JsonValue } from '@backstage/types';
import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler';
@@ -119,6 +123,10 @@ type Options = {
* If not, add a knex migration file in the migrations folder.
* More info on supported algorithms: https://github.com/panva/jose */
algorithm?: string;
/**
* A list of claims to omit from issued tokens and only store in the user info database
*/
omitClaimsFromToken?: string[];
userInfoDatabaseHandler: UserInfoDatabaseHandler;
};
@@ -142,6 +150,7 @@ export class TokenFactory implements TokenIssuer {
private readonly keyStore: KeyStore;
private readonly keyDurationSeconds: number;
private readonly algorithm: string;
private readonly omitClaimsFromToken?: string[];
private readonly userInfoDatabaseHandler: UserInfoDatabaseHandler;
private keyExpiry?: Date;
@@ -153,10 +162,11 @@ export class TokenFactory implements TokenIssuer {
this.keyStore = options.keyStore;
this.keyDurationSeconds = options.keyDurationSeconds;
this.algorithm = options.algorithm ?? 'ES256';
this.omitClaimsFromToken = options.omitClaimsFromToken;
this.userInfoDatabaseHandler = options.userInfoDatabaseHandler;
}
async issueToken(params: TokenParams): Promise<string> {
async issueToken(params: TokenParams): Promise<BackstageSignInResult> {
const key = await this.getKey();
const iss = this.issuer;
@@ -203,7 +213,10 @@ export class TokenFactory implements TokenIssuer {
uip,
};
const token = await new SignJWT(claims)
const tokenClaims = this.omitClaimsFromToken
? omit(claims, this.omitClaimsFromToken)
: claims;
const token = await new SignJWT(tokenClaims)
.setProtectedHeader({
typ: tokenTypes.user.typParam,
alg: key.alg,
@@ -214,7 +227,7 @@ export class TokenFactory implements TokenIssuer {
if (token.length > MAX_TOKEN_LENGTH) {
throw new Error(
`Failed to issue a new user token. The resulting token is excessively large, with either too many ownership claims or too large custom claims. You likely have a bug either in the sign-in resolver or catalog data. The following claims were requested: '${JSON.stringify(
claims,
tokenClaims,
)}'`,
);
}
@@ -225,7 +238,14 @@ export class TokenFactory implements TokenIssuer {
claims: omit(claims, ['aud', 'iat', 'iss', 'uip']),
});
return token;
return {
token,
identity: {
type: 'user',
userEntityRef: sub,
ownershipEntityRefs: ent,
},
};
}
// This will be called by other services that want to verify ID tokens.
+7 -2
View File
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { TokenParams } from '@backstage/plugin-auth-node';
import {
BackstageUserIdentity,
TokenParams,
} from '@backstage/plugin-auth-node';
/** Represents any form of serializable JWK */
export interface AnyJWK extends Record<string, string> {
@@ -31,7 +34,9 @@ export type TokenIssuer = {
/**
* Issues a new ID Token
*/
issueToken(params: TokenParams): Promise<string>;
issueToken(
params: TokenParams,
): Promise<{ token: string; identity?: BackstageUserIdentity }>;
/**
* List all public keys that are currently being used to sign tokens, or have been used
@@ -77,8 +77,7 @@ export class CatalogAuthResolverContext implements AuthResolverContext {
) {}
async issueToken(params: TokenParams) {
const token = await this.tokenIssuer.issueToken(params);
return { token };
return await this.tokenIssuer.issueToken(params);
}
async findCatalogUser(query: AuthResolverCatalogUserQuery) {
@@ -147,13 +146,12 @@ export class CatalogAuthResolverContext implements AuthResolverContext {
entity,
);
const token = await this.tokenIssuer.issueToken({
return await this.tokenIssuer.issueToken({
claims: {
sub: stringifyEntityRef(entity),
ent: ownershipEntityRefs,
},
});
return { token };
}
async resolveOwnershipEntityRefs(
@@ -101,6 +101,11 @@ export async function createRouter(
tokenFactoryAlgorithm ??
config.getOptionalString('auth.identityTokenAlgorithm'),
userInfoDatabaseHandler,
omitClaimsFromToken: config.getOptionalBoolean(
'auth.omitIdentityTokenOwnershipClaim',
)
? ['ent']
: [],
});
}
+1
View File
@@ -5286,6 +5286,7 @@ __metadata:
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-guest-provider": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
"@backstage/types": "workspace:^"