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:
committed by
Camila Belo
parent
4e105415e4
commit
4194ac7200
@@ -127,4 +127,8 @@ describe('authServiceFactory', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should issue limited user tokens', async () => {
|
||||
// TODO
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { IdentityApiGetIdentityRequest } from '@backstage/plugin-auth-node';
|
||||
import { decodeJwt } from 'jose';
|
||||
import { base64url, decodeJwt } from 'jose';
|
||||
|
||||
/** @internal */
|
||||
export type InternalBackstageCredentials<TPrincipal = unknown> =
|
||||
@@ -204,17 +204,41 @@ class DefaultAuthService implements AuthService {
|
||||
async getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const internalCredentials = toInternalBackstageCredentials(credentials);
|
||||
|
||||
const { token } = internalCredentials;
|
||||
|
||||
if (!token) {
|
||||
const { token: backstageToken } =
|
||||
toInternalBackstageCredentials(credentials);
|
||||
if (!backstageToken) {
|
||||
throw new AuthenticationError(
|
||||
'User credentials is unexpectedly missing token',
|
||||
);
|
||||
}
|
||||
|
||||
return { token, expiresAt: this.#getJwtExpiration(token) };
|
||||
const [headerRaw, payloadRaw] = backstageToken.split('.');
|
||||
const header = JSON.parse(
|
||||
new TextDecoder().decode(base64url.decode(headerRaw)),
|
||||
);
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64url.decode(payloadRaw)),
|
||||
);
|
||||
|
||||
const limitedUserToken = [
|
||||
base64url.encode(
|
||||
JSON.stringify({
|
||||
alg: header.alg,
|
||||
kid: header.kid,
|
||||
}),
|
||||
),
|
||||
base64url.encode(
|
||||
JSON.stringify({
|
||||
typ: 'vnd.backstage.limited-user',
|
||||
sub: payload.sub,
|
||||
iat: payload.iat,
|
||||
exp: payload.exp,
|
||||
}),
|
||||
),
|
||||
payload.uip,
|
||||
].join('.');
|
||||
|
||||
return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1000) };
|
||||
}
|
||||
|
||||
#getJwtExpiration(token: string) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,19 @@ export interface BackstageSignInResult {
|
||||
token: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface BackstageTokenPayload {
|
||||
[claim: string]: JsonValue;
|
||||
aud: typeof TokenTypes.user.audClaim;
|
||||
ent: string[];
|
||||
exp: number;
|
||||
iat: number;
|
||||
iss: string;
|
||||
sub: string;
|
||||
typ: typeof TokenTypes.user.typClaim;
|
||||
uip: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type BackstageUserIdentity = {
|
||||
type: 'user';
|
||||
@@ -116,6 +129,14 @@ export type BackstageUserIdentity = {
|
||||
ownershipEntityRefs: string[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface BackstageUserIdentityProofPayload {
|
||||
exp: number;
|
||||
iat: number;
|
||||
sub: string;
|
||||
typ: typeof TokenTypes.limitedUser.typClaim;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type ClientAuthResponse<TProviderInfo> = {
|
||||
providerInfo: TProviderInfo;
|
||||
@@ -639,6 +660,20 @@ export type TokenParams = {
|
||||
} & Record<string, JsonValue>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export const TokenTypes: Readonly<{
|
||||
user: Readonly<{
|
||||
typClaim: 'vnd.backstage.user';
|
||||
audClaim: 'backstage';
|
||||
}>;
|
||||
limitedUser: Readonly<{
|
||||
typClaim: 'vnd.backstage.limited-user';
|
||||
}>;
|
||||
service: Readonly<{
|
||||
typClaim: 'vnd.backstage.service';
|
||||
}>;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type WebMessageResponse =
|
||||
| {
|
||||
|
||||
@@ -29,13 +29,15 @@ export * from './proxy';
|
||||
export * from './sign-in';
|
||||
export type {
|
||||
AuthProviderConfig,
|
||||
AuthProviderRouteHandlers,
|
||||
AuthProviderFactory,
|
||||
AuthProviderRouteHandlers,
|
||||
AuthResolverCatalogUserQuery,
|
||||
AuthResolverContext,
|
||||
BackstageIdentityResponse,
|
||||
BackstageSignInResult,
|
||||
BackstageTokenPayload,
|
||||
BackstageUserIdentity,
|
||||
BackstageUserIdentityProofPayload,
|
||||
ClientAuthResponse,
|
||||
CookieConfigurer,
|
||||
ProfileInfo,
|
||||
@@ -44,3 +46,4 @@ export type {
|
||||
SignInResolver,
|
||||
TokenParams,
|
||||
} from './types';
|
||||
export { TokenTypes } from './types';
|
||||
|
||||
@@ -375,3 +375,100 @@ export type CookieConfigurer = (ctx: {
|
||||
secure: boolean;
|
||||
sameSite?: 'none' | 'lax' | 'strict';
|
||||
};
|
||||
|
||||
/**
|
||||
* Core properties of various token types.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const TokenTypes = Object.freeze({
|
||||
user: Object.freeze({
|
||||
typClaim: 'vnd.backstage.user',
|
||||
audClaim: 'backstage',
|
||||
}),
|
||||
limitedUser: Object.freeze({
|
||||
typClaim: 'vnd.backstage.limited-user',
|
||||
}),
|
||||
service: Object.freeze({
|
||||
typClaim: 'vnd.backstage.service',
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The payload contents of a valid Backstage JWT token
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface BackstageTokenPayload {
|
||||
/**
|
||||
* The token type
|
||||
*/
|
||||
typ: typeof TokenTypes.user.typClaim;
|
||||
|
||||
/**
|
||||
* The issuer of the token, currently the discovery URL of the auth backend
|
||||
*/
|
||||
iss: string;
|
||||
|
||||
/**
|
||||
* The entity ref of the user
|
||||
*/
|
||||
sub: string;
|
||||
|
||||
/**
|
||||
* The entity refs that the user claims ownership througg
|
||||
*/
|
||||
ent: string[];
|
||||
|
||||
/**
|
||||
* A hard coded audience string
|
||||
*/
|
||||
aud: typeof TokenTypes.user.audClaim;
|
||||
|
||||
/**
|
||||
* Standard expiry in epoch seconds
|
||||
*/
|
||||
exp: number;
|
||||
|
||||
/**
|
||||
* Standard issue time in epoch seconds
|
||||
*/
|
||||
iat: number;
|
||||
|
||||
/**
|
||||
* A separate user identity proof that the auth service can convert to a limited user token
|
||||
*/
|
||||
uip: string;
|
||||
|
||||
/**
|
||||
* Any other custom claims that the adopter may have added
|
||||
*/
|
||||
[claim: string]: JsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload contents of a valid Backstage user identity claim token
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface BackstageUserIdentityProofPayload {
|
||||
/**
|
||||
* The token type
|
||||
*/
|
||||
typ: typeof TokenTypes.limitedUser.typClaim;
|
||||
|
||||
/**
|
||||
* The entity ref of the user
|
||||
*/
|
||||
sub: string;
|
||||
|
||||
/**
|
||||
* Standard expiry in epoch seconds
|
||||
*/
|
||||
exp: number;
|
||||
|
||||
/**
|
||||
* Standard issue time in epoch seconds
|
||||
*/
|
||||
iat: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user