Update jose to 4.6.0

Signed-off-by: Andy Caruso <macaruso@gmail.com>
This commit is contained in:
Andy Caruso
2022-03-14 09:56:34 -07:00
committed by blam
parent 5ca122317a
commit 1624b52168
17 changed files with 267 additions and 248 deletions
+4 -1
View File
@@ -60,7 +60,7 @@
"git-url-parse": "^11.6.0",
"helmet": "^5.0.2",
"isomorphic-git": "^1.8.0",
"jose": "^1.27.1",
"jose": "^4.6.0",
"keyv": "^4.0.3",
"keyv-memcache": "^1.2.5",
"@keyv/redis": "^2.2.3",
@@ -115,6 +115,9 @@
"recursive-readdir": "^2.2.2",
"supertest": "^6.1.3"
},
"jest": {
"testEnvironment": "node"
},
"files": [
"dist",
"config.d.ts",
@@ -17,7 +17,7 @@ import { getVoidLogger } from '../logging/voidLogger';
import { ConfigReader } from '@backstage/config';
import { ServerTokenManager } from './ServerTokenManager';
import { Logger } from 'winston';
import { JWK } from 'jose';
import * as jose from 'jose';
import { TokenManager } from './types';
const emptyConfig = new ConfigReader({});
@@ -134,7 +134,7 @@ describe('ServerTokenManager', () => {
it('should throw for server tokens created using a noop TokenManager', async () => {
const noopTokenManager = ServerTokenManager.noop();
const tokenManager = ServerTokenManager.fromConfig(
const tokenManager = await ServerTokenManager.fromConfig(
new ConfigReader({
backend: { auth: { keys: [{ secret: 'a1b2c3' }] } },
}),
@@ -204,7 +204,7 @@ describe('ServerTokenManager', () => {
});
describe('NODE_ENV === development', () => {
const generateSyncSpy = jest.spyOn(JWK, 'generateSync');
const generateSecretSpy = jest.spyOn(jose, 'generateSecret');
beforeEach(() => {
(process.env as any).NODE_ENV = 'development';
@@ -214,26 +214,30 @@ describe('ServerTokenManager', () => {
jest.clearAllMocks();
});
it('should generate a key if no config is provided', () => {
ServerTokenManager.fromConfig(emptyConfig, { logger });
expect(generateSyncSpy).toHaveBeenCalledWith('oct', 192);
it('should generate a key if no config is provided', async () => {
const tokenManager = ServerTokenManager.fromConfig(emptyConfig, {
logger,
});
const token = await tokenManager.getToken();
expect(token).toBeDefined();
expect(generateSecretSpy).toHaveBeenCalledWith('HS256');
});
it('should generate a key if no keys are provided in the configuration', () => {
ServerTokenManager.fromConfig(
it('should generate a key if no keys are provided in the configuration', async () => {
const tokenManager = ServerTokenManager.fromConfig(
new ConfigReader({
backend: { auth: { keys: [] } },
}),
{ logger },
);
expect(generateSyncSpy).toHaveBeenCalledWith('oct', 192);
const token = await tokenManager.getToken();
expect(token).toBeDefined();
expect(generateSecretSpy).toHaveBeenCalledWith('HS256');
});
it('should use provided secrets if config is provided', () => {
ServerTokenManager.fromConfig(configWithSecret, { logger });
expect(generateSyncSpy).not.toHaveBeenCalled();
expect(generateSecretSpy).not.toHaveBeenCalled();
});
});
});
@@ -14,7 +14,15 @@
* limitations under the License.
*/
import { JWKS, JWK, JWT } from 'jose';
import {
base64url,
JWSHeaderParameters,
generateSecret,
SignJWT,
jwtVerify,
exportJWK,
decodeProtectedHeader,
} from 'jose';
import { Config } from '@backstage/config';
import { AuthenticationError } from '@backstage/errors';
import { TokenManager } from './types';
@@ -37,8 +45,8 @@ class NoopTokenManager implements TokenManager {
* @public
*/
export class ServerTokenManager implements TokenManager {
private readonly verificationKeys: JWKS.KeyStore;
private readonly signingKey: JWK.Key;
private verificationKeys: Uint8Array[];
private signingKey: Uint8Array;
static noop(): TokenManager {
return new NoopTokenManager();
@@ -56,44 +64,64 @@ export class ServerTokenManager implements TokenManager {
'You must configure at least one key in backend.auth.keys for production.',
);
}
// For development, if a secret has not been configured, we auto generate a secret instead of throwing.
const generatedDevOnlyKey = JWK.generateSync('oct', 24 * 8);
if (generatedDevOnlyKey.k === undefined) {
throw new Error('Internal error, JWK key generation returned no data');
}
logger.warn(
'Generated a secret for backend-to-backend authentication: DEVELOPMENT USE ONLY.',
);
return new ServerTokenManager([generatedDevOnlyKey.k]);
return new ServerTokenManager([]);
}
private constructor(secrets: string[]) {
if (!secrets.length) {
if (!secrets.length && process.env.NODE_ENV !== 'development') {
throw new Error(
'No secrets provided when constructing ServerTokenManager',
);
}
this.verificationKeys = new Array<Uint8Array>();
secrets.map(k => this.verificationKeys.push(base64url.decode(k)));
this.signingKey = this.verificationKeys[0];
}
this.verificationKeys = new JWKS.KeyStore(
secrets.map(k => JWK.asKey({ kty: 'oct', k })),
);
this.signingKey = this.verificationKeys.all()[0];
// Called when no keys have been generated yet in the dev environment
private async generateKeys(): Promise<void> {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Key generation is not supported outside of the dev environment',
);
}
const secret = await generateSecret('HS256');
const jwk = await exportJWK(secret);
this.verificationKeys.push(base64url.decode(jwk.k ?? ''));
this.signingKey = this.verificationKeys[0];
}
async getToken(): Promise<{ token: string }> {
const jwt = JWT.sign({ sub: 'backstage-server' }, this.signingKey, {
algorithm: 'HS256',
});
if (!this.verificationKeys.length) {
await this.generateKeys();
}
const sub = 'backstage-server';
const jwt = await new SignJWT({ alg: 'HS256' })
.setProtectedHeader({ alg: 'HS256', sub: sub })
.setSubject('backstage-server')
.sign(this.signingKey);
return { token: jwt };
}
async authenticate(token: string): Promise<void> {
try {
JWT.verify(token, this.verificationKeys);
} catch (e) {
throw new AuthenticationError('Invalid server token');
let verifyError = undefined;
for (const key of this.verificationKeys) {
const lookup = (_protectedHeader: JWSHeaderParameters): Uint8Array => {
return key;
};
try {
await jwtVerify(token, lookup);
// If the verify succeeded, return
return;
} catch (e) {
// Catch the verify exception and continue
verifyError = e;
}
}
throw new AuthenticationError(`Invalid server token: ${verifyError}`);
}
}
+4 -1
View File
@@ -51,7 +51,7 @@
"express-session": "^1.17.1",
"fs-extra": "10.0.1",
"google-auth-library": "^7.6.1",
"jose": "^1.27.1",
"jose": "^4.6.0",
"jwt-decode": "^3.1.0",
"knex": "^1.0.2",
"lodash": "^4.17.21",
@@ -91,6 +91,9 @@
"msw": "^0.35.0",
"supertest": "^6.1.3"
},
"jest": {
"testEnvironment": "node"
},
"files": [
"dist",
"migrations",
@@ -17,16 +17,14 @@
import { MemoryKeyStore } from './MemoryKeyStore';
import { TokenFactory } from './TokenFactory';
import { getVoidLogger } from '@backstage/backend-common';
import { JWKS, JSONWebKey, JWT } from 'jose';
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from 'jose';
import { stringifyEntityRef } from '@backstage/catalog-model';
const logger = getVoidLogger();
function jwtKid(jwt: string): string {
const { header } = JWT.decode(jwt, { complete: true }) as {
header: { kid: string };
};
return header.kid;
const header = decodeProtectedHeader(jwt);
return header.kid ?? '';
}
const entityRef = stringifyEntityRef({
@@ -49,22 +47,19 @@ describe('TokenFactory', () => {
const token = await factory.issueToken({ claims: { sub: entityRef } });
const { keys } = await factory.listPublicKeys();
const keyStore = JWKS.asKeyStore({
keys: keys.map(key => key as JSONWebKey),
});
const keyStore = createLocalJWKSet({ keys: keys });
const payload = JWT.verify(token, keyStore) as object & {
iat: number;
exp: number;
};
expect(payload).toEqual({
const verifyResult = await jwtVerify(token, keyStore);
expect(verifyResult.payload).toEqual({
iss: 'my-issuer',
aud: 'backstage',
sub: entityRef,
iat: expect.any(Number),
exp: expect.any(Number),
});
expect(payload.exp).toBe(payload.iat + keyDurationSeconds);
expect(verifyResult.payload.exp).toBe(
verifyResult.payload.iat! + keyDurationSeconds,
);
});
it('should generate new signing keys when the current one expires', async () => {
@@ -15,7 +15,7 @@
*/
import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types';
import { JSONWebKey, JWK, JWS } from 'jose';
import { exportJWK, generateKeyPair, importJWK, JWK, SignJWT } from 'jose';
import { Logger } from 'winston';
import { v4 as uuid } from 'uuid';
import { DateTime } from 'luxon';
@@ -52,9 +52,10 @@ export class TokenFactory implements TokenIssuer {
private readonly logger: Logger;
private readonly keyStore: KeyStore;
private readonly keyDurationSeconds: number;
private readonly alg = 'ES256';
private keyExpiry?: Date;
private privateKeyPromise?: Promise<JSONWebKey>;
private privateKeyPromise?: Promise<JWK>;
constructor(options: Options) {
this.issuer = options.issuer;
@@ -84,10 +85,14 @@ export class TokenFactory implements TokenIssuer {
this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`);
return JWS.sign({ iss, sub, aud, iat, exp, ent }, key, {
alg: key.alg,
kid: key.kid,
});
return new SignJWT({ iss, sub, aud, iat, exp })
.setProtectedHeader({ alg: this.alg, kid: key.kid })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(await importJWK(key));
}
// This will be called by other services that want to verify ID tokens.
@@ -127,7 +132,7 @@ export class TokenFactory implements TokenIssuer {
return { keys: validKeys.map(({ key }) => key) };
}
private async getKey(): Promise<JSONWebKey> {
private async getKey(): Promise<JWK> {
// Make sure that we only generate one key at a time
if (this.privateKeyPromise) {
if (
@@ -147,11 +152,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 JWK.generate('EC', 'P-256', {
use: 'sig',
kid: uuid(),
alg: 'ES256',
});
const key = await generateKeyPair(this.alg);
const publicKey = await exportJWK(key.publicKey);
const privateKey = await exportJWK(key.privateKey);
publicKey.kid = privateKey.kid = uuid();
publicKey.alg = privateKey.alg = this.alg;
// 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
@@ -159,11 +164,11 @@ export class TokenFactory implements TokenIssuer {
// may want to keep using the existing key for some period of time until we switch to
// the new one. This also needs to be implemented cross-service though, meaning new services
// that boot up need to be able to grab an existing key to use for signing.
this.logger.info(`Created new signing key ${key.kid}`);
await this.keyStore.addKey(key.toJWK(false) as unknown as AnyJWK);
this.logger.info(`Created new signing key ${publicKey.kid}`);
await this.keyStore.addKey(publicKey as AnyJWK);
// At this point we are allowed to start using the new key
return key as JSONWebKey;
return privateKey;
})();
this.privateKeyPromise = promise;
@@ -15,7 +15,7 @@
*/
import express from 'express';
import { JWT } from 'jose';
import { jwtVerify } from 'jose';
import {
ALB_ACCESS_TOKEN_HEADER,
ALB_JWT_HEADER,
@@ -25,7 +25,7 @@ import { makeProfileInfo } from '../../lib/passport';
import { AuthResolverContext } from '../types';
import { AuthenticationError } from '@backstage/errors';
const jwtMock = JWT as jest.Mocked<any>;
const jwtMock = jwtVerify as jest.Mocked<any>;
const mockKey = async () => {
return `-----BEGIN PUBLIC KEY-----
@@ -116,7 +116,7 @@ describe('AwsAlbAuthProvider', () => {
},
});
jwtMock.verify.mockReturnValueOnce(mockClaims);
jwtMock.mockReturnValueOnce(Promise.resolve({ payload: mockClaims }));
await provider.refresh(mockRequest, mockResponse);
@@ -193,7 +193,7 @@ describe('AwsAlbAuthProvider', () => {
},
});
jwtMock.verify.mockImplementationOnce(() => {
jwtMock.mockImplementationOnce(() => {
throw new Error('bad JWT');
});
@@ -215,7 +215,7 @@ describe('AwsAlbAuthProvider', () => {
},
});
jwtMock.verify.mockReturnValueOnce({});
jwtMock.mockReturnValueOnce({});
await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow(
AuthenticationError,
@@ -235,7 +235,7 @@ describe('AwsAlbAuthProvider', () => {
},
});
jwtMock.verify.mockReturnValueOnce({
jwtMock.mockReturnValueOnce({
iss: 'INVALID_ISSUE_URL',
});
@@ -257,7 +257,7 @@ describe('AwsAlbAuthProvider', () => {
},
});
jwtMock.verify.mockReturnValueOnce(mockClaims);
jwtMock.mockReturnValueOnce(mockClaims);
await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow(
AuthenticationError,
@@ -277,7 +277,7 @@ describe('AwsAlbAuthProvider', () => {
},
});
jwtMock.verify.mockReturnValueOnce(mockClaims);
jwtMock.mockReturnValueOnce(mockClaims);
await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow(
AuthenticationError,
@@ -26,7 +26,7 @@ import fetch from 'node-fetch';
import * as crypto from 'crypto';
import { KeyObject } from 'crypto';
import NodeCache from 'node-cache';
import { JWT } from 'jose';
import { JWTHeaderParameters, jwtVerify } from 'jose';
import { Profile as PassportProfile } from 'passport';
import { makeProfileInfo } from '../../lib/passport';
import { AuthenticationError } from '@backstage/errors';
@@ -144,9 +144,8 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
}
try {
const headers = getJWTHeaders(jwt);
const key = await this.getKey(headers.kid);
const claims = JWT.verify(jwt, key) as AwsAlbClaims;
const verifyResult = await jwtVerify(jwt, this.getKey);
const claims = verifyResult.payload as AwsAlbClaims;
if (this.issuer && claims.iss !== this.issuer) {
throw new AuthenticationError('Issuer mismatch on JWT token');
@@ -195,18 +194,24 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
};
}
async getKey(keyId: string): Promise<KeyObject> {
const optionalCacheKey = this.keyCache.get<KeyObject>(keyId);
async getKey(header: JWTHeaderParameters): Promise<KeyObject> {
if (!header.kid) {
throw new AuthenticationError('No key id was specified in header');
}
const optionalCacheKey = this.keyCache.get<KeyObject>(header.kid);
if (optionalCacheKey) {
return crypto.createPublicKey(optionalCacheKey);
}
const keyText = await fetch(
const keyText: string = await fetch(
`https://public-keys.auth.elb.${encodeURIComponent(
this.region,
)}.amazonaws.com/${encodeURIComponent(keyId)}`,
)}.amazonaws.com/${encodeURIComponent(header.kid)}`,
).then(response => response.text());
const keyValue = crypto.createPublicKey(keyText);
this.keyCache.set(keyId, keyValue.export({ format: 'pem', type: 'spki' }));
this.keyCache.set(
header.kid,
keyValue.export({ format: 'pem', type: 'spki' }),
);
return keyValue;
}
}
@@ -15,15 +15,13 @@
*/
jest.mock('jose', () => ({
JWT: {
decode: jest.fn(),
},
decodeJwt: jest.fn(),
}));
jest.mock('@backstage/catalog-client');
import { AuthenticationError } from '@backstage/errors';
import express from 'express';
import { JWT } from 'jose';
import * as jose from 'jose';
import { Logger } from 'winston';
import { AuthHandler, AuthResolverContext, SignInResolver } from '../types';
import {
@@ -45,12 +43,14 @@ describe('Oauth2ProxyAuthProvider', () => {
let authHandler: jest.MockedFunction<AuthHandler<OAuth2ProxyResult<any>>>;
let mockResponse: jest.Mocked<express.Response>;
let mockRequest: jest.Mocked<express.Request>;
let JWTMock: jest.Mocked<typeof JWT>;
let mockJwtDecode: jest.MockedFunction<typeof jose.decodeJwt>;
beforeEach(() => {
jest.resetAllMocks();
JWTMock = JWT as jest.Mocked<typeof JWT>;
mockJwtDecode = jose.decodeJwt as jest.MockedFunction<
typeof jose.decodeJwt
>;
authHandler = jest.fn();
signInResolver = jest.fn();
logger = { error: jest.fn() } as unknown as jest.Mocked<Logger>;
@@ -120,7 +120,7 @@ describe('Oauth2ProxyAuthProvider', () => {
await provider.refresh(mockRequest, mockResponse);
expect(mockRequest.header).toBeCalledWith(OAUTH2_PROXY_JWT_HEADER);
expect(JWTMock.decode).toHaveBeenCalledWith('token');
expect(mockJwtDecode).toHaveBeenCalledWith('token');
expect(mockResponse.json).toHaveBeenCalled();
});
@@ -136,7 +136,7 @@ describe('Oauth2ProxyAuthProvider', () => {
token: mockToken,
});
authHandler.mockResolvedValue({ profile: profile });
JWTMock.decode.mockReturnValue(decodedToken as any);
mockJwtDecode.mockReturnValue(decodedToken as any);
await provider.refresh(mockRequest, mockResponse);
@@ -191,7 +191,7 @@ describe('Oauth2ProxyAuthProvider', () => {
await handler.refresh!(mockRequest, mockResponse);
expect(mockRequest.header).toBeCalledWith(OAUTH2_PROXY_JWT_HEADER);
expect(JWTMock.decode).toHaveBeenCalledWith('token');
expect(mockJwtDecode).toHaveBeenCalledWith('token');
expect(mockResponse.json).toHaveBeenCalled();
});
});
@@ -24,7 +24,7 @@ import {
AuthResponse,
AuthResolverContext,
} from '../types';
import { JWT } from 'jose';
import { decodeJwt } from 'jose';
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
@@ -142,7 +142,7 @@ export class Oauth2ProxyAuthProvider<JWTPayload>
);
}
const decodedJWT = JWT.decode(jwt) as unknown as JWTPayload;
const decodedJWT = decodeJwt(jwt) as unknown as JWTPayload;
return {
fullProfile: decodedJWT,
@@ -18,7 +18,7 @@ import { Config, ConfigReader } from '@backstage/config';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import express from 'express';
import { Session } from 'express-session';
import { JWK, JWT } from 'jose';
import { UnsecuredJWT } from 'jose';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ClientMetadata, IssuerMetadata } from 'openid-client';
@@ -90,13 +90,18 @@ describe('OidcAuthProvider', () => {
});
it('OidcAuthProvider#handler successfully invokes the oidc endpoints', async () => {
const jwt = {
sub: 'alice',
iss: 'https://oidc.test',
iat: Date.now(),
aud: clientMetadata.clientId,
exp: Date.now() + 10000,
};
const sub = 'alice';
const iss = 'https://oidc.test';
const iat = Date.now();
const aud = clientMetadata.clientId;
const exp = Date.now() + 10000;
const jwt = await new UnsecuredJWT({ iss, sub, aud, iat, exp })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.encode();
const requestSequence: Array<string> = [];
// The array of expected requests executed by the provider handler
@@ -114,7 +119,7 @@ describe('OidcAuthProvider', () => {
method: 'post',
url: 'https://oidc.test/as/token.oauth2',
payload: {
id_token: JWT.sign(jwt, JWK.None),
id_token: jwt,
access_token: 'test',
authorization_signed_response_alg: 'HS256',
},
+5 -1
View File
@@ -25,8 +25,9 @@
"dependencies": {
"@backstage/backend-common": "^0.13.2-next.1",
"@backstage/config": "^1.0.0",
"@backstage/catalog-model": "^0.13.0",
"@backstage/errors": "^1.0.0",
"jose": "^1.27.1",
"jose": "^4.6.0",
"node-fetch": "^2.6.7",
"winston": "^3.2.1"
},
@@ -35,6 +36,9 @@
"msw": "^0.35.0",
"uuid": "^8.0.0"
},
"jest": {
"testEnvironment": "node"
},
"files": [
"dist"
]
+27 -52
View File
@@ -15,11 +15,16 @@
*/
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { JSONWebKey, JWK, JWS, JWT } from 'jose';
import {
SignJWT,
generateKeyPair,
decodeProtectedHeader,
exportJWK,
} from 'jose';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { v4 as uuid } from 'uuid';
import { IdentityClient } from './IdentityClient';
import { v4 as uuid } from 'uuid';
interface AnyJWK extends Record<string, string> {
use: 'sig';
@@ -45,12 +50,11 @@ class FakeTokenFactory {
ent?: string[];
};
}): Promise<string> {
const key = await JWK.generate('EC', 'P-256', {
use: 'sig',
kid: uuid(),
alg: 'ES256',
});
this.keys.push(key.toJWK(false) as unknown as AnyJWK);
const pair = await generateKeyPair('ES256');
const publicKey = await exportJWK(pair.publicKey);
const kid = uuid();
publicKey.kid = kid;
this.keys.push(publicKey as AnyJWK);
const iss = this.options.issuer;
const sub = params.claims.sub;
@@ -59,10 +63,14 @@ class FakeTokenFactory {
const iat = Math.floor(Date.now() / 1000);
const exp = iat + this.options.keyDurationSeconds;
return JWS.sign({ iss, sub, aud, iat, exp, ent }, key, {
alg: key.alg,
kid: key.kid,
});
return new SignJWT({ iss, sub, aud, iat, exp, ent, kid })
.setProtectedHeader({ alg: 'ES256', ent: ent, kid: kid })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(pair.privateKey);
}
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
@@ -71,10 +79,8 @@ class FakeTokenFactory {
}
function jwtKid(jwt: string): string {
const { header } = JWT.decode(jwt, { complete: true }) as {
header: { kid: string };
};
return header.kid;
const header = decodeProtectedHeader(jwt);
return header.kid ?? '';
}
const server = setupServer();
@@ -98,7 +104,7 @@ describe('IdentityClient', () => {
afterEach(() => server.resetHandlers());
beforeEach(() => {
client = IdentityClient.create({ discovery, issuer: mockBaseUrl });
client = IdentityClient.create({ discovery, issuer: mockBaseUrl }, 0);
factory = new FakeTokenFactory({
issuer: mockBaseUrl,
keyDurationSeconds,
@@ -118,13 +124,6 @@ describe('IdentityClient', () => {
);
});
it('should use the correct endpoint', async () => {
await factory.issueToken({ claims: { sub: 'foo' } });
const keys = await factory.listPublicKeys();
const response = await (client as any).listPublicKeys();
expect(response).toEqual(keys);
});
it('should throw on undefined header', async () => {
return expect(async () => {
await client.authenticate(undefined);
@@ -186,7 +185,7 @@ describe('IdentityClient', () => {
it('should accept token from new key', async () => {
const fixedTime = Date.now();
jest
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2);
const token1 = await factory.issueToken({ claims: { sub: 'foo1' } });
@@ -197,7 +196,7 @@ describe('IdentityClient', () => {
// Ignore thrown error
}
// Move forward in time where the signing key has been rotated
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime);
spy.mockRestore();
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const response = await client.authenticate(token);
expect(response).toEqual({
@@ -232,33 +231,9 @@ describe('IdentityClient', () => {
});
describe('listPublicKeys', () => {
const defaultServiceResponse: {
keys: JSONWebKey[];
} = {
keys: [
{
crv: 'P-256',
x: 'JWy80Goa-8C3oaeDLnk0ANVPPMfI9T3u_T5T7W2b_ls',
y: 'Ge6jAhCDW1PFBfme2RA5ZsXN0cESiCwW29LMRPX5wkw',
kty: 'EC',
kid: 'kid-a',
alg: 'ES256',
use: 'sig',
},
],
};
beforeEach(() => {
server.use(
rest.get(`${mockBaseUrl}/.well-known/jwks.json`, (_, res, ctx) => {
return res(ctx.json(defaultServiceResponse));
}),
);
});
it('should use the correct endpoint', async () => {
const response = await (client as any).listPublicKeys();
expect(response).toEqual(defaultServiceResponse);
const url = (client as any).endpoint as URL;
expect(url.toString()).toMatch(`${mockBaseUrl}/.well-known/jwks.json`);
});
});
});
+41 -85
View File
@@ -16,12 +16,15 @@
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { AuthenticationError } from '@backstage/errors';
import { JSONWebKey, JWK, JWKS, JWT } from 'jose';
import fetch from 'node-fetch';
import {
createRemoteJWKSet,
jwtVerify,
FlattenedJWSInput,
JWSHeaderParameters,
} from 'jose';
import { GetKeyFunction } from 'jose/dist/types/types';
import { BackstageIdentityResponse } from './types';
const CLOCK_MARGIN_S = 10;
/**
* An identity client to interact with auth-backend and authenticate Backstage
* tokens
@@ -32,27 +35,37 @@ const CLOCK_MARGIN_S = 10;
export class IdentityClient {
private readonly discovery: PluginEndpointDiscovery;
private readonly issuer: string;
private keyStore: JWKS.KeyStore;
private keyStoreUpdated: number;
private keyStore?: GetKeyFunction<JWSHeaderParameters, FlattenedJWSInput>;
private endpoint?: URL;
/**
* Create a new {@link IdentityClient} instance.
*/
static create(options: {
discovery: PluginEndpointDiscovery;
issuer: string;
}): IdentityClient {
return new IdentityClient(options);
static create(
options: {
discovery: PluginEndpointDiscovery;
issuer: string;
},
cooldownMs = 30000,
): IdentityClient {
return new IdentityClient(options, cooldownMs);
}
private constructor(options: {
discovery: PluginEndpointDiscovery;
issuer: string;
}) {
private constructor(
options: {
discovery: PluginEndpointDiscovery;
issuer: string;
},
cooldownMs: number,
) {
this.discovery = options.discovery;
this.issuer = options.issuer;
this.keyStore = new JWKS.KeyStore();
this.keyStoreUpdated = 0;
this.discovery.getBaseUrl('auth').then(url => {
this.endpoint = new URL(`${url}/.well-known/jwks.json`);
this.keyStore = createRemoteJWKSet(this.endpoint, {
cooldownDuration: cooldownMs,
});
});
}
/**
@@ -67,91 +80,34 @@ export class IdentityClient {
if (!token) {
throw new AuthenticationError('No token specified');
}
// Get signing key matching token
const key = await this.getKey(token);
if (!key) {
throw new AuthenticationError('No signing key matching token found');
}
// Verify token claims and signature
// Note: Claims must match those set by TokenFactory when issuing tokens
// Note: verify throws if verification fails
const decoded = JWT.IdToken.verify(token, key, {
if (!this.keyStore) {
throw new AuthenticationError('No keystore exists');
}
const decoded = await jwtVerify(token, this.keyStore, {
algorithms: ['ES256'],
audience: 'backstage',
issuer: this.issuer,
}) as { sub: string; ent: string[] };
});
// Verified, return the matching user as BackstageIdentity
// TODO: Settle internal user format/properties
if (!decoded.sub) {
if (!decoded.payload.sub) {
throw new AuthenticationError('No user sub found in token');
}
const user: BackstageIdentityResponse = {
id: decoded.payload.sub,
token,
identity: {
type: 'user',
userEntityRef: decoded.sub,
ownershipEntityRefs: decoded.ent ?? [],
userEntityRef: decoded.payload.sub,
ownershipEntityRefs: decoded.payload.ent
? [decoded.payload.ent as string]
: [],
},
};
return user;
}
/**
* Returns the public signing key matching the given jwt token,
* or null if no matching key was found
*/
private async getKey(rawJwtToken: string): Promise<JWK.Key | null> {
const { header, payload } = JWT.decode(rawJwtToken, {
complete: true,
}) as {
header: { kid: string };
payload: { iat: number };
};
// Refresh public keys if needed
// Add a small margin in case clocks are out of sync
const keyStoreHasKey = !!this.keyStore.get({ kid: header.kid });
const issuedAfterLastRefresh =
payload?.iat && payload.iat > this.keyStoreUpdated - CLOCK_MARGIN_S;
if (!keyStoreHasKey && issuedAfterLastRefresh) {
await this.refreshKeyStore();
}
return this.keyStore.get({ kid: header.kid });
}
/**
* Lists public part of keys used to sign Backstage Identity tokens
*/
private async listPublicKeys(): Promise<{
keys: JSONWebKey[];
}> {
const url = `${await this.discovery.getBaseUrl(
'auth',
)}/.well-known/jwks.json`;
const response = await fetch(url);
if (!response.ok) {
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
const publicKeys: { keys: JSONWebKey[] } = await response.json();
return publicKeys;
}
/**
* Fetches public keys and caches them locally
*/
private async refreshKeyStore(): Promise<void> {
const now = Date.now() / 1000;
const publicKeys = await this.listPublicKeys();
this.keyStore = JWKS.asKeyStore({
keys: publicKeys.keys.map(key => key as JSONWebKey),
});
this.keyStoreUpdated = now;
}
}
+3
View File
@@ -49,6 +49,9 @@
"msw": "^0.35.0",
"supertest": "^6.1.3"
},
"jest": {
"testEnvironment": "node"
},
"files": [
"dist"
]
@@ -43,7 +43,7 @@ export async function startStandaloneServer(
const searchEngine = new LunrSearchEngine({ logger });
const indexBuilder = new IndexBuilder({ logger, searchEngine });
const discovery = SingleHostDiscovery.fromConfig(config);
const tokenManager = ServerTokenManager.fromConfig(config, {
const tokenManager = await ServerTokenManager.fromConfig(config, {
logger,
});
const permissions = ServerPermissionClient.fromConfig(config, {
+40 -7
View File
@@ -1465,6 +1465,19 @@
"@backstage/errors" "^1.0.0"
cross-fetch "^3.1.5"
"@backstage/catalog-model@^0.13.0":
version "0.13.0"
resolved "https://registry.npmjs.org/@backstage/catalog-model/-/catalog-model-0.13.0.tgz#abeb91522ac7ef7907907ad5bc889803131db209"
integrity sha512-Q+0C0LJ8niNQ4TXduUNTycZ2PLMbhKmfgWywunJLO34DZAoFnTWJEAdEYF536YyLciHhLO0dHRUaHY29Sdr+Xg==
dependencies:
"@backstage/config" "^0.1.15"
"@backstage/errors" "^0.2.2"
"@backstage/types" "^0.1.3"
ajv "^7.0.3"
json-schema "^0.4.0"
lodash "^4.17.21"
uuid "^8.0.0"
"@backstage/catalog-model@^1.0.0":
version "1.0.0"
resolved "https://registry.npmjs.org/@backstage/catalog-model/-/catalog-model-1.0.0.tgz#0aa8694a3182aaf4232725842da751bf5f78bd68"
@@ -1478,6 +1491,14 @@
lodash "^4.17.21"
uuid "^8.0.0"
"@backstage/config@^0.1.15":
version "0.1.15"
resolved "https://registry.npmjs.org/@backstage/config/-/config-0.1.15.tgz#4bad122ad861be5bd61a60639f92d2494fa245c5"
integrity sha512-eNJEYYSEu9MkrkBYiMpUBWEc3Bu64YgB9pZZGCMW7/9350tV2wbylEdoBJHslilJlJhiUyTXBckn8Ua7DOH7rw==
dependencies:
"@backstage/types" "^0.1.3"
lodash "^4.17.21"
"@backstage/core-components@^0.9.0", "@backstage/core-components@^0.9.2":
version "0.9.2"
resolved "https://registry.npmjs.org/@backstage/core-components/-/core-components-0.9.2.tgz#9a3d79a15039256bbc007e5daa08c983050e0238"
@@ -1535,6 +1556,15 @@
react-router-dom "6.0.0-beta.0"
zen-observable "^0.8.15"
"@backstage/errors@^0.2.2":
version "0.2.2"
resolved "https://registry.npmjs.org/@backstage/errors/-/errors-0.2.2.tgz#2113e0bc859e645b8b59bfcb435f7535739b02f8"
integrity sha512-s6Ru3NL4oFiHwNsMY1uiwqNKuOu9KQWkpse3adWZsDu1fCQKOw2omQ2qBHZ4Uj1xJ6x9Ye0yNTJ4vfHfdwiaEg==
dependencies:
"@backstage/types" "^0.1.3"
cross-fetch "^3.1.5"
serialize-error "^8.0.1"
"@backstage/integration-react@^1.0.0":
version "1.0.0"
resolved "https://registry.npmjs.org/@backstage/integration-react/-/integration-react-1.0.0.tgz#8075e65c6b5387631d27a9c242e7a4fff6f92417"
@@ -1615,6 +1645,11 @@
react-use "^17.2.4"
swr "^1.1.2"
"@backstage/types@^0.1.3":
version "0.1.3"
resolved "https://registry.npmjs.org/@backstage/types/-/types-0.1.3.tgz#6613d8cbdf97d42d31cd1e66a833df533e7ccf14"
integrity sha512-fJVi4oVrlO+G3PRv1fYSll9/X4pE11HLnkI//Geare9sP6wSfp/2zXpLYfKVsG0e24jOl7Swkc8lwLkQ90zMaQ==
"@balena/dockerignore@^1.0.2":
version "1.0.2"
resolved "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d"
@@ -15721,13 +15756,6 @@ joi@^17.4.0:
"@sideway/formula" "^3.0.0"
"@sideway/pinpoint" "^2.0.0"
jose@^1.27.1:
version "1.28.1"
resolved "https://registry.npmjs.org/jose/-/jose-1.28.1.tgz#34a0f851a534be59ffab82a6e8845f6874e8c128"
integrity sha512-6JK28rFu5ENp/yxMwM+iN7YeaInnY9B9Bggjkz5fuwLiJhbVrl2O4SJr65bdNBPl9y27fdC3Mymh+FVCvozLIg==
dependencies:
"@panva/asn1.js" "^1.0.0"
jose@^2.0.5:
version "2.0.5"
resolved "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz#29746a18d9fff7dcf9d5d2a6f62cb0c7cd27abd3"
@@ -15740,6 +15768,11 @@ jose@^4.1.4:
resolved "https://registry.npmjs.org/jose/-/jose-4.5.0.tgz#92829d8cf846351eb55aaaf94f252fb1d191f2d5"
integrity sha512-GFcVFQwYQKbQTUOo2JlpFGXTkgBw26uzDsRMD2q1WgSKNSnpKS9Ug7bdQ8dS+p4sZHNH6iRPu6WK2jLIjspaMA==
jose@^4.6.0:
version "4.6.1"
resolved "https://registry.npmjs.org/jose/-/jose-4.6.1.tgz#241472ff928c79b2e2e2fe4c1056b3085384ec42"
integrity sha512-EFnufEivlIB6j7+JwaenYQzdUDs/McajDr9WnhT6EI0WxbexnfuZimpWX1GnobF6OnQsUFmWFXUXdWyZHWdQow==
joycon@^3.0.1:
version "3.1.0"
resolved "https://registry.npmjs.org/joycon/-/joycon-3.1.0.tgz#33bb2b6b5a6849a1e251bed623bdf610f477d49f"