feat: add support for service obo tokens
Signed-off-by: Camila Belo <camilaibs@gmail.com> Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com> Co-authored-by: Vincenzo Scamporlino <vinzscam@users.noreply.github.com>
This commit is contained in:
@@ -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,25 @@ 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 exp = onBehalfOf
|
||||
? Math.floor(onBehalfOf.expiresAt.getTime() / 1000)
|
||||
: iat + this.keyDurationSeconds;
|
||||
|
||||
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 +203,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+168
-5
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,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);
|
||||
}
|
||||
|
||||
@@ -194,14 +209,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,
|
||||
@@ -209,11 +225,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}'`,
|
||||
|
||||
Reference in New Issue
Block a user