auth: issue user identity claims and create limited user tokens from them

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2024-03-27 23:57:33 +01:00
committed by Camila Belo
parent 4e105415e4
commit 4194ac7200
7 changed files with 306 additions and 26 deletions
@@ -13,12 +13,22 @@
* 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 {
base64url,
createLocalJWKSet,
decodeProtectedHeader,
jwtVerify,
} from 'jose';
import { MemoryKeyStore } from './MemoryKeyStore';
import { TokenFactory } from './TokenFactory';
import {
BackstageUserIdentityProofPayload,
BackstageTokenPayload,
TokenTypes,
} from '@backstage/plugin-auth-node';
const logger = getVoidLogger();
@@ -59,19 +69,58 @@ describe('TokenFactory', () => {
const { keys } = await factory.listPublicKeys();
const keyStore = createLocalJWKSet({ keys: keys });
const verifyResult = await jwtVerify(token, keyStore);
const verifyResult = await jwtVerify<BackstageTokenPayload>(
token,
keyStore,
);
expect(verifyResult.payload).toEqual({
typ: TokenTypes.user.typClaim,
iss: 'my-issuer',
aud: 'backstage',
aud: TokenTypes.user.audClaim,
sub: entityRef,
ent: [entityRef],
'x-fancy-claim': 'my special claim',
iat: expect.any(Number),
exp: expect.any(Number),
uip: expect.any(String),
});
expect(verifyResult.payload.exp).toBe(
verifyResult.payload.iat! + keyDurationSeconds,
);
// Emulate the reconstruction of a limited user token
const limitedUserToken = [
base64url.encode(
JSON.stringify({
alg: verifyResult.protectedHeader.alg,
kid: verifyResult.protectedHeader.kid!,
}),
),
base64url.encode(
JSON.stringify({
typ: TokenTypes.limitedUser.typClaim,
sub: verifyResult.payload.sub,
iat: verifyResult.payload.iat,
exp: verifyResult.payload.exp,
}),
),
verifyResult.payload.uip,
].join('.');
const verifyProofResult =
await jwtVerify<BackstageUserIdentityProofPayload>(
limitedUserToken,
keyStore,
);
expect(verifyProofResult.payload).toEqual({
typ: TokenTypes.limitedUser.typClaim,
sub: entityRef,
iat: expect.any(Number),
exp: expect.any(Number),
});
expect(verifyProofResult.payload.exp).toBe(
verifyProofResult.payload.iat! + keyDurationSeconds,
);
});
it('should generate new signing keys when the current one expires', async () => {
@@ -16,11 +16,24 @@
import { parseEntityRef } from '@backstage/catalog-model';
import { AuthenticationError } from '@backstage/errors';
import { exportJWK, generateKeyPair, importJWK, JWK, SignJWT } from 'jose';
import {
exportJWK,
generateKeyPair,
importJWK,
JWK,
SignJWT,
GeneralSign,
KeyLike,
} from 'jose';
import { DateTime } from 'luxon';
import { v4 as uuid } from 'uuid';
import { LoggerService } from '@backstage/backend-plugin-api';
import { TokenParams } from '@backstage/plugin-auth-node';
import {
BackstageTokenPayload,
BackstageUserIdentityProofPayload,
TokenParams,
TokenTypes,
} from '@backstage/plugin-auth-node';
import { AnyJWK, KeyStore, TokenIssuer } from './types';
const MS_IN_S = 1000;
@@ -79,13 +92,13 @@ export class TokenFactory implements TokenIssuer {
const key = await this.getKey();
const iss = this.issuer;
const { sub, ent, ...additionalClaims } = params.claims;
const aud = 'backstage';
const { sub, ent = [sub], ...additionalClaims } = params.claims;
const aud = TokenTypes.user.audClaim;
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + this.keyDurationSeconds;
// Validate that the subject claim is a valid EntityRef
try {
// The subject must be a valid entity ref
parseEntityRef(sub);
} catch (error) {
throw new Error(
@@ -93,21 +106,35 @@ export class TokenFactory implements TokenIssuer {
);
}
this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`);
if (!key.alg) {
throw new AuthenticationError('No algorithm was provided in the key');
}
const claims = { ...additionalClaims, iss, sub, ent, aud, iat, exp };
this.logger.info(`Issuing token for ${sub}, with entities ${ent}`);
const signingKey = await importJWK(key);
const uip = await this.createUserIdentityClaim({
header: { alg: key.alg, kid: key.kid },
payload: { typ: TokenTypes.limitedUser.typClaim, sub, iat, exp },
key: signingKey,
});
const claims: BackstageTokenPayload = {
...additionalClaims,
typ: TokenTypes.user.typClaim,
iss,
sub,
ent,
aud,
iat,
exp,
uip,
};
const token = await new SignJWT(claims)
.setProtectedHeader({ alg: key.alg, kid: key.kid })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(await importJWK(key));
.sign(signingKey);
if (token.length > MAX_TOKEN_LENGTH) {
throw new Error(
@@ -210,4 +237,45 @@ export class TokenFactory implements TokenIssuer {
return promise;
}
// Creates a string claim that can be used as part of reconstructing a limited
// user token. The output of this function is only the signature part of a
// JWS.
private async createUserIdentityClaim(options: {
header: {
alg: string;
kid?: string;
};
payload: BackstageUserIdentityProofPayload;
key: KeyLike | Uint8Array;
}): Promise<string> {
// NOTE: We reconstruct the header and payload structures carefully to
// perfectly guarantee ordering. The reason for this is that we store only
// the signature part of these to reduce duplication within the Backstage
// token. Anyone who wants to make an actual JWT based on all this must be
// able to do the EXACT reconstruction of the header and payload parts, to
// then append the signature.
const header = {
alg: options.header.alg,
...(options.header.kid ? { kid: options.header.kid } : {}),
};
const payload = {
typ: options.payload.typ,
sub: options.payload.sub,
iat: options.payload.iat,
exp: options.payload.exp,
};
const jws = await new GeneralSign(
new TextEncoder().encode(JSON.stringify(payload)),
)
.addSignature(options.key)
.setProtectedHeader(header)
.done()
.sign();
return jws.signatures[0].signature;
}
}