implement external token access
Co-authored-by: Vincenzo Scamporlino <vincenzos@spotify.com> Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Vendored
+98
@@ -32,6 +32,104 @@ export interface Config {
|
||||
* unless you configure credentials for service calls.
|
||||
*/
|
||||
dangerouslyDisableDefaultAuthPolicy?: boolean;
|
||||
|
||||
/**
|
||||
* Configures methods of external access, ie ways for callers outside of
|
||||
* the Backstage ecosystem to get authorized for access to APIs that do
|
||||
* not permit unauthorized access.
|
||||
*
|
||||
* @deepVisibility secret
|
||||
*/
|
||||
externalAccess: Array<
|
||||
| {
|
||||
/**
|
||||
* This is the legacy service-to-service access method, where a set
|
||||
* of static keys were shared among plugins and used for symmetric
|
||||
* signing and verification. These correspond to the old
|
||||
* `backend.auth.keys` set and retain their behavior for backwards
|
||||
* compatibility. Please migrate to other access methods when
|
||||
* possible.
|
||||
*
|
||||
* Callers generate JWT tokens with the following payload:
|
||||
*
|
||||
* ```json
|
||||
* {
|
||||
* "sub": "backstage-plugin",
|
||||
* "exp": <epoch seconds one hour in the future>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* And sign them with HS256, using the base64 decoded secret. The
|
||||
* tokens are then passed along with requests in the Authorization
|
||||
* header:
|
||||
*
|
||||
* ```
|
||||
* Authorization: Bearer eyJhbGciOiJIUzI...
|
||||
* ```
|
||||
*/
|
||||
type: 'legacy';
|
||||
options: {
|
||||
/**
|
||||
* Any set of base64 encoded random bytes to be used as both the
|
||||
* signing and verification key. Should be sufficiently long so as
|
||||
* not to be easy to guess by brute force.
|
||||
*
|
||||
* Can be generated eg using
|
||||
*
|
||||
* ```sh
|
||||
* node -p 'require("crypto").randomBytes(24).toString("base64")'
|
||||
* ```
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* Sets the subject of the principal, when matching this token.
|
||||
* Useful for debugging and tracking purposes.
|
||||
*/
|
||||
subject: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* This access method consists of random static tokens that can be
|
||||
* handed out to callers.
|
||||
*
|
||||
* The tokens are then passed along verbatim with requests in the
|
||||
* Authorization header:
|
||||
*
|
||||
* ```
|
||||
* Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
|
||||
* ```
|
||||
*/
|
||||
type: 'static';
|
||||
options: {
|
||||
/**
|
||||
* A raw token that can be any string, but for security reasons
|
||||
* should be sufficiently long so as not to be easy to guess by
|
||||
* brute force.
|
||||
*
|
||||
* Can be generated eg using
|
||||
*
|
||||
* ```sh
|
||||
* node -p 'require("crypto").randomBytes(24).toString("base64")'
|
||||
* ```
|
||||
*
|
||||
* Since the tokens can be any string, you are free to add
|
||||
* additional identifying data to them if you like. For example,
|
||||
* adding a `freben-local-dev-` prefix for debugging purposes to a
|
||||
* token that you know will be handed out for use as a personal
|
||||
* access token during development.
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* Sets the subject of the principal, when matching this token.
|
||||
* Useful for debugging and tracking purposes.
|
||||
*/
|
||||
subject: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -26,8 +26,9 @@ import {
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { decodeJwt } from 'jose';
|
||||
import { PluginTokenHandler } from './PluginTokenHandler';
|
||||
import { UserTokenHandler } from './UserTokenHandler';
|
||||
import { ExternalTokenHandler } from './external/ExternalTokenHandler';
|
||||
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
|
||||
import { UserTokenHandler } from './user/UserTokenHandler';
|
||||
import {
|
||||
createCredentialsWithNonePrincipal,
|
||||
createCredentialsWithServicePrincipal,
|
||||
@@ -39,12 +40,13 @@ import { KeyStore } from './types';
|
||||
/** @internal */
|
||||
export class DefaultAuthService implements AuthService {
|
||||
constructor(
|
||||
private readonly tokenManager: TokenManager,
|
||||
private readonly userTokenHandler: UserTokenHandler,
|
||||
private readonly pluginTokenHandler: PluginTokenHandler,
|
||||
private readonly externalTokenHandler: ExternalTokenHandler,
|
||||
private readonly tokenManager: TokenManager,
|
||||
private readonly pluginId: string,
|
||||
private readonly disableDefaultAuthPolicy: boolean,
|
||||
private readonly publicKeyStore: KeyStore,
|
||||
private readonly pluginTokenHandler: PluginTokenHandler,
|
||||
) {}
|
||||
|
||||
// allowLimitedAccess is currently ignored, since we currently always use the full user tokens
|
||||
@@ -78,14 +80,15 @@ export 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');
|
||||
const externalResult = await this.externalTokenHandler.verifyToken(token);
|
||||
if (externalResult) {
|
||||
return createCredentialsWithServicePrincipal(
|
||||
externalResult.subject,
|
||||
externalResult.token,
|
||||
);
|
||||
}
|
||||
|
||||
throw new AuthenticationError('Unknown token');
|
||||
throw new AuthenticationError('Illegal token');
|
||||
}
|
||||
|
||||
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
|
||||
|
||||
@@ -20,8 +20,9 @@ import {
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { DatabaseKeyStore } from './DatabaseKeyStore';
|
||||
import { DefaultAuthService } from './DefaultAuthService';
|
||||
import { PluginTokenHandler } from './PluginTokenHandler';
|
||||
import { UserTokenHandler } from './UserTokenHandler';
|
||||
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
|
||||
import { UserTokenHandler } from './user/UserTokenHandler';
|
||||
import { ExternalTokenHandler } from './external/ExternalTokenHandler';
|
||||
|
||||
/** @public */
|
||||
export const authServiceFactory = createServiceFactory({
|
||||
@@ -46,21 +47,34 @@ export const authServiceFactory = createServiceFactory({
|
||||
),
|
||||
);
|
||||
|
||||
const publicKeyStore = await DatabaseKeyStore.create({ database, logger });
|
||||
const publicKeyStore = await DatabaseKeyStore.create({
|
||||
database,
|
||||
logger,
|
||||
});
|
||||
|
||||
const userTokens = UserTokenHandler.create({
|
||||
discovery,
|
||||
});
|
||||
const pluginTokens = PluginTokenHandler.create({
|
||||
ownPluginId: plugin.getId(),
|
||||
keyDurationSeconds: 60 * 60,
|
||||
logger,
|
||||
publicKeyStore,
|
||||
discovery,
|
||||
});
|
||||
const externalTokens = ExternalTokenHandler.create({
|
||||
config,
|
||||
logger,
|
||||
});
|
||||
|
||||
return new DefaultAuthService(
|
||||
userTokens,
|
||||
pluginTokens,
|
||||
externalTokens,
|
||||
tokenManager,
|
||||
new UserTokenHandler({ discovery }),
|
||||
plugin.getId(),
|
||||
disableDefaultAuthPolicy,
|
||||
publicKeyStore,
|
||||
PluginTokenHandler.create({
|
||||
ownPluginId: plugin.getId(),
|
||||
keyDurationSeconds: 60 * 60,
|
||||
logger,
|
||||
publicKeyStore,
|
||||
discovery,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Vendored
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 {
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { LegacyTokenHandler } from './legacy';
|
||||
import { StaticTokenHandler } from './static';
|
||||
import { TokenHandler } from './types';
|
||||
|
||||
const NEW_CONFIG_KEY = 'backend.auth.externalAccess';
|
||||
const OLD_CONFIG_KEY = 'backend.auth.keys';
|
||||
|
||||
/**
|
||||
* Handles all types of external caller token types (i.e. not Backstage user
|
||||
* tokens, nor Backstage backend plugin tokens).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class ExternalTokenHandler {
|
||||
static create(options: {
|
||||
config: RootConfigService;
|
||||
logger: LoggerService;
|
||||
}): ExternalTokenHandler {
|
||||
const { config, logger } = options;
|
||||
|
||||
const staticHandler = new StaticTokenHandler();
|
||||
const legacyHandler = new LegacyTokenHandler();
|
||||
const handlers: Record<string, TokenHandler> = {
|
||||
static: staticHandler,
|
||||
legacy: legacyHandler,
|
||||
};
|
||||
|
||||
// Load the new-style handlers
|
||||
const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? [];
|
||||
for (const handlerConfig of handlerConfigs) {
|
||||
const type = handlerConfig.getString('type');
|
||||
const handler = handlers[type];
|
||||
if (!handler) {
|
||||
const valid = Object.keys(handlers)
|
||||
.map(k => `'${k}'`)
|
||||
.join(', ');
|
||||
throw new Error(
|
||||
`Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`,
|
||||
);
|
||||
}
|
||||
handler.add(handlerConfig.getConfig('options'));
|
||||
}
|
||||
|
||||
// Load the old keys too
|
||||
const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? [];
|
||||
if (legacyConfigs.length) {
|
||||
logger.warn(
|
||||
`DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`,
|
||||
);
|
||||
}
|
||||
for (const handlerConfig of legacyConfigs) {
|
||||
legacyHandler.addOld(handlerConfig);
|
||||
}
|
||||
|
||||
return new ExternalTokenHandler(Object.values(handlers));
|
||||
}
|
||||
|
||||
constructor(private readonly handlers: TokenHandler[]) {}
|
||||
|
||||
async verifyToken(
|
||||
token: string,
|
||||
): Promise<{ subject: string; token?: string } | undefined> {
|
||||
for (const handler of this.handlers) {
|
||||
const result = await handler.verifyToken(token);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { SignJWT, importJWK } from 'jose';
|
||||
import { DateTime } from 'luxon';
|
||||
import { LegacyTokenHandler } from './legacy';
|
||||
|
||||
describe('LegacyTokenHandler', () => {
|
||||
const tokenHandler = new LegacyTokenHandler();
|
||||
const key1 = randomBytes(24);
|
||||
const key2 = randomBytes(24);
|
||||
const key3 = randomBytes(24);
|
||||
|
||||
tokenHandler.add(
|
||||
new ConfigReader({
|
||||
secret: key1.toString('base64'),
|
||||
subject: 'key1',
|
||||
}),
|
||||
);
|
||||
tokenHandler.add(
|
||||
new ConfigReader({
|
||||
secret: key2.toString('base64'),
|
||||
subject: 'key2',
|
||||
}),
|
||||
);
|
||||
tokenHandler.addOld(
|
||||
new ConfigReader({
|
||||
secret: key3.toString('base64'),
|
||||
}),
|
||||
);
|
||||
|
||||
it('should verify valid tokens', async () => {
|
||||
const token1 = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(key1);
|
||||
|
||||
await expect(tokenHandler.verifyToken(token1)).resolves.toEqual({
|
||||
subject: 'key1',
|
||||
token: token1,
|
||||
});
|
||||
|
||||
const token2 = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(key2);
|
||||
|
||||
await expect(tokenHandler.verifyToken(token2)).resolves.toEqual({
|
||||
subject: 'key2',
|
||||
token: token2,
|
||||
});
|
||||
|
||||
const token3 = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(key3);
|
||||
|
||||
await expect(tokenHandler.verifyToken(token3)).resolves.toEqual({
|
||||
subject: 'external:backstage-plugin',
|
||||
token: token3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined if the token is not a valid legacy token', async () => {
|
||||
const validToken = await new SignJWT({
|
||||
sub: 'backstage-serverrr',
|
||||
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(key1);
|
||||
|
||||
await expect(tokenHandler.verifyToken(validToken)).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
tokenHandler.verifyToken('statickeyblaaa'),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const randomToken = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(randomBytes(24));
|
||||
await expect(
|
||||
tokenHandler.verifyToken(randomToken),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const mockPublicKey = {
|
||||
kty: 'EC',
|
||||
x: 'GHlwg744e8JekzukPTdtix6R868D6fcWy0ooOx-NEZI',
|
||||
y: 'Lyujcm0M6X9_yQi3l1eH09z0brU8K9cwrLml_fRFKro',
|
||||
crv: 'P-256',
|
||||
kid: 'mock',
|
||||
alg: 'ES256',
|
||||
};
|
||||
const mockPrivateKey = {
|
||||
...mockPublicKey,
|
||||
d: 'KEn_mDqXYbZdRHb-JnCrW53LDOv5x4NL1FnlKcqBsFI',
|
||||
};
|
||||
|
||||
const keyWithWrongAlg = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'ES256' })
|
||||
.sign(await importJWK(mockPrivateKey));
|
||||
|
||||
await expect(
|
||||
tokenHandler.verifyToken(keyWithWrongAlg),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw in case key uses a different payload', async () => {
|
||||
const keyWithWrongExp = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
// @ts-expect-error
|
||||
exp: 'blaaah',
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(key1);
|
||||
|
||||
await expect(tokenHandler.verifyToken(keyWithWrongExp)).rejects.toThrow(
|
||||
/\"exp\" claim must be a number/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects bad config', () => {
|
||||
const handler = new LegacyTokenHandler();
|
||||
|
||||
// new style add, bad secrets
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ _missingsecret: true, subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: '', subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'has spaces', subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'hasnewline\n', subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 3, subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
|
||||
// new style add, bad subjects
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', _missingsubject: true })),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', subject: '' })),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', subject: 'has spaces' })),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ secret: 'b2s=', subject: 'hasnewline\n' }),
|
||||
),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', subject: 3 })),
|
||||
).toThrow(/subject/);
|
||||
|
||||
// old style add
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 'b2s=' })),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ _missingsecret: true })),
|
||||
).toThrow(/secret/);
|
||||
expect(() => handler.addOld(new ConfigReader({ secret: '' }))).toThrow(
|
||||
/secret/,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 'has spaces' })),
|
||||
).toThrow(/secret/);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 'hasnewline\n' })),
|
||||
).toThrow(/secret/);
|
||||
expect(() => handler.addOld(new ConfigReader({ secret: 3 }))).toThrow(
|
||||
/secret/,
|
||||
);
|
||||
});
|
||||
});
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 { Config } from '@backstage/config';
|
||||
import { base64url, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
|
||||
import { TokenHandler } from './types';
|
||||
|
||||
/**
|
||||
* Handles `type: legacy` access.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class LegacyTokenHandler implements TokenHandler {
|
||||
#entries: Array<{ key: Uint8Array; subject: string }> = [];
|
||||
|
||||
add(options: Config) {
|
||||
this.#doAdd(options.getString('secret'), options.getString('subject'));
|
||||
}
|
||||
|
||||
// used only for the old backend.auth.keys array
|
||||
addOld(options: Config) {
|
||||
// This choice of subject is for compatibility reasons
|
||||
this.#doAdd(options.getString('secret'), 'external:backstage-plugin');
|
||||
}
|
||||
|
||||
#doAdd(secret: string, subject: string) {
|
||||
if (!secret.match(/^\S+$/)) {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
}
|
||||
|
||||
let key: Uint8Array;
|
||||
try {
|
||||
key = base64url.decode(secret);
|
||||
} catch {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
}
|
||||
|
||||
if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
}
|
||||
|
||||
this.#entries.push({ key, subject });
|
||||
}
|
||||
|
||||
async verifyToken(
|
||||
token: string,
|
||||
): Promise<{ subject: string; token?: string } | undefined> {
|
||||
// First do a duck typing check to see if it remotely looks like a legacy token
|
||||
try {
|
||||
// We do a fair amount of checking upfront here. Since we aren't certain
|
||||
// that it's even the right type of key that we're looking at, we can't
|
||||
// defer eg the alg check to jwtVerify, because it won't be possible to
|
||||
// discern different reasons for key verification failures from each other
|
||||
// easily
|
||||
const { alg } = decodeProtectedHeader(token);
|
||||
if (alg !== 'HS256') {
|
||||
return undefined;
|
||||
}
|
||||
const { sub, aud } = decodeJwt(token);
|
||||
if (sub !== 'backstage-server' || aud) {
|
||||
return undefined;
|
||||
}
|
||||
} catch (e) {
|
||||
// Doesn't look like a jwt at all
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const entry of this.#entries) {
|
||||
try {
|
||||
await jwtVerify(token, entry.key);
|
||||
return {
|
||||
subject: entry.subject,
|
||||
token: token,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
|
||||
throw e;
|
||||
}
|
||||
// Otherwise continue to try the next key
|
||||
}
|
||||
}
|
||||
|
||||
// None of the signing keys matched
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { StaticTokenHandler } from './static';
|
||||
|
||||
describe('StaticTokenHandler', () => {
|
||||
it('accepts any of the added list of tokens', async () => {
|
||||
const handler = new StaticTokenHandler();
|
||||
handler.add(new ConfigReader({ token: 'abc', subject: 'one' }));
|
||||
handler.add(new ConfigReader({ token: 'def', subject: 'two' }));
|
||||
|
||||
await expect(handler.verifyToken('abc')).resolves.toEqual({
|
||||
subject: 'one',
|
||||
token: 'abc',
|
||||
});
|
||||
await expect(handler.verifyToken('def')).resolves.toEqual({
|
||||
subject: 'two',
|
||||
token: 'def',
|
||||
});
|
||||
await expect(handler.verifyToken('ghi')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('gracefully handles no added tokens', async () => {
|
||||
const handler = new StaticTokenHandler();
|
||||
await expect(handler.verifyToken('ghi')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects bad config', () => {
|
||||
const handler = new StaticTokenHandler();
|
||||
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ _missingtoken: true, subject: 'ok' })),
|
||||
).toThrow(/token/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: '', subject: 'ok' })),
|
||||
).toThrow(/token/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'has spaces', subject: 'ok' })),
|
||||
).toThrow(/token/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'hasnewline\n', subject: 'ok' })),
|
||||
).toThrow(/token/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 3, subject: 'ok' })),
|
||||
).toThrow(/token/);
|
||||
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'ok', _missingsubject: true })),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'ok', subject: '' })),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'ok', subject: 'has spaces' })),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'ok', subject: 'hasnewline\n' })),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'ok', subject: 3 })),
|
||||
).toThrow(/subject/);
|
||||
});
|
||||
});
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 { Config } from '@backstage/config';
|
||||
import { TokenHandler } from './types';
|
||||
|
||||
/**
|
||||
* Handles `type: static` access.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class StaticTokenHandler implements TokenHandler {
|
||||
#entries: Array<{ token: string; subject: string }> = [];
|
||||
|
||||
add(options: Config) {
|
||||
const token = options.getString('token');
|
||||
if (!token.match(/^\S+$/)) {
|
||||
throw new Error('Illegal token, must be a set of non-space characters');
|
||||
}
|
||||
|
||||
const subject = options.getString('subject');
|
||||
if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
}
|
||||
|
||||
this.#entries.push({ token, subject });
|
||||
}
|
||||
|
||||
async verifyToken(
|
||||
token: string,
|
||||
): Promise<{ subject: string; token: string } | undefined> {
|
||||
const entry = this.#entries.find(e => e.token === token);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
subject: entry.subject,
|
||||
token: token,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 { Config } from '@backstage/config';
|
||||
|
||||
export interface TokenHandler {
|
||||
add(options: Config): void;
|
||||
verifyToken(
|
||||
token: string,
|
||||
): Promise<{ subject: string; token?: string } | undefined>;
|
||||
}
|
||||
+2
-2
@@ -25,11 +25,11 @@ import {
|
||||
decodeProtectedHeader,
|
||||
} from 'jose';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { InternalKey, KeyStore } from './types';
|
||||
import { InternalKey, KeyStore } from '../types';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { tokenTypes } from '@backstage/plugin-auth-node';
|
||||
import { JwksClient } from './JwksClient';
|
||||
import { JwksClient } from '../JwksClient';
|
||||
|
||||
/**
|
||||
* The margin for how many times longer we make the public key available
|
||||
+1
-1
@@ -69,7 +69,7 @@ describe('UserTokenHandler', () => {
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
|
||||
userTokenHandler = new UserTokenHandler({
|
||||
userTokenHandler = UserTokenHandler.create({
|
||||
discovery: mockServices.discovery(),
|
||||
});
|
||||
|
||||
+15
-12
@@ -24,7 +24,7 @@ import {
|
||||
jwtVerify,
|
||||
JWTVerifyOptions,
|
||||
} from 'jose';
|
||||
import { JwksClient } from './JwksClient';
|
||||
import { JwksClient } from '../JwksClient';
|
||||
|
||||
/**
|
||||
* An identity client to interact with auth-backend and authenticate Backstage
|
||||
@@ -33,29 +33,32 @@ import { JwksClient } from './JwksClient';
|
||||
* @internal
|
||||
*/
|
||||
export class UserTokenHandler {
|
||||
readonly #jwksClient: JwksClient;
|
||||
readonly #algorithms?: string[];
|
||||
|
||||
constructor(options: { discovery: DiscoveryService }) {
|
||||
this.#algorithms = ['ES256']; // TODO: configurable?
|
||||
this.#jwksClient = new JwksClient(async () => {
|
||||
static create(options: { discovery: DiscoveryService }): UserTokenHandler {
|
||||
const algorithms = ['ES256']; // TODO: configurable?
|
||||
const jwksClient = new JwksClient(async () => {
|
||||
const url = await options.discovery.getBaseUrl('auth');
|
||||
return new URL(`${url}/.well-known/jwks.json`);
|
||||
});
|
||||
return new UserTokenHandler(algorithms, jwksClient);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly algorithms: string[],
|
||||
private readonly jwksClient: JwksClient,
|
||||
) {}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
const verifyOpts = this.#getTokenVerificationOptions(token);
|
||||
if (!verifyOpts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await this.#jwksClient.refreshKeyStore(token);
|
||||
await this.jwksClient.refreshKeyStore(token);
|
||||
|
||||
// Verify a limited token, ensuring the necessarily claims are present and token type is correct
|
||||
const { payload } = await jwtVerify(
|
||||
token,
|
||||
this.#jwksClient.getKey,
|
||||
this.jwksClient.getKey,
|
||||
verifyOpts,
|
||||
).catch(e => {
|
||||
throw new AuthenticationError('Invalid token', e);
|
||||
@@ -76,7 +79,7 @@ export class UserTokenHandler {
|
||||
|
||||
if (typ === tokenTypes.user.typParam) {
|
||||
return {
|
||||
algorithms: this.#algorithms,
|
||||
algorithms: this.algorithms,
|
||||
requiredClaims: ['iat', 'exp', 'sub'],
|
||||
typ: tokenTypes.user.typParam,
|
||||
};
|
||||
@@ -84,7 +87,7 @@ export class UserTokenHandler {
|
||||
|
||||
if (typ === tokenTypes.limitedUser.typParam) {
|
||||
return {
|
||||
algorithms: this.#algorithms,
|
||||
algorithms: this.algorithms,
|
||||
requiredClaims: ['iat', 'exp', 'sub'],
|
||||
typ: tokenTypes.limitedUser.typParam,
|
||||
};
|
||||
@@ -93,7 +96,7 @@ export class UserTokenHandler {
|
||||
const { aud } = decodeJwt(token);
|
||||
if (aud === tokenTypes.user.audClaim) {
|
||||
return {
|
||||
algorithms: this.#algorithms,
|
||||
algorithms: this.algorithms,
|
||||
audience: tokenTypes.user.audClaim,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user