fix for new config shape too

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2024-05-23 14:32:45 +02:00
parent c2ea75f473
commit a9791bcec0
3 changed files with 258 additions and 46 deletions
@@ -17,8 +17,72 @@
import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api';
import { ExternalTokenHandler } from './ExternalTokenHandler';
import { TokenHandler } from './types';
import {
mockServices,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import { randomBytes } from 'crypto';
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
import { DateTime } from 'luxon';
import { v4 as uuid } from 'uuid';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
// Simplified copy of TokenFactory in @backstage/plugin-auth-backend
interface AnyJWK extends Record<string, string> {
use: 'sig';
alg: string;
kid: string;
kty: string;
}
class FakeTokenFactory {
private readonly keys = new Array<AnyJWK>();
constructor(
private readonly options: {
issuer: string;
keyDurationSeconds: number;
},
) {}
async issueToken(params: {
claims: {
sub: string;
ent?: string[];
};
}): Promise<string> {
const pair = await generateKeyPair('RS256');
const publicKey = await exportJWK(pair.publicKey);
const kid = uuid();
publicKey.kid = kid;
this.keys.push(publicKey as AnyJWK);
const iss = this.options.issuer;
const sub = params.claims.sub;
const ent = params.claims.ent;
const aud = 'backstage';
const iat = Math.floor(Date.now() / 1000);
const exp = iat + this.options.keyDurationSeconds;
return new SignJWT({ iss, sub, aud, iat, exp, ent, kid })
.setProtectedHeader({ alg: 'RS256', ent: ent, kid: kid })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(pair.privateKey);
}
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
return { keys: this.keys };
}
}
describe('ExternalTokenHandler', () => {
const server = setupServer();
setupRequestMockHandlers(server);
it('skips over inner handlers that do not match, and applies plugin restrictions', async () => {
const handler1: TokenHandler = {
add: jest.fn(),
@@ -52,4 +116,96 @@ describe('ExternalTokenHandler', () => {
`"This token's access is restricted to plugin(s) 'plugin1'"`,
);
});
it('successfully parses known methods', async () => {
const legacyKey = randomBytes(24);
const factory = new FakeTokenFactory({
issuer: 'blah',
keyDurationSeconds: 100,
});
server.use(
rest.get(
'https://example.com/.well-known/jwks.json',
async (_, res, ctx) => {
const keys = await factory.listPublicKeys();
return res(ctx.json(keys));
},
),
);
const handler = ExternalTokenHandler.create({
ownPluginId: 'catalog',
logger: mockServices.logger.mock(),
config: mockServices.rootConfig({
data: {
backend: {
auth: {
externalAccess: [
{
type: 'legacy',
options: {
secret: legacyKey.toString('base64'),
subject: 'legacy-subject',
},
accessRestrictions: [
{ plugin: 'catalog', permission: 'catalog.entity.read' },
],
},
{
type: 'static',
options: {
token: 'defdefdef',
subject: 'static-subject',
},
accessRestrictions: [
{ plugin: 'catalog', permission: 'catalog.entity.read' },
],
},
{
type: 'jwks',
options: {
url: 'https://example.com/.well-known/jwks.json',
algorithm: 'RS256',
issuer: 'blah',
audience: 'backstage',
subjectPrefix: 'custom-prefix',
},
accessRestrictions: [
{ plugin: 'catalog', permission: 'catalog.entity.read' },
],
},
],
},
},
},
}),
});
const legacyToken = await new SignJWT({
sub: 'backstage-server',
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
})
.setProtectedHeader({ alg: 'HS256' })
.sign(legacyKey);
await expect(handler.verifyToken(legacyToken)).resolves.toEqual({
subject: 'legacy-subject',
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
});
await expect(handler.verifyToken('defdefdef')).resolves.toEqual({
subject: 'static-subject',
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
});
const jwksToken = await factory.issueToken({
claims: { sub: 'jwks-subject' },
});
await expect(handler.verifyToken(jwksToken)).resolves.toEqual({
subject: 'external:custom-prefix:jwks-subject',
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
});
});
});
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
@@ -21,13 +22,13 @@ import { setupServer } from 'msw/node';
import { v4 as uuid } from 'uuid';
import { JWKSHandler } from './jwks';
// Simplified copy of TokenFactory in @backstage/plugin-auth-backend
interface AnyJWK extends Record<string, string> {
use: 'sig';
alg: string;
kid: string;
kty: string;
}
// Simplified copy of TokenFactory in @backstage/plugin-auth-backend
class FakeTokenFactory {
private readonly keys = new Array<AnyJWK>();
@@ -100,10 +101,12 @@ describe('JWKSHandler', () => {
it('verifies token with valid entry', async () => {
const validEntry = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: 'RS256',
issuer: mockBaseUrl,
audience: 'backstage',
options: {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: 'RS256',
issuer: mockBaseUrl,
audience: 'backstage',
},
};
const jwksHandler = new JWKSHandler();
@@ -120,17 +123,21 @@ describe('JWKSHandler', () => {
it('skips invalid entry and continues verification', async () => {
const invalidEntry = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: 'RS256',
issuer: ['fakeIssuer'],
audience: ['fakeAud'],
options: {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: 'RS256',
issuer: ['fakeIssuer'],
audience: ['fakeAud'],
},
};
const validEntry = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: 'RS256',
issuer: ['multiple-issuers', mockBaseUrl],
audience: ['multiple-audiences', 'backstage'],
options: {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: 'RS256',
issuer: ['multiple-issuers', mockBaseUrl],
audience: ['multiple-audiences', 'backstage'],
},
};
const jwksHandler = new JWKSHandler();
@@ -148,15 +155,19 @@ describe('JWKSHandler', () => {
it('returns undefined if no valid entry found', async () => {
const invalidEntry1 = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: 'RS256',
issuer: 'wrong',
options: {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: 'RS256',
issuer: 'wrong',
},
};
const invalidEntry2 = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: ['HS256'],
audience: 'wrong',
options: {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: ['HS256'],
audience: 'wrong',
},
};
const jwksHandler = new JWKSHandler();
@@ -178,17 +189,21 @@ describe('JWKSHandler', () => {
expect(() => {
jwksHandler.add(
new ConfigReader({
url: 'https://exampl e.com/jwks',
options: {
url: 'https://exampl e.com/jwks',
},
}),
);
}).toThrow('Invalid URL');
}).toThrow('Illegal JWKS URL, must be a set of non-space characters');
expect(() => {
jwksHandler.add(
new ConfigReader({
url: 'https://example.com/jwks\n',
options: {
url: 'https://example.com/jwks\n',
},
}),
);
}).toThrow('Illegal URL, must be a set of non-space characters');
}).toThrow('Illegal JWKS URL, must be a set of non-space characters');
});
it('gracefully handles no added tokens', async () => {
@@ -198,11 +213,13 @@ describe('JWKSHandler', () => {
it('uses custom subject prefix if provided', async () => {
const validEntry = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: 'RS256',
issuer: mockBaseUrl,
audience: 'backstage',
subjectPrefix: 'custom-prefix',
options: {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithm: 'RS256',
issuer: mockBaseUrl,
audience: 'backstage',
subjectPrefix: 'custom-prefix',
},
};
const jwksHandler = new JWKSHandler();
@@ -215,7 +232,30 @@ describe('JWKSHandler', () => {
const result = await jwksHandler.verifyToken(token);
expect(result).toEqual({
subject: `external:${validEntry.subjectPrefix}:${mockSubject}`,
subject: `external:${validEntry.options.subjectPrefix}:${mockSubject}`,
});
});
it('carries over access restrictions', async () => {
const jwksHandler = new JWKSHandler();
jwksHandler.add(
new ConfigReader({
options: {
url: `${mockBaseUrl}/.well-known/jwks.json`,
},
accessRestrictions: [{ plugin: 'scaffolder', permission: 'do.it' }],
}),
);
const token = await factory.issueToken({ claims: { sub: mockSubject } });
await expect(jwksHandler.verifyToken(token)).resolves.toEqual({
subject: `external:${mockSubject}`,
allAccessRestrictions: new Map(
Object.entries({
scaffolder: { permissionNames: ['do.it'] },
}),
),
});
});
});
@@ -16,8 +16,11 @@
import { jwtVerify, createRemoteJWKSet, JWTVerifyGetKey } from 'jose';
import { Config } from '@backstage/config';
import { readStringOrStringArrayFromConfig } from './helpers';
import { TokenHandler } from './types';
import {
readAccessRestrictionsFromConfig,
readStringOrStringArrayFromConfig,
} from './helpers';
import { AccessRestriptionsMap, TokenHandler } from './types';
/**
* Handles `type: jwks` access.
@@ -32,20 +35,30 @@ export class JWKSHandler implements TokenHandler {
subjectPrefix?: string;
url: URL;
jwks: JWTVerifyGetKey;
allAccessRestrictions?: AccessRestriptionsMap;
}> = [];
add(options: Config) {
const algorithms = readStringOrStringArrayFromConfig(options, 'algorithm');
const issuers = readStringOrStringArrayFromConfig(options, 'issuer');
const audiences = readStringOrStringArrayFromConfig(options, 'audience');
const subjectPrefix = options.getOptionalString('subjectPrefix');
const url = new URL(options.getString('url'));
const jwks = createRemoteJWKSet(url);
if (!options.getString('url').match(/^\S+$/)) {
throw new Error('Illegal URL, must be a set of non-space characters');
add(config: Config) {
if (!config.getString('options.url').match(/^\S+$/)) {
throw new Error(
'Illegal JWKS URL, must be a set of non-space characters',
);
}
const algorithms = readStringOrStringArrayFromConfig(
config,
'options.algorithm',
);
const issuers = readStringOrStringArrayFromConfig(config, 'options.issuer');
const audiences = readStringOrStringArrayFromConfig(
config,
'options.audience',
);
const subjectPrefix = config.getOptionalString('options.subjectPrefix');
const url = new URL(config.getString('options.url'));
const jwks = createRemoteJWKSet(url);
const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
this.#entries.push({
algorithms,
audiences,
@@ -53,6 +66,7 @@ export class JWKSHandler implements TokenHandler {
jwks,
subjectPrefix,
url,
allAccessRestrictions,
});
}
@@ -68,11 +82,13 @@ export class JWKSHandler implements TokenHandler {
});
if (sub) {
if (entry.subjectPrefix) {
return { subject: `external:${entry.subjectPrefix}:${sub}` };
}
return { subject: `external:${sub}` };
const prefix = entry.subjectPrefix
? `external:${entry.subjectPrefix}:`
: 'external:';
return {
subject: `${prefix}${sub}`,
allAccessRestrictions: entry.allAccessRestrictions,
};
}
} catch {
continue;