Upgrade TokenFactory to latest jose library

This commit is contained in:
Jonah Back
2021-01-13 20:18:07 -08:00
committed by Jonah Back
parent 2335fa9d17
commit 1f275fe31a
4 changed files with 61 additions and 34 deletions
+7 -1
View File
@@ -18,7 +18,13 @@
},
"jest": {
"moduleNameMapper": {
"jose/jwt/verify": "<rootDir>/../../../node_modules/jose/lib/jwt/verify"
"jose/jwt/sign": "<rootDir>/../node_modules/jose/dist/node/cjs/jwt/sign",
"jose/jwt/verify": "<rootDir>/../node_modules/jose/dist/node/cjs/jwt/verify",
"jose/jwk/from_key_like": "<rootDir>/../node_modules/jose/dist/node/cjs/jwk/from_key_like",
"jose/jwk/parse": "<rootDir>/../node_modules/jose/dist/node/cjs/jwk/parse",
"jose/util/base64url": "<rootDir>/../node_modules/jose/dist/node/cjs/util/base64url",
"jose/util/decode_protected_header": "<rootDir>/../node_modules/jose/dist/node/cjs/util/decode_protected_header",
"jose/util/generate_secret": "<rootDir>/../node_modules/jose/dist/node/cjs/util/generate_secret"
}
},
"keywords": [
@@ -13,12 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TextDecoder, TextEncoder } from 'util';
// These two statements are structured like this because Jest doesn't include these in the default
// test environment, even though they exist in Node.
global.TextEncoder = TextEncoder;
// @ts-ignore
global.TextDecoder = TextDecoder;
import { utc } from 'moment';
import { TokenFactory } from './TokenFactory';
import { getVoidLogger } from '@backstage/backend-common';
import { KeyStore, AnyJWK, StoredKey } from './types';
import { JWKS, JSONWebKey, JWT } from 'jose';
import { AnyJWK, KeyStore, StoredKey } from './types';
import jwtVerify from 'jose/jwt/verify';
import parseJwk from 'jose/jwk/parse';
import decodeProtectedHeader, {
ProtectedHeaderParameters,
} from 'jose/util/decode_protected_header';
const logger = getVoidLogger();
@@ -52,10 +63,8 @@ class MemoryKeyStore implements KeyStore {
}
function jwtKid(jwt: string): string {
const { header } = JWT.decode(jwt, { complete: true }) as {
header: { kid: string };
};
return header.kid;
const header = decodeProtectedHeader(jwt) as ProtectedHeaderParameters;
return header.kid as string;
}
describe('TokenFactory', () => {
@@ -72,14 +81,20 @@ describe('TokenFactory', () => {
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const { keys } = await factory.listPublicKeys();
const keyStore = JWKS.asKeyStore({
keys: keys.map(key => key as JSONWebKey),
const keyMap: {
[key: string]: AnyJWK;
} = {};
keys.forEach(key => {
keyMap[key.kid] = key;
});
const payload = JWT.verify(token, keyStore) as object & {
iat: number;
exp: number;
};
const payload = (
await jwtVerify(token, async header => {
const kid = header.kid as string;
return await parseJwk(keyMap[kid]);
})
).payload;
expect(payload).toEqual({
iss: 'my-issuer',
aud: 'backstage',
@@ -87,7 +102,7 @@ describe('TokenFactory', () => {
iat: expect.any(Number),
exp: expect.any(Number),
});
expect(payload.exp).toBe(payload.iat + keyDurationSeconds);
expect(payload.exp).toBe((payload.iat as number) + keyDurationSeconds);
});
it('should generate new signing keys when the current one expires', async () => {
@@ -16,7 +16,10 @@
import moment from 'moment';
import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types';
import { JSONWebKey, JWK, JWS } from 'jose';
import parseJwk, { JWK } from 'jose/jwk/parse';
import SignJWT from 'jose/jwt/sign';
import generateSecret from 'jose/util/generate_secret';
import fromKeyLike from 'jose/jwk/from_key_like';
import { Logger } from 'winston';
import { v4 as uuid } from 'uuid';
@@ -53,7 +56,7 @@ export class TokenFactory implements TokenIssuer {
private readonly keyDurationSeconds: number;
private keyExpiry?: moment.Moment;
private privateKeyPromise?: Promise<JSONWebKey>;
private privateKeyPromise?: Promise<JWK>;
constructor(options: Options) {
this.issuer = options.issuer;
@@ -64,7 +67,7 @@ export class TokenFactory implements TokenIssuer {
async issueToken(params: TokenParams): Promise<string> {
const key = await this.getKey();
const keyLike = await parseJwk(key);
const iss = this.issuer;
const sub = params.claims.sub;
const aud = 'backstage';
@@ -72,11 +75,9 @@ export class TokenFactory implements TokenIssuer {
const exp = iat + this.keyDurationSeconds;
this.logger.info(`Issuing token for ${sub}`);
return JWS.sign({ iss, sub, aud, iat, exp }, key, {
alg: key.alg,
kid: key.kid,
});
return new SignJWT({ iss, sub, aud, iat, exp })
.setProtectedHeader({ alg: key.alg, typ: 'JWT', kid: key.kid })
.sign(keyLike);
}
// This will be called by other services that want to verify ID tokens.
@@ -114,7 +115,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 (this.keyExpiry?.isAfter()) {
@@ -127,11 +128,12 @@ export class TokenFactory implements TokenIssuer {
this.keyExpiry = moment().add(this.keyDurationSeconds, 'seconds');
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 generateSecret('HS256');
const kid = uuid();
const jwk = await fromKeyLike(key);
jwk.kid = kid;
jwk.alg = 'HS256';
// 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
@@ -139,11 +141,15 @@ 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 ${jwk.kid}`);
await this.keyStore.addKey({
// @ts-ignore
use: 'sig',
...jwk,
});
// At this point we are allowed to start using the new key
return key as JSONWebKey;
return jwk;
})();
this.privateKeyPromise = promise;