From f6aae90e4e759965c3d62a307170de61f2432f30 Mon Sep 17 00:00:00 2001 From: Manuel Scurti Date: Tue, 17 May 2022 19:40:15 +0200 Subject: [PATCH 1/3] Added configurable signing algorithm Signed-off-by: Manuel Scurti --- .changeset/polite-spiders-pay.md | 6 ++ .../src/identity/TokenFactory.test.ts | 43 +++++++++++++- .../auth-backend/src/identity/TokenFactory.ts | 22 +++++--- plugins/auth-node/src/IdentityClient.test.ts | 56 +++++++++++++++++-- plugins/auth-node/src/IdentityClient.ts | 30 ++++++---- 5 files changed, 130 insertions(+), 27 deletions(-) create mode 100644 .changeset/polite-spiders-pay.md diff --git a/.changeset/polite-spiders-pay.md b/.changeset/polite-spiders-pay.md new file mode 100644 index 0000000000..882172b4d9 --- /dev/null +++ b/.changeset/polite-spiders-pay.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-auth-backend': minor +'@backstage/plugin-auth-node': minor +--- + +Added configurable algorithm field for IdentityClient and TokenFactory diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 3ae6b895bc..291359676f 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { getVoidLogger } from '@backstage/backend-common'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from 'jose'; import { MemoryKeyStore } from './MemoryKeyStore'; import { TokenFactory } from './TokenFactory'; -import { getVoidLogger } from '@backstage/backend-common'; -import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from 'jose'; -import { stringifyEntityRef } from '@backstage/catalog-model'; const logger = getVoidLogger(); @@ -130,4 +130,41 @@ describe('TokenFactory', () => { }); }).rejects.toThrowError(); }); + + it('should throw error on empty algorithm string', async () => { + const keyDurationSeconds = 5; + const factory = new TokenFactory({ + issuer: 'my-issuer', + keyStore: new MemoryKeyStore(), + keyDurationSeconds, + logger, + algorithm: '', + }); + + await expect(() => { + return factory.issueToken({ + claims: { sub: 'UserId' }, + }); + }).rejects.toThrowError(); + }); + + it('should defaults to ES256 when no algorithm string is supplied', async () => { + const keyDurationSeconds = 5; + const factory = new TokenFactory({ + issuer: 'my-issuer', + keyStore: new MemoryKeyStore(), + keyDurationSeconds, + logger, + }); + + const token = await factory.issueToken({ + claims: { sub: entityRef, ent: [entityRef] }, + }); + + const { keys } = await factory.listPublicKeys(); + const keyStore = createLocalJWKSet({ keys: keys }); + + const verifyResult = await jwtVerify(token, keyStore); + expect(verifyResult.protectedHeader.alg).toBe('ES256'); + }); }); diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index cb24d7ccd1..c8e5feb81e 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types'; -import { exportJWK, generateKeyPair, importJWK, JWK, SignJWT } from 'jose'; -import { Logger } from 'winston'; -import { v4 as uuid } from 'uuid'; -import { DateTime } from 'luxon'; import { parseEntityRef } from '@backstage/catalog-model'; import { AuthenticationError } from '@backstage/errors'; +import { exportJWK, generateKeyPair, importJWK, JWK, SignJWT } from 'jose'; +import { DateTime } from 'luxon'; +import { v4 as uuid } from 'uuid'; +import { Logger } from 'winston'; + +import { AnyJWK, KeyStore, TokenIssuer, TokenParams } from './types'; const MS_IN_S = 1000; @@ -32,6 +32,10 @@ type Options = { keyStore: KeyStore; /** Expiration time of signing keys in seconds */ keyDurationSeconds: number; + /** JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256. + * Must match the algorithm defined in IdentityClient. + * More info on supported algorithms: https://github.com/panva/jose */ + algorithm?: string; }; /** @@ -53,6 +57,7 @@ export class TokenFactory implements TokenIssuer { private readonly logger: Logger; private readonly keyStore: KeyStore; private readonly keyDurationSeconds: number; + private readonly algorithm: string; private keyExpiry?: Date; private privateKeyPromise?: Promise; @@ -62,6 +67,7 @@ export class TokenFactory implements TokenIssuer { this.logger = options.logger; this.keyStore = options.keyStore; this.keyDurationSeconds = options.keyDurationSeconds; + this.algorithm = options.algorithm ?? 'ES256'; } async issueToken(params: TokenParams): Promise { @@ -156,11 +162,11 @@ export class TokenFactory implements TokenIssuer { .toJSDate(); const promise = (async () => { // This generates a new signing key to be used to sign tokens until the next key rotation - const key = await generateKeyPair('ES256'); + const key = await generateKeyPair(this.algorithm); const publicKey = await exportJWK(key.publicKey); const privateKey = await exportJWK(key.privateKey); publicKey.kid = privateKey.kid = uuid(); - publicKey.alg = privateKey.alg = 'ES256'; + publicKey.alg = privateKey.alg = this.algorithm; // We're not allowed to use the key until it has been successfully stored // TODO: some token verification implementations aggressively cache the list of keys, and diff --git a/plugins/auth-node/src/IdentityClient.test.ts b/plugins/auth-node/src/IdentityClient.test.ts index 5efc85cb21..a773764e2d 100644 --- a/plugins/auth-node/src/IdentityClient.test.ts +++ b/plugins/auth-node/src/IdentityClient.test.ts @@ -13,20 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { - SignJWT, - generateKeyPair, decodeProtectedHeader, exportJWK, + generateKeyPair, + SignJWT, } from 'jose'; import { cloneDeep } from 'lodash'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { IdentityClient } from './IdentityClient'; import { v4 as uuid } from 'uuid'; +import { IdentityClient } from './IdentityClient'; + interface AnyJWK extends Record { use: 'sig'; alg: string; @@ -112,6 +112,54 @@ describe('IdentityClient', () => { }); }); + describe('identity client configuration', () => { + beforeEach(() => { + server.use( + rest.get( + `${mockBaseUrl}/.well-known/jwks.json`, + async (_, res, ctx) => { + const keys = await factory.listPublicKeys(); + return res(ctx.json(keys)); + }, + ), + ); + }); + + it('should defaults to ES256 when no algorithm is supplied', async () => { + const identityClient = IdentityClient.create({ + discovery, + issuer: mockBaseUrl, + }); + + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const response = await identityClient.authenticate(token); + + // expect that the authenticate is able to validate a token with ES256, which is the one set to FakeTokenFactory. + // This means that IdentityClient set ES256 by default. + expect(response).toEqual({ + token: token, + identity: { + type: 'user', + userEntityRef: 'foo', + ownershipEntityRefs: [], + }, + }); + }); + + it('should throw error on empty algorithm string', async () => { + const identityClient = IdentityClient.create({ + discovery, + issuer: mockBaseUrl, + algorithm: '', + }); + + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + return expect( + async () => await identityClient.authenticate(token), + ).rejects.toThrow(); + }); + }); + describe('authenticate', () => { beforeEach(() => { server.use( diff --git a/plugins/auth-node/src/IdentityClient.ts b/plugins/auth-node/src/IdentityClient.ts index 605c434431..d398ec4c9e 100644 --- a/plugins/auth-node/src/IdentityClient.ts +++ b/plugins/auth-node/src/IdentityClient.ts @@ -13,22 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { AuthenticationError } from '@backstage/errors'; import { createRemoteJWKSet, decodeJwt, - jwtVerify, + decodeProtectedHeader, FlattenedJWSInput, JWSHeaderParameters, - decodeProtectedHeader, + jwtVerify, } from 'jose'; import { GetKeyFunction } from 'jose/dist/types/types'; + import { BackstageIdentityResponse } from './types'; const CLOCK_MARGIN_S = 10; +export type IdentityClientOptions = { + discovery: PluginEndpointDiscovery; + issuer: string; + + /** JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256. + * Must match the algorithm defined in TokenFactory. + * More info on supported algorithms: https://github.com/panva/jose */ + algorithm?: string; +}; + /** * An identity client to interact with auth-backend and authenticate Backstage * tokens @@ -39,25 +49,21 @@ const CLOCK_MARGIN_S = 10; export class IdentityClient { private readonly discovery: PluginEndpointDiscovery; private readonly issuer: string; + private readonly algorithm: string; private keyStore?: GetKeyFunction; private keyStoreUpdated: number = 0; /** * Create a new {@link IdentityClient} instance. */ - static create(options: { - discovery: PluginEndpointDiscovery; - issuer: string; - }): IdentityClient { + static create(options: IdentityClientOptions): IdentityClient { return new IdentityClient(options); } - private constructor(options: { - discovery: PluginEndpointDiscovery; - issuer: string; - }) { + private constructor(options: IdentityClientOptions) { this.discovery = options.discovery; this.issuer = options.issuer; + this.algorithm = options.algorithm ?? 'ES256'; } /** @@ -82,7 +88,7 @@ export class IdentityClient { throw new AuthenticationError('No keystore exists'); } const decoded = await jwtVerify(token, this.keyStore, { - algorithms: ['ES256'], + algorithms: [this.algorithm], audience: 'backstage', issuer: this.issuer, }); From 9079a780789eb7fc94b79af9554f908d48659eca Mon Sep 17 00:00:00 2001 From: Manuel Scurti Date: Wed, 18 May 2022 11:31:29 +0200 Subject: [PATCH 2/3] algorithms field is now array for IdentityClient Signed-off-by: Manuel Scurti --- .changeset/polite-spiders-pay.md | 5 ++--- .changeset/tasty-snails-boil.md | 5 +++++ plugins/auth-backend/src/identity/TokenFactory.ts | 2 +- plugins/auth-node/src/IdentityClient.test.ts | 15 ++++++++++++++- plugins/auth-node/src/IdentityClient.ts | 11 +++++------ 5 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 .changeset/tasty-snails-boil.md diff --git a/.changeset/polite-spiders-pay.md b/.changeset/polite-spiders-pay.md index 882172b4d9..41528aff6b 100644 --- a/.changeset/polite-spiders-pay.md +++ b/.changeset/polite-spiders-pay.md @@ -1,6 +1,5 @@ --- -'@backstage/plugin-auth-backend': minor -'@backstage/plugin-auth-node': minor +'@backstage/plugin-auth-backend': patch --- -Added configurable algorithm field for IdentityClient and TokenFactory +Added configurable algorithm field for TokenFactory diff --git a/.changeset/tasty-snails-boil.md b/.changeset/tasty-snails-boil.md new file mode 100644 index 0000000000..d5b73d3b1b --- /dev/null +++ b/.changeset/tasty-snails-boil.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': patch +--- + +Added configurable algorithms array for IdentityClient diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index c8e5feb81e..fe9d2a1b4f 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -33,7 +33,7 @@ type Options = { /** Expiration time of signing keys in seconds */ keyDurationSeconds: number; /** JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256. - * Must match the algorithm defined in IdentityClient. + * Must match one of the algorithms defined for IdentityClient. * More info on supported algorithms: https://github.com/panva/jose */ algorithm?: string; }; diff --git a/plugins/auth-node/src/IdentityClient.test.ts b/plugins/auth-node/src/IdentityClient.test.ts index a773764e2d..5451bd3f9e 100644 --- a/plugins/auth-node/src/IdentityClient.test.ts +++ b/plugins/auth-node/src/IdentityClient.test.ts @@ -146,11 +146,24 @@ describe('IdentityClient', () => { }); }); + it('should throw error on empty algorithms array', async () => { + const identityClient = IdentityClient.create({ + discovery, + issuer: mockBaseUrl, + algorithms: [''], + }); + + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + return expect( + async () => await identityClient.authenticate(token), + ).rejects.toThrow(); + }); + it('should throw error on empty algorithm string', async () => { const identityClient = IdentityClient.create({ discovery, issuer: mockBaseUrl, - algorithm: '', + algorithms: [], }); const token = await factory.issueToken({ claims: { sub: 'foo' } }); diff --git a/plugins/auth-node/src/IdentityClient.ts b/plugins/auth-node/src/IdentityClient.ts index d398ec4c9e..2da6ae31f1 100644 --- a/plugins/auth-node/src/IdentityClient.ts +++ b/plugins/auth-node/src/IdentityClient.ts @@ -33,10 +33,9 @@ export type IdentityClientOptions = { discovery: PluginEndpointDiscovery; issuer: string; - /** JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256. - * Must match the algorithm defined in TokenFactory. + /** JWS "alg" (Algorithm) Header Parameter values. Defaults to an array containing just ES256. * More info on supported algorithms: https://github.com/panva/jose */ - algorithm?: string; + algorithms?: string[]; }; /** @@ -49,7 +48,7 @@ export type IdentityClientOptions = { export class IdentityClient { private readonly discovery: PluginEndpointDiscovery; private readonly issuer: string; - private readonly algorithm: string; + private readonly algorithms: string[]; private keyStore?: GetKeyFunction; private keyStoreUpdated: number = 0; @@ -63,7 +62,7 @@ export class IdentityClient { private constructor(options: IdentityClientOptions) { this.discovery = options.discovery; this.issuer = options.issuer; - this.algorithm = options.algorithm ?? 'ES256'; + this.algorithms = options.algorithms ?? ['ES256']; } /** @@ -88,7 +87,7 @@ export class IdentityClient { throw new AuthenticationError('No keystore exists'); } const decoded = await jwtVerify(token, this.keyStore, { - algorithms: [this.algorithm], + algorithms: this.algorithms, audience: 'backstage', issuer: this.issuer, }); From 46eb6556505244147807a185b67fa2ebd8bdbd2c Mon Sep 17 00:00:00 2001 From: Manuel Scurti Date: Wed, 18 May 2022 18:14:03 +0200 Subject: [PATCH 3/3] fixed api reports Signed-off-by: Manuel Scurti --- plugins/auth-node/api-report.md | 12 ++++++++---- plugins/auth-node/src/IdentityClient.ts | 6 ++++++ plugins/auth-node/src/index.ts | 1 + 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index f7fdd6369b..80c00b43fc 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -30,9 +30,13 @@ export function getBearerTokenFromAuthorizationHeader( // @public export class IdentityClient { authenticate(token: string | undefined): Promise; - static create(options: { - discovery: PluginEndpointDiscovery; - issuer: string; - }): IdentityClient; + static create(options: IdentityClientOptions): IdentityClient; } + +// @public +export type IdentityClientOptions = { + discovery: PluginEndpointDiscovery; + issuer: string; + algorithms?: string[]; +}; ``` diff --git a/plugins/auth-node/src/IdentityClient.ts b/plugins/auth-node/src/IdentityClient.ts index 2da6ae31f1..3799222447 100644 --- a/plugins/auth-node/src/IdentityClient.ts +++ b/plugins/auth-node/src/IdentityClient.ts @@ -29,6 +29,12 @@ import { BackstageIdentityResponse } from './types'; const CLOCK_MARGIN_S = 10; +/** + * An identity client options object which allows extra configurations + * + * @experimental This is not a stable API yet + * @public + */ export type IdentityClientOptions = { discovery: PluginEndpointDiscovery; issuer: string; diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index 5551f2c85d..cfb7275abd 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -22,6 +22,7 @@ export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader'; export { IdentityClient } from './IdentityClient'; +export type { IdentityClientOptions } from './IdentityClient'; export type { BackstageIdentityResponse, BackstageSignInResult,