auth-backend: add omitIdentityTokenOwnershipClaim flag
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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] },
|
||||
});
|
||||
```
|
||||
@@ -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
|
||||
|
||||
Vendored
+9
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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']
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
Reference in New Issue
Block a user