Merge pull request #24024 from backstage/mob/create-obo-tokens

backend-app-api: add support for OBO tokens
This commit is contained in:
Patrik Oldsberg
2024-04-08 15:28:40 +02:00
committed by GitHub
5 changed files with 239 additions and 19 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Make the auth service create and validate dedicated OBO tokens, containing the user identity proof.
@@ -79,7 +79,9 @@ export class PluginTokenHandler {
readonly discovery: DiscoveryService,
) {}
async verifyToken(token: string): Promise<{ subject: string } | undefined> {
async verifyToken(
token: string,
): Promise<{ subject: string; limitedUserToken?: string } | undefined> {
try {
const { typ } = decodeProtectedHeader(token);
if (typ !== tokenTypes.plugin.typParam) {
@@ -102,7 +104,7 @@ export class PluginTokenHandler {
const jwksClient = await this.getJwksClient(pluginId);
await jwksClient.refreshKeyStore(token); // TODO(Rugvip): Refactor so that this isn't needed
const { payload } = await jwtVerify<{ sub: string }>(
const { payload } = await jwtVerify<{ sub: string; obo?: string }>(
token,
jwksClient.getKey,
{
@@ -114,21 +116,26 @@ export class PluginTokenHandler {
throw new AuthenticationError('Invalid plugin token', e);
});
return { subject: payload.sub };
return { subject: `plugin:${payload.sub}`, limitedUserToken: payload.obo };
}
async issueToken(options: {
pluginId: string;
targetPluginId: string;
onBehalfOf?: { token: string; expiresAt: Date };
}): Promise<{ token: string }> {
const { pluginId, targetPluginId, onBehalfOf } = options;
const key = await this.getKey();
const sub = options.pluginId;
const aud = options.targetPluginId;
const sub = pluginId;
const aud = targetPluginId;
const iat = Math.floor(Date.now() / 1000);
const exp = iat + this.keyDurationSeconds;
const ourExp = iat + this.keyDurationSeconds;
const exp = onBehalfOf
? Math.min(ourExp, Math.floor(onBehalfOf.expiresAt.getTime() / 1000))
: ourExp;
const claims = { sub, aud, iat, exp };
const claims = { sub, aud, iat, exp, obo: onBehalfOf?.token };
const token = await new SignJWT(claims)
.setProtectedHeader({
typ: tokenTypes.plugin.typParam,
@@ -197,7 +204,7 @@ export class PluginTokenHandler {
// Double check that the target plugin has a valid JWKS endpoint, otherwise avoid creating a remote key set
if (!(await this.isTargetPluginSupported(pluginId))) {
throw new AuthenticationError(
'Target plugin does not support self-signed tokens',
`Received a plugin token where the source '${pluginId}' plugin unexpectedly does not have a JWKS endpoint`,
);
}
@@ -151,4 +151,13 @@ export class UserTokenHandler {
return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1000) };
}
isLimitedUserToken(token: string): boolean {
try {
const { typ } = decodeProtectedHeader(token);
return typ === tokenTypes.limitedUser.typParam;
} catch {
return false;
}
}
}
@@ -22,6 +22,7 @@ import {
import {
InternalBackstageCredentials,
authServiceFactory,
toInternalBackstageCredentials,
} from './authServiceFactory';
import { base64url, decodeJwt } from 'jose';
import { discoveryServiceFactory } from '../discovery';
@@ -56,7 +57,14 @@ describe('authServiceFactory', () => {
jest.useRealTimers();
});
it('should authenticate issued tokens', async () => {
it('should authenticate issued tokens with legacy auth', async () => {
server.use(
rest.get(
'http://localhost:7007/api/catalog/.backstage/auth/v1/jwks.json',
(_req, res, ctx) => res(ctx.status(404)),
),
);
const tester = ServiceFactoryTester.from(authServiceFactory, {
dependencies: mockDeps,
});
@@ -87,7 +95,53 @@ describe('authServiceFactory', () => {
);
});
it('should forward user tokens', async () => {
it('should authenticate issued tokens with new auth', async () => {
const tester = ServiceFactoryTester.from(authServiceFactory, {
dependencies: mockDeps,
});
const searchAuth = await tester.get('search');
const catalogAuth = await tester.get('catalog');
server.use(
rest.get(
'http://localhost:7007/api/catalog/.backstage/auth/v1/jwks.json',
async (_req, res, ctx) =>
res(ctx.json(await catalogAuth.listPublicServiceKeys())),
),
rest.get(
'http://localhost:7007/api/search/.backstage/auth/v1/jwks.json',
async (_req, res, ctx) =>
res(ctx.json(await searchAuth.listPublicServiceKeys())),
),
);
const { token: searchToken } = await searchAuth.getPluginRequestToken({
onBehalfOf: await searchAuth.getOwnServiceCredentials(),
targetPluginId: 'catalog',
});
await expect(searchAuth.authenticate(searchToken)).rejects.toThrow(
'Invalid plugin token',
);
await expect(catalogAuth.authenticate(searchToken)).resolves.toEqual(
expect.objectContaining({
principal: {
type: 'service',
subject: 'plugin:search',
},
}),
);
});
it('should forward user token if target plugin does not support new auth service', async () => {
server.use(
rest.get(
'http://localhost:7007/api/permission/.backstage/auth/v1/jwks.json',
(_req, res, ctx) => res(ctx.status(404)),
),
);
const tester = ServiceFactoryTester.from(authServiceFactory, {
dependencies: mockDeps,
});
@@ -106,12 +160,19 @@ describe('authServiceFactory', () => {
userEntityRef: 'user:default/alice',
},
} as InternalBackstageCredentials<BackstageUserPrincipal>,
targetPluginId: 'catalog',
targetPluginId: 'permission',
}),
).resolves.toEqual({ token: 'alice-token' });
});
it('should not forward service tokens', async () => {
it('should issue a new service token with token manager if target plugin does not support new auth service', async () => {
server.use(
rest.get(
'http://localhost:7007/api/permission/.backstage/auth/v1/jwks.json',
(_req, res, ctx) => res(ctx.status(404)),
),
);
const tester = ServiceFactoryTester.from(authServiceFactory, {
dependencies: mockDeps,
});
@@ -129,7 +190,7 @@ describe('authServiceFactory', () => {
subject: 'external:upstream-service',
},
} as InternalBackstageCredentials<BackstageServicePrincipal>,
targetPluginId: 'catalog',
targetPluginId: 'permission',
});
expect(decodeJwt(token)).toEqual(
@@ -224,4 +285,106 @@ describe('authServiceFactory', () => {
new Date(expectedExpiresAt * 1000),
);
});
it('should issue service on-behalf-of user tokens', async () => {
const tester = ServiceFactoryTester.from(authServiceFactory, {
dependencies: mockDeps,
});
const searchAuth = await tester.get('search');
const catalogAuth = await tester.get('catalog');
const permissionAuth = await tester.get('permission');
server.use(
rest.get(
'http://localhost:7007/api/auth/.well-known/jwks.json',
(_req, res, ctx) =>
res(
ctx.json({
keys: [
{
kty: 'EC',
x: '78-Ei1H3nKM23ZpGMMzte2mVoYCcnfnSiLTm1P7vZM0',
y: 'Z9-PjG_EU598tLLUc2f8sCqxT7bjs8WpoV-lHm9GJHY',
crv: 'P-256',
kid: '8d01c3db-56f9-45f0-86dd-05b3c835b3d3',
alg: 'ES256',
},
],
}),
),
),
rest.get(
'http://localhost:7007/api/catalog/.backstage/auth/v1/jwks.json',
async (_req, res, ctx) =>
res(ctx.json(await catalogAuth.listPublicServiceKeys())),
),
rest.get(
'http://localhost:7007/api/search/.backstage/auth/v1/jwks.json',
async (_req, res, ctx) =>
res(ctx.json(await searchAuth.listPublicServiceKeys())),
),
rest.get(
'http://localhost:7007/api/permission/.backstage/auth/v1/jwks.json',
async (_req, res, ctx) =>
res(ctx.json(await permissionAuth.listPublicServiceKeys())),
),
rest.get(
'http://localhost:7007/api/kubernetes/.backstage/auth/v1/jwks.json',
(_req, res, ctx) => res(ctx.status(404)),
),
);
const expectedIssuedAt = 1712071714;
const expectedExpiresAt = 1712075314;
jest.useFakeTimers({
now: expectedIssuedAt * 1000 + 600_000,
});
const fullToken =
'eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJhbGciOiJFUzI1NiIsImtpZCI6IjhkMDFjM2RiLTU2ZjktNDVmMC04NmRkLTA1YjNjODM1YjNkMyJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNzE3MTQsImV4cCI6MTcxMjA3NTMxNCwidWlwIjoiMDFBUUJfSWpHTXRWc2gyWmgzZEg1NXhOX29pSVlhQ1F3ODJjeDZ5M1BQMXlpTjM4eGMzMVpMS2U0YVNDQlJTTy10cjFzZFUzT29ELUxJYV8tNV9RVUEifQ.mjIrZGqbZ2t68fS4U3crlGw-bYJZnMlhMHf-YL7q_u1HfaLr4NMTcHkxdnNS2wfJxCmUBxRfUS8b3nSAKsxcHA';
const credentials = await searchAuth.authenticate(fullToken);
if (!searchAuth.isPrincipal(credentials, 'user')) {
throw new Error('not a user principal');
}
const { token: limitedToken } = await searchAuth.getLimitedUserToken(
credentials,
);
const { token: oboToken } = await searchAuth.getPluginRequestToken({
onBehalfOf: credentials,
targetPluginId: 'catalog',
});
expect(oboToken).not.toBe(fullToken);
expect(decodeJwt(oboToken).obo).toBe(limitedToken);
expect(decodeJwt(oboToken).exp).toBe(expectedExpiresAt);
const oboCredentials = await catalogAuth.authenticate(oboToken);
if (!catalogAuth.isPrincipal(oboCredentials, 'user')) {
throw new Error('obo credential is not a user principal');
}
expect(oboCredentials.principal.userEntityRef).toBe(
'user:development/guest',
);
expect(toInternalBackstageCredentials(oboCredentials).token).toBe(
limitedToken,
);
const { token: oboToken2 } = await catalogAuth.getPluginRequestToken({
onBehalfOf: oboCredentials,
targetPluginId: 'permission',
});
expect(decodeJwt(oboToken2).obo).toBe(limitedToken);
await expect(
catalogAuth.getPluginRequestToken({
onBehalfOf: oboCredentials,
targetPluginId: 'kubernetes',
}),
).rejects.toThrow(
"Unable to call 'kubernetes' plugin on behalf of user, because the target plugin does not support on-behalf-of tokens",
);
});
});
@@ -119,6 +119,21 @@ class DefaultAuthService implements AuthService {
async authenticate(token: string): Promise<BackstageCredentials> {
const pluginResult = await this.pluginTokenHandler.verifyToken(token);
if (pluginResult) {
if (pluginResult.limitedUserToken) {
const userResult = await this.userTokenHandler.verifyToken(
pluginResult.limitedUserToken,
);
if (!userResult) {
throw new AuthenticationError(
'Invalid user token in plugin token obo claim',
);
}
return createCredentialsWithUserPrincipal(
userResult.userEntityRef,
pluginResult.limitedUserToken,
this.#getJwtExpiration(pluginResult.limitedUserToken),
);
}
return createCredentialsWithServicePrincipal(pluginResult.subject);
}
@@ -189,14 +204,15 @@ class DefaultAuthService implements AuthService {
return { token: '' };
}
const targetSupportsNewAuth =
await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId);
// check whether a plugin support the new auth system
// by checking the public keys endpoint existance.
switch (type) {
// TODO: Check whether the principal is ourselves
case 'service':
if (
await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId)
) {
if (targetSupportsNewAuth) {
return this.pluginTokenHandler.issueToken({
pluginId: this.pluginId,
targetPluginId,
@@ -204,11 +220,31 @@ class DefaultAuthService implements AuthService {
}
// If the target plugin does not support the new auth service, fall back to using old token format
return this.tokenManager.getToken();
case 'user':
if (!internalForward.token) {
case 'user': {
const { token } = internalForward;
if (!token) {
throw new Error('User credentials is unexpectedly missing token');
}
return { token: internalForward.token };
// If the target plugin supports the new auth service we issue a service
// on-behalf-of token rather than forwarding the user token
if (targetSupportsNewAuth) {
const onBehalfOf = await this.userTokenHandler.createLimitedUserToken(
token,
);
return this.pluginTokenHandler.issueToken({
pluginId: this.pluginId,
targetPluginId,
onBehalfOf,
});
}
if (this.userTokenHandler.isLimitedUserToken(token)) {
throw new AuthenticationError(
`Unable to call '${targetPluginId}' plugin on behalf of user, because the target plugin does not support on-behalf-of tokens`,
);
}
return { token };
}
default:
throw new AuthenticationError(
`Refused to issue service token for credential type '${type}'`,