Merge pull request #24681 from ryan-hanchett/feat/add-jwks-access-type-1

feat: add jwks access type to external token handler
This commit is contained in:
Fredrik Adelöw
2024-05-22 15:28:27 +02:00
committed by GitHub
6 changed files with 403 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Add support for JWKS tokens in ExternalTokenHandler.
+49
View File
@@ -80,6 +80,55 @@ header:
Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
```
## JWKS Token Auth
This access method allows for external caller token authentication using configured
JSON Web Key Sets (JWKS). This is useful for callers that are authenticating to our
instance of Backstage with third-party tools, such as Auth0.
You can configure this access method by adding one or more entries of type `jwks`
to the `backend.auth.externalAccess` app-config key:
```yaml title="in e.g. app-config.production.yaml"
backend:
auth:
externalAccess:
- type: jwks
options:
url: https://example.com/.well-known/jwks.json
issuers:
- https://example.com
algorithms:
- RS256
audiences:
- example
subjectPrefix: custom-prefix
- type: jwks
options:
url: https://another-example.com/.well-known/jwks.json
issuers:
- https://example.com
```
The URL should point at an unauthenticated endpoint that returns the JWKS.
Issuers specifies the issuer(s) of the JWT that the authenticating app will accept.
Passed JWTs must have an `iss` claim which matches one of the specified issuers.
Algorithms specifies the algorithm(s) that are used to verify the JWT. The passed JWTs
must have been signed using one of the listed algorithms.
Audiences specify the intended audience(s) of the JWT. The passed JWTs must have an "aud"
claim that matches one of the audiences specified, or have no audience specified.
For additional details regarding the JWKS configuration, please consult your authentication
provider's documentation.
The subject returned from the token verification will become part of the
credentials object that the request recipient plugins get. All subjects will have the prefix
`external:`, but you can also provide a custom subjectPrefix which will get appended before the
subject returned from your JWKS service (ex. `external:custom-prefix:sub`).
## Legacy Tokens
Plugins and backends that are _not_ on the new backend system use a legacy token
+41
View File
@@ -131,6 +131,47 @@ export interface Config {
subject: string;
};
}
| {
/**
* This access method consists of a JWKS endpoint that can be used to
* verify JWT tokens.
*
* Callers generate JWT tokens via 3rd party tooling
* and pass them in the Authorization header:
*
* ```
* Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
* ```
*/
type: 'jwks';
options: {
/**
* Sets the algorithms that should be used to verify the JWT tokens.
* The passed JWTs must have been signed using one of the listed algorithms.
*/
algorithms?: string[];
/**
* Sets the issuers that should be used to verify the JWT tokens.
* Passed JWTs must have an `iss` claim which matches one of the specified issuers.
*/
issuers?: string[];
/**
* Sets the audiences that should be used to verify the JWT tokens.
* The passed JWTs must have an "aud" claim that matches one of the audiences specified,
* or have no audience specified.
*/
audiences?: string[];
/**
* Sets an optional subject prefix. Passes the subject to called plugins.
* Useful for debugging and tracking purposes.
*/
subjectPrefix?: string;
/**
* Sets the URL containing the JWKS endpoint.
*/
url: string;
};
}
>;
};
};
@@ -21,6 +21,7 @@ import {
import { LegacyTokenHandler } from './legacy';
import { StaticTokenHandler } from './static';
import { TokenHandler } from './types';
import { JWKSHandler } from './jwks';
const NEW_CONFIG_KEY = 'backend.auth.externalAccess';
const OLD_CONFIG_KEY = 'backend.auth.keys';
@@ -40,9 +41,11 @@ export class ExternalTokenHandler {
const staticHandler = new StaticTokenHandler();
const legacyHandler = new LegacyTokenHandler();
const jwksHandler = new JWKSHandler();
const handlers: Record<string, TokenHandler> = {
static: staticHandler,
legacy: legacyHandler,
jwks: jwksHandler,
};
// Load the new-style handlers
@@ -0,0 +1,223 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* 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';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { v4 as uuid } from 'uuid';
import { JWKSHandler } from './jwks';
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>();
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 };
}
}
const server = setupServer();
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
describe('JWKSHandler', () => {
let factory: FakeTokenFactory;
let mockSubject: string;
const keyDurationSeconds = 5;
setupRequestMockHandlers(server);
beforeEach(() => {
mockSubject = 'test_subject';
factory = new FakeTokenFactory({
issuer: mockBaseUrl,
keyDurationSeconds,
});
server.use(
rest.get(`${mockBaseUrl}/.well-known/jwks.json`, async (_, res, ctx) => {
const keys = await factory.listPublicKeys();
return res(ctx.json(keys));
}),
);
});
it('verifies token with valid entry', async () => {
const validEntry = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithms: ['RS256'],
issuers: [mockBaseUrl],
audiences: ['backstage'],
};
const jwksHandler = new JWKSHandler();
jwksHandler.add(new ConfigReader(validEntry));
const token = await factory.issueToken({
claims: { sub: mockSubject },
});
const result = await jwksHandler.verifyToken(token);
expect(result).toEqual({ subject: `external:${mockSubject}` });
});
it('skips invalid entry and continues verification', async () => {
const invalidEntry = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithms: ['RS256'],
issuers: ['fakeIssuer'],
audiences: ['fakeAud'],
};
const validEntry = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithms: ['RS256'],
issuers: ['multiple-issuers', mockBaseUrl],
audiences: ['multiple-audiences', 'backstage'],
};
const jwksHandler = new JWKSHandler();
jwksHandler.add(new ConfigReader(invalidEntry));
jwksHandler.add(new ConfigReader(validEntry));
const token = await factory.issueToken({
claims: { sub: mockSubject },
});
const result = await jwksHandler.verifyToken(token);
expect(result).toEqual({ subject: `external:${mockSubject}` });
});
it('returns undefined if no valid entry found', async () => {
const invalidEntry1 = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithms: ['RS256'],
issuers: [mockBaseUrl],
audiences: [],
};
const invalidEntry2 = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithms: ['HS256'],
issuers: [],
audiences: ['backstage'],
};
const jwksHandler = new JWKSHandler();
jwksHandler.add(new ConfigReader(invalidEntry1));
jwksHandler.add(new ConfigReader(invalidEntry2));
const token = await factory.issueToken({
claims: { sub: mockSubject },
});
const result = await jwksHandler.verifyToken(token);
expect(result).toBeUndefined();
});
it('rejects bad config', () => {
const jwksHandler = new JWKSHandler();
expect(() => {
jwksHandler.add(
new ConfigReader({
url: 'https://exampl e.com/jwks',
}),
);
}).toThrow('Invalid URL');
expect(() => {
jwksHandler.add(
new ConfigReader({
url: 'https://example.com/jwks\n',
}),
);
}).toThrow('Illegal URL, must be a set of non-space characters');
});
it('gracefully handles no added tokens', async () => {
const handler = new JWKSHandler();
await expect(handler.verifyToken('ghi')).resolves.toBeUndefined();
});
it('uses custom subject prefix if provided', async () => {
const validEntry = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithms: ['RS256'],
issuers: [mockBaseUrl],
audiences: ['backstage'],
subjectPrefix: 'custom-prefix',
};
const jwksHandler = new JWKSHandler();
jwksHandler.add(new ConfigReader(validEntry));
const token = await factory.issueToken({
claims: { sub: mockSubject },
});
const result = await jwksHandler.verifyToken(token);
expect(result).toEqual({
subject: `external:${validEntry.subjectPrefix}:${mockSubject}`,
});
});
});
@@ -0,0 +1,82 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { jwtVerify, createRemoteJWKSet, JWTVerifyGetKey } from 'jose';
import { Config } from '@backstage/config';
import { TokenHandler } from './types';
/**
* Handles `type: jwks` access.
*
* @internal
*/
export class JWKSHandler implements TokenHandler {
#entries: Array<{
algorithms?: string[];
audiences?: string[];
issuers?: string[];
subjectPrefix?: string;
url: URL;
jwks: JWTVerifyGetKey;
}> = [];
add(options: Config) {
const algorithms = options.getOptionalStringArray('algorithms');
const issuers = options.getOptionalStringArray('issuers');
const audiences = options.getOptionalStringArray('audiences');
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');
}
this.#entries.push({
algorithms,
audiences,
issuers,
jwks,
subjectPrefix,
url,
});
}
async verifyToken(token: string) {
for (const entry of this.#entries) {
try {
const {
payload: { sub },
} = await jwtVerify(token, entry.jwks, {
algorithms: entry.algorithms,
issuer: entry.issuers,
audience: entry.audiences,
});
if (sub) {
if (entry.subjectPrefix) {
return { subject: `external:${entry.subjectPrefix}:${sub}` };
}
return { subject: `external:${sub}` };
}
} catch {
continue;
}
}
return undefined;
}
}