diff --git a/.changeset/angry-sites-fold.md b/.changeset/angry-sites-fold.md new file mode 100644 index 0000000000..8124ddaf47 --- /dev/null +++ b/.changeset/angry-sites-fold.md @@ -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] }, +}); +``` diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index cce8f08b7a..6a59805b0b 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -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 diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index bf91780c34..4e099e75be 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -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'; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index e6da6d2eb6..c5b8823a14 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -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", diff --git a/plugins/auth-backend/src/authPlugin.test.ts b/plugins/auth-backend/src/authPlugin.test.ts index 008df01295..cd2b558c80 100644 --- a/plugins/auth-backend/src/authPlugin.test.ts +++ b/plugins/auth-backend/src/authPlugin.test.ts @@ -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), + }, + }); + }); + }); }); diff --git a/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts b/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts index 92b5888113..1b9b5311ba 100644 --- a/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts +++ b/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts @@ -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], diff --git a/plugins/auth-backend/src/identity/StaticTokenIssuer.ts b/plugins/auth-backend/src/identity/StaticTokenIssuer.ts index 17fbe1b180..827dbc09ba 100644 --- a/plugins/auth-backend/src/identity/StaticTokenIssuer.ts +++ b/plugins/auth-backend/src/identity/StaticTokenIssuer.ts @@ -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 { + public async issueToken(params: TokenParams): Promise { 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 { diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 53cec685d1..764b26eda4 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -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] }, }); diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index a1643cdbe3..28addbbaeb 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -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 { + async issueToken(params: TokenParams): Promise { 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. diff --git a/plugins/auth-backend/src/identity/types.ts b/plugins/auth-backend/src/identity/types.ts index 0693a2aba0..786e5d6481 100644 --- a/plugins/auth-backend/src/identity/types.ts +++ b/plugins/auth-backend/src/identity/types.ts @@ -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 { @@ -31,7 +34,9 @@ export type TokenIssuer = { /** * Issues a new ID Token */ - issueToken(params: TokenParams): Promise; + issueToken( + params: TokenParams, + ): Promise<{ token: string; identity?: BackstageUserIdentity }>; /** * List all public keys that are currently being used to sign tokens, or have been used diff --git a/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts b/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts index bbf42c70eb..5268833d14 100644 --- a/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts +++ b/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts @@ -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( diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 1952e9d648..3f53a111a1 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -101,6 +101,11 @@ export async function createRouter( tokenFactoryAlgorithm ?? config.getOptionalString('auth.identityTokenAlgorithm'), userInfoDatabaseHandler, + omitClaimsFromToken: config.getOptionalBoolean( + 'auth.omitIdentityTokenOwnershipClaim', + ) + ? ['ent'] + : [], }); } diff --git a/yarn.lock b/yarn.lock index 20037a1652..564c393629 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:^"