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, });