backend-app-api: final service to service refactoring

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Vincenzo Scamporlino <vincenzos@spotify.com>
This commit is contained in:
Vincenzo Scamporlino
2024-04-04 13:41:52 +02:00
parent e1afe84d75
commit 130b215629
6 changed files with 53 additions and 49 deletions
@@ -22,19 +22,22 @@ import {
JWK,
importJWK,
SignJWT,
decodeProtectedHeader,
} from 'jose';
import { DateTime } from 'luxon';
import { v4 as uuid } from 'uuid';
import { InternalKey, KeyStore } from './types';
import { AuthenticationError } from '@backstage/errors';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { tokenTypes } from '@backstage/plugin-auth-node';
const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i;
type Options = {
ownPluginId: string;
publicKeyStore: KeyStore;
discovery: DiscoveryService;
logger: LoggerService;
/** Value of the issuer claim in issued tokens */
issuer: string;
/** Expiration time of signing keys in seconds */
keyDurationSeconds: number;
/** JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256.
@@ -54,6 +57,7 @@ export class PluginTokenHandler {
static create(options: Options) {
return new PluginTokenHandler(
options.logger,
options.ownPluginId,
options.publicKeyStore,
options.keyDurationSeconds,
options.algorithm ?? 'ES256',
@@ -63,36 +67,43 @@ export class PluginTokenHandler {
private constructor(
readonly logger: LoggerService,
readonly ownPluginId: string,
readonly publicKeyStore: KeyStore,
readonly keyDurationSeconds: number,
readonly algorithm: string,
readonly discovery: DiscoveryService,
) {}
async verifyToken(token: string): Promise<{ subject: string }> {
const claims = decodeJwt(token);
const pluginId = claims.sub;
async verifyToken(token: string): Promise<{ subject: string } | undefined> {
try {
const { typ } = decodeProtectedHeader(token);
if (typ !== tokenTypes.plugin.typParam) {
return undefined;
}
} catch {
return undefined;
}
const pluginId = String(decodeJwt(token).sub);
if (!pluginId) {
throw new AuthenticationError('Invalid subject');
throw new AuthenticationError('Invalid plugin token: missing subject');
}
if (!ALLOWED_PLUGIN_ID_PATTERN.test(pluginId)) {
throw new AuthenticationError(
'Invalid plugin token: forbidden subject format',
);
}
const JWKS = await this.getJWKS(pluginId);
try {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: 'backstage-plugin',
// TODO(vinzscam): add audience verification
});
const { payload } = await jwtVerify<{ sub: string }>(token, JWKS, {
typ: tokenTypes.plugin.typParam,
audience: this.ownPluginId,
requiredClaims: ['iat', 'exp', 'sub', 'aud'],
}).catch(e => {
throw new AuthenticationError('Invalid plugin token', e);
});
if (!payload.sub) {
throw new AuthenticationError('Missing subject');
}
console.log('payload', payload);
console.log('protected header', protectedHeader);
return { subject: payload.sub };
} catch (e) {
// TODO(vinzscam): here we need to handle errors properly
throw e;
}
return { subject: payload.sub };
}
async issueToken(options: {
@@ -101,18 +112,18 @@ export class PluginTokenHandler {
}): Promise<{ token: string }> {
const key = await this.getKey();
const iss = 'backstage-plugin';
const sub = options.pluginId;
const aud = options.targetPluginId;
const iat = Math.floor(Date.now() / 1000);
const exp = iat + this.keyDurationSeconds;
this.logger.info(`Issuing token for ${sub} with audentice ${aud}`);
const claims = { iss, sub, aud, iat, exp };
const claims = { sub, aud, iat, exp };
const token = await new SignJWT(claims)
.setProtectedHeader({ alg: this.algorithm, kid: key.kid })
.setIssuer(iss)
.setProtectedHeader({
typ: tokenTypes.plugin.typParam,
alg: this.algorithm,
kid: key.kid,
})
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
@@ -122,23 +122,9 @@ class DefaultAuthService implements AuthService {
// allowLimitedAccess is currently ignored, since we currently always use the full user tokens
async authenticate(token: string): Promise<BackstageCredentials> {
const { sub, aud, iss } = decodeJwt(token);
console.log(`DEBUG: iss=`, iss);
if (iss === 'backstage-plugin') {
console.log('DO THE STUFF!');
const { subject } = await this.pluginTokenHandler.verifyToken(token);
return createCredentialsWithServicePrincipal(subject);
}
// # identify new token
// 1. generate and store public keys in database
// 2. verification of token, by fetching all public keys
// Legacy service-to-service token
if (sub === 'backstage-server' && !aud) {
await this.tokenManager.authenticate(token);
return createCredentialsWithServicePrincipal('external:backstage-plugin');
const pluginResult = await this.pluginTokenHandler.verifyToken(token);
if (pluginResult) {
return createCredentialsWithServicePrincipal(pluginResult.subject);
}
const userResult = await this.userTokenHandler.verifyToken(token);
@@ -150,6 +136,13 @@ class DefaultAuthService implements AuthService {
);
}
// Legacy service-to-service token
const { sub, aud } = decodeJwt(token);
if (sub === 'backstage-server' && !aud) {
await this.tokenManager.authenticate(token);
return createCredentialsWithServicePrincipal('external:backstage-plugin');
}
throw new AuthenticationError('Unknown token');
}
@@ -277,8 +270,8 @@ export const authServiceFactory = createServiceFactory({
disableDefaultAuthPolicy,
publicKeyStore,
PluginTokenHandler.create({
ownPluginId: plugin.getId(),
keyDurationSeconds: 60 * 60,
issuer: `plugin:${plugin.getId()}`,
logger,
publicKeyStore,
discovery,
@@ -15,4 +15,3 @@
*/
export { authServiceFactory } from './authServiceFactory';
export { createAuthIntegrationRouter } from './createAuthIntegrationRouter';
@@ -24,6 +24,7 @@ export function createAuthIntegrationRouter(options: {
router.get('/.backstage/auth/v1/jwks.json', async (_req, res) => {
const { keys } = await options.auth.listPublicServiceKeys();
res.json({ keys });
});
@@ -23,7 +23,7 @@ import { Handler } from 'express';
import PromiseRouter from 'express-promise-router';
import { createLifecycleMiddleware } from './createLifecycleMiddleware';
import { createCredentialsBarrier } from './createCredentialsBarrier';
import { createAuthIntegrationRouter } from '../auth';
import { createAuthIntegrationRouter } from './createAuthIntegrationRouter';
/**
* @public