Merge pull request #12542 from RoadieHQ/identity-api-client-interface
Identity api client interface
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-node': patch
|
||||
---
|
||||
|
||||
`IdentityClient` is now deprecated. Please migrate to `IdentityApi` and `DefaultIdentityClient` instead. The authenticate function on `DefaultIdentityClient` is also deprecated. Please use `getIdentity` instead.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Adds `IdentityApi` configuration to `create-app` scaffolding templates.
|
||||
|
||||
To migrate to the new `IdentityApi`, edit the `packages/backend/src/index.ts` adding the following import:
|
||||
|
||||
```typescript
|
||||
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
|
||||
```
|
||||
|
||||
Use the factory function to create an `IdentityApi` in the `makeCreateEnv` function and return it from the
|
||||
function as follows:
|
||||
|
||||
```typescript
|
||||
function makeCreateEnv(config: Config) {
|
||||
...
|
||||
const identity = DefaultIdentityClient.create({
|
||||
discovery,
|
||||
});
|
||||
...
|
||||
|
||||
return {
|
||||
...,
|
||||
identity
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Backend plugins can be upgraded to work with this new `IdentityApi`.
|
||||
|
||||
Add `identity` to the `RouterOptions` type.
|
||||
|
||||
```typescript
|
||||
export interface RouterOptions {
|
||||
...
|
||||
identity: IdentityApi;
|
||||
}
|
||||
```
|
||||
|
||||
Then you can use the `IdentityApi` from the plugin.
|
||||
|
||||
```typescript
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { identity } = options;
|
||||
|
||||
router.get('/user', async (req, res) => {
|
||||
const user = await identity.getIdentity({ request: req });
|
||||
...
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-permission-backend': patch
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Uptake the `IdentityApi` change to use `getIdentity` instead of `authenticate` for retrieving the logged in users identity.
|
||||
@@ -46,7 +46,7 @@ The source code is available here:
|
||||
Create a new `packages/backend/src/plugins/todolist.ts` with the following content:
|
||||
|
||||
```javascript
|
||||
import { IdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { createRouter } from '@internal/plugin-todo-list-backend';
|
||||
import { Router } from 'express';
|
||||
import { PluginEnvironment } from '../types';
|
||||
@@ -57,7 +57,7 @@ The source code is available here:
|
||||
}: PluginEnvironment): Promise<Router> {
|
||||
return await createRouter({
|
||||
logger,
|
||||
identity: IdentityClient.create({
|
||||
identity: DefaultIdentityClient.create({
|
||||
discovery,
|
||||
issuer: await discovery.getExternalBaseUrl('auth'),
|
||||
}),
|
||||
|
||||
@@ -59,6 +59,7 @@ import jenkins from './plugins/jenkins';
|
||||
import permission from './plugins/permission';
|
||||
import { PluginEnvironment } from './types';
|
||||
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
|
||||
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
|
||||
|
||||
function makeCreateEnv(config: Config) {
|
||||
const root = getRootLogger();
|
||||
@@ -72,6 +73,9 @@ function makeCreateEnv(config: Config) {
|
||||
const databaseManager = DatabaseManager.fromConfig(config, { logger: root });
|
||||
const cacheManager = CacheManager.fromConfig(config);
|
||||
const taskScheduler = TaskScheduler.fromConfig(config);
|
||||
const identity = DefaultIdentityClient.create({
|
||||
discovery,
|
||||
});
|
||||
|
||||
root.info(`Created UrlReader ${reader}`);
|
||||
|
||||
@@ -80,6 +84,7 @@ function makeCreateEnv(config: Config) {
|
||||
const database = databaseManager.forPlugin(plugin);
|
||||
const cache = cacheManager.forPlugin(plugin);
|
||||
const scheduler = taskScheduler.forPlugin(plugin);
|
||||
|
||||
return {
|
||||
logger,
|
||||
cache,
|
||||
@@ -90,6 +95,7 @@ function makeCreateEnv(config: Config) {
|
||||
tokenManager,
|
||||
permissions,
|
||||
scheduler,
|
||||
identity,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { IdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { createRouter } from '@backstage/plugin-permission-backend';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
@@ -40,9 +39,6 @@ export default async function createPlugin(
|
||||
logger: env.logger,
|
||||
discovery: env.discovery,
|
||||
policy: new AllowAllPermissionPolicy(),
|
||||
identity: IdentityClient.create({
|
||||
discovery: env.discovery,
|
||||
issuer: await env.discovery.getExternalBaseUrl('auth'),
|
||||
}),
|
||||
identity: env.identity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,5 +32,6 @@ export default async function createPlugin(
|
||||
database: env.database,
|
||||
catalogClient: catalogClient,
|
||||
reader: env.reader,
|
||||
identity: env.identity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
PermissionAuthorizer,
|
||||
PermissionEvaluator,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
|
||||
export type PluginEnvironment = {
|
||||
logger: Logger;
|
||||
@@ -39,4 +40,5 @@ export type PluginEnvironment = {
|
||||
tokenManager: TokenManager;
|
||||
permissions: PermissionEvaluator | PermissionAuthorizer;
|
||||
scheduler: PluginTaskScheduler;
|
||||
identity: IdentityApi;
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ import { version as theme } from '../../../theme/package.json';
|
||||
import { version as pluginApiDocs } from '../../../../plugins/api-docs/package.json';
|
||||
import { version as pluginAppBackend } from '../../../../plugins/app-backend/package.json';
|
||||
import { version as pluginAuthBackend } from '../../../../plugins/auth-backend/package.json';
|
||||
import { version as pluginAuthNode } from '../../../../plugins/auth-node/package.json';
|
||||
import { version as pluginCatalog } from '../../../../plugins/catalog/package.json';
|
||||
import { version as pluginCatalogCommon } from '../../../../plugins/catalog-common/package.json';
|
||||
import { version as pluginCatalogReact } from '../../../../plugins/catalog-react/package.json';
|
||||
@@ -96,6 +97,7 @@ export const packageVersions = {
|
||||
'@backstage/plugin-api-docs': pluginApiDocs,
|
||||
'@backstage/plugin-app-backend': pluginAppBackend,
|
||||
'@backstage/plugin-auth-backend': pluginAuthBackend,
|
||||
'@backstage/plugin-auth-node': pluginAuthNode,
|
||||
'@backstage/plugin-catalog': pluginCatalog,
|
||||
'@backstage/plugin-catalog-common': pluginCatalogCommon,
|
||||
'@backstage/plugin-catalog-react': pluginCatalogReact,
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@backstage/config": "^{{version '@backstage/config'}}",
|
||||
"@backstage/plugin-app-backend": "^{{version '@backstage/plugin-app-backend'}}",
|
||||
"@backstage/plugin-auth-backend": "^{{version '@backstage/plugin-auth-backend'}}",
|
||||
"@backstage/plugin-auth-node": "^{{version '@backstage/plugin-auth-node'}}",
|
||||
"@backstage/plugin-catalog-backend": "^{{version '@backstage/plugin-catalog-backend'}}",
|
||||
"@backstage/plugin-permission-common": "^{{version '@backstage/plugin-permission-common'}}",
|
||||
"@backstage/plugin-permission-node": "^{{version '@backstage/plugin-permission-node'}}",
|
||||
|
||||
@@ -30,6 +30,7 @@ import techdocs from './plugins/techdocs';
|
||||
import search from './plugins/search';
|
||||
import { PluginEnvironment } from './types';
|
||||
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
|
||||
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
|
||||
|
||||
function makeCreateEnv(config: Config) {
|
||||
const root = getRootLogger();
|
||||
@@ -39,6 +40,10 @@ function makeCreateEnv(config: Config) {
|
||||
const databaseManager = DatabaseManager.fromConfig(config, { logger: root });
|
||||
const tokenManager = ServerTokenManager.noop();
|
||||
const taskScheduler = TaskScheduler.fromConfig(config);
|
||||
|
||||
const identity = DefaultIdentityClient.create({
|
||||
discovery,
|
||||
});
|
||||
const permissions = ServerPermissionClient.fromConfig(config, {
|
||||
discovery,
|
||||
tokenManager,
|
||||
@@ -61,6 +66,7 @@ function makeCreateEnv(config: Config) {
|
||||
tokenManager,
|
||||
scheduler,
|
||||
permissions,
|
||||
identity,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ export default async function createPlugin(
|
||||
database: env.database,
|
||||
reader: env.reader,
|
||||
catalogClient,
|
||||
identity: env.identity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@backstage/backend-common';
|
||||
import { PluginTaskScheduler } from '@backstage/backend-tasks';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
|
||||
export type PluginEnvironment = {
|
||||
logger: Logger;
|
||||
@@ -20,4 +21,5 @@ export type PluginEnvironment = {
|
||||
tokenManager: TokenManager;
|
||||
scheduler: PluginTaskScheduler;
|
||||
permissions: PermissionEvaluator;
|
||||
identity: IdentityApi;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
```ts
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { Request as Request_2 } from 'express';
|
||||
|
||||
// @public
|
||||
export interface BackstageIdentityResponse extends BackstageSignInResult {
|
||||
@@ -22,21 +23,48 @@ export type BackstageUserIdentity = {
|
||||
ownershipEntityRefs: string[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export class DefaultIdentityClient implements IdentityApi {
|
||||
// @deprecated
|
||||
authenticate(token: string | undefined): Promise<BackstageIdentityResponse>;
|
||||
static create(options: IdentityClientOptions): DefaultIdentityClient;
|
||||
// (undocumented)
|
||||
getIdentity({
|
||||
request,
|
||||
}: IdentityApiGetIdentityRequest): Promise<
|
||||
BackstageIdentityResponse | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function getBearerTokenFromAuthorizationHeader(
|
||||
authorizationHeader: unknown,
|
||||
): string | undefined;
|
||||
|
||||
// @public
|
||||
export interface IdentityApi {
|
||||
getIdentity(
|
||||
options: IdentityApiGetIdentityRequest,
|
||||
): Promise<BackstageIdentityResponse | undefined>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type IdentityApiGetIdentityRequest = {
|
||||
request: Request_2<unknown>;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export class IdentityClient {
|
||||
// @deprecated
|
||||
authenticate(token: string | undefined): Promise<BackstageIdentityResponse>;
|
||||
// (undocumented)
|
||||
static create(options: IdentityClientOptions): IdentityClient;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type IdentityClientOptions = {
|
||||
discovery: PluginEndpointDiscovery;
|
||||
issuer: string;
|
||||
issuer?: string;
|
||||
algorithms?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
"@backstage/backend-common": "^0.15.1-next.1",
|
||||
"@backstage/config": "^1.0.1",
|
||||
"@backstage/errors": "^1.1.0",
|
||||
"@types/express": "*",
|
||||
"express": "^4.17.1",
|
||||
"jose": "^4.6.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"winston": "^3.2.1"
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* Copyright 2020 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 { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import {
|
||||
decodeProtectedHeader,
|
||||
exportJWK,
|
||||
generateKeyPair,
|
||||
SignJWT,
|
||||
} from 'jose';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { DefaultIdentityClient } from './DefaultIdentityClient';
|
||||
import { IdentityApiGetIdentityRequest } from './types';
|
||||
|
||||
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('ES256');
|
||||
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: 'ES256', 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 };
|
||||
}
|
||||
}
|
||||
|
||||
function jwtKid(jwt: string): string {
|
||||
const header = decodeProtectedHeader(jwt);
|
||||
return header.kid ?? '';
|
||||
}
|
||||
|
||||
const server = setupServer();
|
||||
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
|
||||
const discovery: PluginEndpointDiscovery = {
|
||||
async getBaseUrl() {
|
||||
return mockBaseUrl;
|
||||
},
|
||||
async getExternalBaseUrl() {
|
||||
return mockBaseUrl;
|
||||
},
|
||||
};
|
||||
|
||||
describe('DefaultIdentityClient', () => {
|
||||
let client: DefaultIdentityClient;
|
||||
let factory: FakeTokenFactory;
|
||||
const keyDurationSeconds = 5;
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterAll(() => server.close());
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
beforeEach(() => {
|
||||
client = DefaultIdentityClient.create({ discovery, issuer: mockBaseUrl });
|
||||
factory = new FakeTokenFactory({
|
||||
issuer: mockBaseUrl,
|
||||
keyDurationSeconds,
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity client configuration', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/.well-known/jwks.json`,
|
||||
async (_, res, ctx) => {
|
||||
const keys = await factory.listPublicKeys();
|
||||
return res(ctx.json(keys));
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should defaults to ES256 when no algorithm is supplied', async () => {
|
||||
const identityClient = DefaultIdentityClient.create({
|
||||
discovery,
|
||||
issuer: mockBaseUrl,
|
||||
});
|
||||
|
||||
const token = await factory.issueToken({ claims: { sub: 'foo' } });
|
||||
const response = await identityClient.authenticate(token);
|
||||
|
||||
// expect that the authenticate is able to validate a token with ES256, which is the one set to FakeTokenFactory.
|
||||
// This means that IdentityClient set ES256 by default.
|
||||
expect(response).toEqual({
|
||||
token: token,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'foo',
|
||||
ownershipEntityRefs: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error on empty algorithms array', async () => {
|
||||
const identityClient = DefaultIdentityClient.create({
|
||||
discovery,
|
||||
issuer: mockBaseUrl,
|
||||
algorithms: [''],
|
||||
});
|
||||
|
||||
const token = await factory.issueToken({ claims: { sub: 'foo' } });
|
||||
return expect(
|
||||
async () => await identityClient.authenticate(token),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error on empty algorithm string', async () => {
|
||||
const identityClient = DefaultIdentityClient.create({
|
||||
discovery,
|
||||
issuer: mockBaseUrl,
|
||||
algorithms: [],
|
||||
});
|
||||
|
||||
const token = await factory.issueToken({ claims: { sub: 'foo' } });
|
||||
return expect(
|
||||
async () => await identityClient.authenticate(token),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/.well-known/jwks.json`,
|
||||
async (_, res, ctx) => {
|
||||
const keys = await factory.listPublicKeys();
|
||||
return res(ctx.json(keys));
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on undefined header', async () => {
|
||||
return expect(async () => {
|
||||
await client.authenticate(undefined);
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should accept fresh token', async () => {
|
||||
const token = await factory.issueToken({ claims: { sub: 'foo' } });
|
||||
const response = await client.authenticate(token);
|
||||
expect(response).toEqual({
|
||||
token: token,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'foo',
|
||||
ownershipEntityRefs: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should decode claims correctly', async () => {
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: 'foo', ent: ['entity1', 'entity2'] },
|
||||
});
|
||||
const response = await client.authenticate(token);
|
||||
expect(response).toEqual({
|
||||
token: token,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'foo',
|
||||
ownershipEntityRefs: ['entity1', 'entity2'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on incorrect issuer', async () => {
|
||||
const hackerFactory = new FakeTokenFactory({
|
||||
issuer: 'hacker',
|
||||
keyDurationSeconds,
|
||||
});
|
||||
return expect(async () => {
|
||||
const token = await hackerFactory.issueToken({
|
||||
claims: { sub: 'foo' },
|
||||
});
|
||||
await client.authenticate(token);
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw on expired token', async () => {
|
||||
return expect(async () => {
|
||||
const fixedTime = Date.now();
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2);
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: 'foo' },
|
||||
});
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime);
|
||||
await client.authenticate(token);
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw on incorrect signing key', async () => {
|
||||
const hackerFactory = new FakeTokenFactory({
|
||||
issuer: mockBaseUrl,
|
||||
keyDurationSeconds,
|
||||
});
|
||||
return expect(async () => {
|
||||
const token = await hackerFactory.issueToken({
|
||||
claims: { sub: 'foo' },
|
||||
});
|
||||
await client.authenticate(token);
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should accept token from new key', async () => {
|
||||
const fixedTime = Date.now();
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2);
|
||||
const token1 = await factory.issueToken({ claims: { sub: 'foo1' } });
|
||||
try {
|
||||
// This throws as token has already expired
|
||||
await client.authenticate(token1);
|
||||
} catch (_err) {
|
||||
// Ignore thrown error
|
||||
}
|
||||
// Move forward in time where the signing key has been rotated and the
|
||||
// cooldown period to look up a new public key has elapsed.
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(
|
||||
() => fixedTime + 30 * keyDurationSeconds * 1000 + 2,
|
||||
);
|
||||
const token = await factory.issueToken({ claims: { sub: 'foo' } });
|
||||
const response = await client.authenticate(token);
|
||||
expect(response).toEqual({
|
||||
token: token,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'foo',
|
||||
ownershipEntityRefs: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not be fooled by the none algorithm', async () => {
|
||||
return expect(async () => {
|
||||
const token = await factory.issueToken({ claims: { sub: 'foo' } });
|
||||
const header = btoa(
|
||||
JSON.stringify({ alg: 'none', kid: jwtKid(token) }),
|
||||
);
|
||||
const payload = btoa(
|
||||
JSON.stringify({
|
||||
iss: mockBaseUrl,
|
||||
sub: 'foo',
|
||||
aud: 'backstage',
|
||||
iat: Date.now() / 1000,
|
||||
exp: Date.now() / 1000 + 60000,
|
||||
}),
|
||||
);
|
||||
const fakeToken = `${header}.${payload}.`;
|
||||
return await client.authenticate(fakeToken);
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should use an updated endpoint when the key is not found', async () => {
|
||||
const updatedURL = 'http://backstage:9191/an-updated-base';
|
||||
const getBaseUrl = discovery.getBaseUrl;
|
||||
const getExternalBaseUrl = discovery.getExternalBaseUrl;
|
||||
// Generate a key and sign a token with it
|
||||
await factory.issueToken({ claims: { sub: 'foo' } });
|
||||
// Only return the key from a single token
|
||||
const singleKey = cloneDeep(await factory.listPublicKeys());
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/.well-known/jwks.json`,
|
||||
async (_, res, ctx) => {
|
||||
return res(ctx.json(singleKey));
|
||||
},
|
||||
),
|
||||
);
|
||||
// Update the discovery endpoint to point to a new URL
|
||||
discovery.getBaseUrl = async () => {
|
||||
return updatedURL;
|
||||
};
|
||||
discovery.getExternalBaseUrl = async () => {
|
||||
return updatedURL;
|
||||
};
|
||||
let calledUpdatedEndpoint = false;
|
||||
server.use(
|
||||
rest.get(`${updatedURL}/.well-known/jwks.json`, async (_, res, ctx) => {
|
||||
const keys = await factory.listPublicKeys();
|
||||
calledUpdatedEndpoint = true;
|
||||
return res(ctx.json(keys));
|
||||
}),
|
||||
);
|
||||
// Advance time
|
||||
const future_11s = Date.now() + 11 * 1000;
|
||||
const dateSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => future_11s);
|
||||
// Issue a new token
|
||||
const token = await factory.issueToken({ claims: { sub: 'foo2' } });
|
||||
const response = await client.authenticate(token);
|
||||
// Verify that the endpoint was updated.
|
||||
expect(calledUpdatedEndpoint).toBeTruthy();
|
||||
expect(response).toEqual({
|
||||
token: token,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'foo2',
|
||||
ownershipEntityRefs: [],
|
||||
},
|
||||
});
|
||||
// Restore the discovery endpoint and time
|
||||
discovery.getBaseUrl = getBaseUrl;
|
||||
discovery.getExternalBaseUrl = getExternalBaseUrl;
|
||||
dateSpy.mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIdentity', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/.well-known/jwks.json`,
|
||||
async (_, res, ctx) => {
|
||||
const keys = await factory.listPublicKeys();
|
||||
return res(ctx.json(keys));
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the identity', async () => {
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: 'foo', ent: ['entity1', 'entity2'] },
|
||||
});
|
||||
const response = await client.getIdentity({
|
||||
request: { headers: { authorization: `Bearer ${token}` } },
|
||||
} as IdentityApiGetIdentityRequest);
|
||||
expect(response).toEqual({
|
||||
token: token,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'foo',
|
||||
ownershipEntityRefs: ['entity1', 'entity2'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('given a corrupt the identity', async () => {
|
||||
await expect(
|
||||
client.getIdentity({
|
||||
request: { headers: { authorization: `Bearer bad-token` } },
|
||||
} as IdentityApiGetIdentityRequest),
|
||||
).rejects.toThrow('Invalid JWT');
|
||||
});
|
||||
|
||||
it('given no authorization header', async () => {
|
||||
expect(
|
||||
await client.getIdentity({
|
||||
request: { headers: {} },
|
||||
} as IdentityApiGetIdentityRequest),
|
||||
).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright 2020 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 { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
|
||||
import {
|
||||
createRemoteJWKSet,
|
||||
decodeJwt,
|
||||
decodeProtectedHeader,
|
||||
FlattenedJWSInput,
|
||||
JWSHeaderParameters,
|
||||
jwtVerify,
|
||||
} from 'jose';
|
||||
import { GetKeyFunction } from 'jose/dist/types/types';
|
||||
|
||||
import {
|
||||
BackstageIdentityResponse,
|
||||
IdentityApiGetIdentityRequest,
|
||||
} from './types';
|
||||
import { getBearerTokenFromAuthorizationHeader, IdentityApi } from '.';
|
||||
|
||||
const CLOCK_MARGIN_S = 10;
|
||||
|
||||
/**
|
||||
* An identity client options object which allows extra configurations
|
||||
*
|
||||
* @experimental This is not a stable API yet
|
||||
* @public
|
||||
*/
|
||||
export type IdentityClientOptions = {
|
||||
discovery: PluginEndpointDiscovery;
|
||||
issuer?: string;
|
||||
|
||||
/** JWS "alg" (Algorithm) Header Parameter values. Defaults to an array containing just ES256.
|
||||
* More info on supported algorithms: https://github.com/panva/jose */
|
||||
algorithms?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* An identity client to interact with auth-backend and authenticate Backstage
|
||||
* tokens
|
||||
*
|
||||
* @experimental This is not a stable API yet
|
||||
* @public
|
||||
*/
|
||||
export class DefaultIdentityClient implements IdentityApi {
|
||||
private readonly discovery: PluginEndpointDiscovery;
|
||||
private readonly issuer?: string;
|
||||
private readonly algorithms?: string[];
|
||||
private keyStore?: GetKeyFunction<JWSHeaderParameters, FlattenedJWSInput>;
|
||||
private keyStoreUpdated: number = 0;
|
||||
|
||||
/**
|
||||
* Create a new {@link DefaultIdentityClient} instance.
|
||||
*/
|
||||
static create(options: IdentityClientOptions): DefaultIdentityClient {
|
||||
return new DefaultIdentityClient(options);
|
||||
}
|
||||
|
||||
private constructor(options: IdentityClientOptions) {
|
||||
this.discovery = options.discovery;
|
||||
this.issuer = options.issuer;
|
||||
this.algorithms = options.hasOwnProperty('algorithms')
|
||||
? options.algorithms
|
||||
: ['ES256'];
|
||||
}
|
||||
|
||||
async getIdentity({ request }: IdentityApiGetIdentityRequest) {
|
||||
if (!request.headers.authorization) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return await this.authenticate(
|
||||
getBearerTokenFromAuthorizationHeader(request.headers.authorization),
|
||||
);
|
||||
} catch (e) {
|
||||
throw new NotAllowedError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the given backstage identity token
|
||||
* Returns a BackstageIdentity (user) matching the token.
|
||||
* The method throws an error if verification fails.
|
||||
*
|
||||
* @deprecated You should start to use getIdentity instead of authenticate to retrieve the user
|
||||
* identity.
|
||||
*/
|
||||
async authenticate(
|
||||
token: string | undefined,
|
||||
): Promise<BackstageIdentityResponse> {
|
||||
// Extract token from header
|
||||
if (!token) {
|
||||
throw new AuthenticationError('No token specified');
|
||||
}
|
||||
|
||||
// Verify token claims and signature
|
||||
// Note: Claims must match those set by TokenFactory when issuing tokens
|
||||
// Note: verify throws if verification fails
|
||||
// Check if the keystore needs to be updated
|
||||
await this.refreshKeyStore(token);
|
||||
if (!this.keyStore) {
|
||||
throw new AuthenticationError('No keystore exists');
|
||||
}
|
||||
const decoded = await jwtVerify(token, this.keyStore, {
|
||||
algorithms: this.algorithms,
|
||||
audience: 'backstage',
|
||||
issuer: this.issuer,
|
||||
});
|
||||
// Verified, return the matching user as BackstageIdentity
|
||||
// TODO: Settle internal user format/properties
|
||||
if (!decoded.payload.sub) {
|
||||
throw new AuthenticationError('No user sub found in token');
|
||||
}
|
||||
|
||||
const user: BackstageIdentityResponse = {
|
||||
token,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: decoded.payload.sub,
|
||||
ownershipEntityRefs: decoded.payload.ent
|
||||
? (decoded.payload.ent as string[])
|
||||
: [],
|
||||
},
|
||||
};
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the last keystore refresh is stale, update the keystore URL to the latest
|
||||
*/
|
||||
private async refreshKeyStore(rawJwtToken: string): Promise<void> {
|
||||
const payload = await decodeJwt(rawJwtToken);
|
||||
const header = await decodeProtectedHeader(rawJwtToken);
|
||||
|
||||
// Refresh public keys if needed
|
||||
let keyStoreHasKey;
|
||||
try {
|
||||
if (this.keyStore) {
|
||||
// Check if the key is present in the keystore
|
||||
const [_, rawPayload, rawSignature] = rawJwtToken.split('.');
|
||||
keyStoreHasKey = await this.keyStore(header, {
|
||||
payload: rawPayload,
|
||||
signature: rawSignature,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
keyStoreHasKey = false;
|
||||
}
|
||||
// Refresh public key URL if needed
|
||||
// Add a small margin in case clocks are out of sync
|
||||
const issuedAfterLastRefresh =
|
||||
payload?.iat && payload.iat > this.keyStoreUpdated - CLOCK_MARGIN_S;
|
||||
if (!this.keyStore || (!keyStoreHasKey && issuedAfterLastRefresh)) {
|
||||
const url = await this.discovery.getBaseUrl('auth');
|
||||
const endpoint = new URL(`${url}/.well-known/jwks.json`);
|
||||
this.keyStore = createRemoteJWKSet(endpoint);
|
||||
this.keyStoreUpdated = Date.now() / 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2020 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 {
|
||||
BackstageIdentityResponse,
|
||||
IdentityApiGetIdentityRequest,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* An identity client api to authenticate Backstage
|
||||
* tokens
|
||||
*
|
||||
* @experimental This is not a stable API yet
|
||||
* @public
|
||||
*/
|
||||
export interface IdentityApi {
|
||||
/**
|
||||
* Verifies the given backstage identity token
|
||||
* Returns a BackstageIdentity (user) matching the token.
|
||||
* The method throws an error if verification fails.
|
||||
*/
|
||||
getIdentity(
|
||||
options: IdentityApiGetIdentityRequest,
|
||||
): Promise<BackstageIdentityResponse | undefined>;
|
||||
}
|
||||
@@ -13,139 +13,42 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
|
||||
import {
|
||||
createRemoteJWKSet,
|
||||
decodeJwt,
|
||||
decodeProtectedHeader,
|
||||
FlattenedJWSInput,
|
||||
JWSHeaderParameters,
|
||||
jwtVerify,
|
||||
} from 'jose';
|
||||
import { GetKeyFunction } from 'jose/dist/types/types';
|
||||
|
||||
DefaultIdentityClient,
|
||||
IdentityClientOptions,
|
||||
} from './DefaultIdentityClient';
|
||||
import { BackstageIdentityResponse } from './types';
|
||||
|
||||
const CLOCK_MARGIN_S = 10;
|
||||
|
||||
/**
|
||||
* An identity client options object which allows extra configurations
|
||||
*
|
||||
* @experimental This is not a stable API yet
|
||||
* @public
|
||||
*/
|
||||
export type IdentityClientOptions = {
|
||||
discovery: PluginEndpointDiscovery;
|
||||
issuer: string;
|
||||
|
||||
/** JWS "alg" (Algorithm) Header Parameter values. Defaults to an array containing just ES256.
|
||||
* More info on supported algorithms: https://github.com/panva/jose */
|
||||
algorithms?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* An identity client to interact with auth-backend and authenticate Backstage
|
||||
* tokens
|
||||
*
|
||||
* @experimental This is not a stable API yet
|
||||
* @public
|
||||
* @experimental This is not a stable API yet
|
||||
* @deprecated Please migrate to the DefaultIdentityClient.
|
||||
*/
|
||||
export class IdentityClient {
|
||||
private readonly discovery: PluginEndpointDiscovery;
|
||||
private readonly issuer: string;
|
||||
private readonly algorithms: string[];
|
||||
private keyStore?: GetKeyFunction<JWSHeaderParameters, FlattenedJWSInput>;
|
||||
private keyStoreUpdated: number = 0;
|
||||
|
||||
/**
|
||||
* Create a new {@link IdentityClient} instance.
|
||||
*/
|
||||
private readonly defaultIdentityClient: DefaultIdentityClient;
|
||||
static create(options: IdentityClientOptions): IdentityClient {
|
||||
return new IdentityClient(options);
|
||||
return new IdentityClient(DefaultIdentityClient.create(options));
|
||||
}
|
||||
|
||||
private constructor(options: IdentityClientOptions) {
|
||||
this.discovery = options.discovery;
|
||||
this.issuer = options.issuer;
|
||||
this.algorithms = options.algorithms ?? ['ES256'];
|
||||
private constructor(defaultIdentityClient: DefaultIdentityClient) {
|
||||
this.defaultIdentityClient = defaultIdentityClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the given backstage identity token
|
||||
* Returns a BackstageIdentity (user) matching the token.
|
||||
* The method throws an error if verification fails.
|
||||
*
|
||||
* @deprecated You should start to use IdentityApi#getIdentity instead of authenticate
|
||||
* to retrieve the user identity.
|
||||
*/
|
||||
async authenticate(
|
||||
token: string | undefined,
|
||||
): Promise<BackstageIdentityResponse> {
|
||||
// Extract token from header
|
||||
if (!token) {
|
||||
throw new AuthenticationError('No token specified');
|
||||
}
|
||||
|
||||
// Verify token claims and signature
|
||||
// Note: Claims must match those set by TokenFactory when issuing tokens
|
||||
// Note: verify throws if verification fails
|
||||
// Check if the keystore needs to be updated
|
||||
await this.refreshKeyStore(token);
|
||||
if (!this.keyStore) {
|
||||
throw new AuthenticationError('No keystore exists');
|
||||
}
|
||||
const decoded = await jwtVerify(token, this.keyStore, {
|
||||
algorithms: this.algorithms,
|
||||
audience: 'backstage',
|
||||
issuer: this.issuer,
|
||||
});
|
||||
// Verified, return the matching user as BackstageIdentity
|
||||
// TODO: Settle internal user format/properties
|
||||
if (!decoded.payload.sub) {
|
||||
throw new AuthenticationError('No user sub found in token');
|
||||
}
|
||||
|
||||
const user: BackstageIdentityResponse = {
|
||||
token,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: decoded.payload.sub,
|
||||
ownershipEntityRefs: decoded.payload.ent
|
||||
? (decoded.payload.ent as string[])
|
||||
: [],
|
||||
},
|
||||
};
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the last keystore refresh is stale, update the keystore URL to the latest
|
||||
*/
|
||||
private async refreshKeyStore(rawJwtToken: string): Promise<void> {
|
||||
const payload = await decodeJwt(rawJwtToken);
|
||||
const header = await decodeProtectedHeader(rawJwtToken);
|
||||
|
||||
// Refresh public keys if needed
|
||||
let keyStoreHasKey;
|
||||
try {
|
||||
if (this.keyStore) {
|
||||
// Check if the key is present in the keystore
|
||||
const [_, rawPayload, rawSignature] = rawJwtToken.split('.');
|
||||
keyStoreHasKey = await this.keyStore(header, {
|
||||
payload: rawPayload,
|
||||
signature: rawSignature,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
keyStoreHasKey = false;
|
||||
}
|
||||
// Refresh public key URL if needed
|
||||
// Add a small margin in case clocks are out of sync
|
||||
const issuedAfterLastRefresh =
|
||||
payload?.iat && payload.iat > this.keyStoreUpdated - CLOCK_MARGIN_S;
|
||||
if (!this.keyStore || (!keyStoreHasKey && issuedAfterLastRefresh)) {
|
||||
const url = await this.discovery.getBaseUrl('auth');
|
||||
const endpoint = new URL(`${url}/.well-known/jwks.json`);
|
||||
this.keyStore = createRemoteJWKSet(endpoint);
|
||||
this.keyStoreUpdated = Date.now() / 1000;
|
||||
}
|
||||
return await this.defaultIdentityClient.authenticate(token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,13 @@
|
||||
*/
|
||||
|
||||
export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader';
|
||||
export { DefaultIdentityClient } from './DefaultIdentityClient';
|
||||
export { IdentityClient } from './IdentityClient';
|
||||
export type { IdentityClientOptions } from './IdentityClient';
|
||||
export type { IdentityApi } from './IdentityApi';
|
||||
export type { IdentityClientOptions } from './DefaultIdentityClient';
|
||||
export type {
|
||||
BackstageIdentityResponse,
|
||||
BackstageSignInResult,
|
||||
BackstageUserIdentity,
|
||||
IdentityApiGetIdentityRequest,
|
||||
} from './types';
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* A representation of a successful Backstage sign-in.
|
||||
*
|
||||
@@ -29,6 +31,15 @@ export interface BackstageSignInResult {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options to request the identity from a Backstage backend request
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type IdentityApiGetIdentityRequest = {
|
||||
request: Request<unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response object containing the {@link BackstageUserIdentity} and the token
|
||||
* from the authentication provider.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
```ts
|
||||
import express from 'express';
|
||||
import { IdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
// @public
|
||||
@@ -13,7 +13,7 @@ export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
// @public
|
||||
export interface RouterOptions {
|
||||
// (undocumented)
|
||||
identity: IdentityClient;
|
||||
identity: IdentityApi;
|
||||
// (undocumented)
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { IdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('createRouter', () => {
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
identity: {} as IdentityClient,
|
||||
identity: {} as DefaultIdentityClient,
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
@@ -18,12 +18,9 @@ import { errorHandler } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
IdentityClient,
|
||||
getBearerTokenFromAuthorizationHeader,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { add, getAll, update } from './todos';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
|
||||
/**
|
||||
* Dependencies of the todo-list router
|
||||
@@ -32,7 +29,7 @@ import { InputError } from '@backstage/errors';
|
||||
*/
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
identity: IdentityClient;
|
||||
identity: IdentityApi;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,12 +59,9 @@ export async function createRouter(
|
||||
});
|
||||
|
||||
router.post('/todos', async (req, res) => {
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.header('authorization'),
|
||||
);
|
||||
let author: string | undefined = undefined;
|
||||
|
||||
const user = token ? await identity.authenticate(token) : undefined;
|
||||
const user = await identity.getIdentity({ request: req });
|
||||
author = user?.identity.userEntityRef;
|
||||
|
||||
if (!isTodoCreateRequest(req.body)) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
loadBackendConfig,
|
||||
SingleHostDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { IdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
@@ -39,7 +39,7 @@ export async function startStandaloneServer(
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
const router = await createRouter({
|
||||
logger,
|
||||
identity: IdentityClient.create({
|
||||
identity: DefaultIdentityClient.create({
|
||||
discovery,
|
||||
issuer: await discovery.getExternalBaseUrl('auth'),
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
```ts
|
||||
import { Config } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import { IdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
import { Logger } from 'winston';
|
||||
import { PermissionPolicy } from '@backstage/plugin-permission-node';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
@@ -20,7 +20,7 @@ export interface RouterOptions {
|
||||
// (undocumented)
|
||||
discovery: PluginEndpointDiscovery;
|
||||
// (undocumented)
|
||||
identity: IdentityClient;
|
||||
identity: IdentityApi;
|
||||
// (undocumented)
|
||||
logger: Logger;
|
||||
// (undocumented)
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { IdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
ApplyConditionsRequestEntry,
|
||||
@@ -71,17 +70,23 @@ describe('createRouter', () => {
|
||||
getExternalBaseUrl: jest.fn(),
|
||||
},
|
||||
identity: {
|
||||
authenticate: jest.fn(token => {
|
||||
getIdentity: jest.fn(({ request: req }) => {
|
||||
const token = req.headers.authorization?.replace(/^Bearer[ ]+/, '');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No token supplied!');
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
id: 'test-user',
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'test-user',
|
||||
ownershipEntityRefs: ['blah'],
|
||||
},
|
||||
token,
|
||||
});
|
||||
}),
|
||||
} as unknown as IdentityClient,
|
||||
},
|
||||
policy,
|
||||
});
|
||||
|
||||
@@ -184,7 +189,14 @@ describe('createRouter', () => {
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
{ id: 'test-user', token: 'test-token' },
|
||||
{
|
||||
token: 'test-token',
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'test-user',
|
||||
ownershipEntityRefs: ['blah'],
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
items: [{ id: '123', result: AuthorizeResult.ALLOW }],
|
||||
|
||||
@@ -24,9 +24,8 @@ import {
|
||||
} from '@backstage/backend-common';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import {
|
||||
getBearerTokenFromAuthorizationHeader,
|
||||
BackstageIdentityResponse,
|
||||
IdentityClient,
|
||||
IdentityApi,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
@@ -96,7 +95,7 @@ export interface RouterOptions {
|
||||
logger: Logger;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
policy: PermissionPolicy;
|
||||
identity: IdentityClient;
|
||||
identity: IdentityApi;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
@@ -189,10 +188,7 @@ export async function createRouter(
|
||||
req: Request<EvaluatePermissionRequestBatch>,
|
||||
res: Response<EvaluatePermissionResponseBatch>,
|
||||
) => {
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.header('authorization'),
|
||||
);
|
||||
const user = token ? await identity.authenticate(token) : undefined;
|
||||
const user = await identity.getIdentity({ request: req });
|
||||
|
||||
const parseResult = evaluatePermissionRequestBatchSchema.safeParse(
|
||||
req.body,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createPullRequest } from 'octokit-plugin-create-pull-request';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import express from 'express';
|
||||
import { GithubCredentialsProvider } from '@backstage/integration';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Knex } from 'knex';
|
||||
@@ -535,6 +536,8 @@ export interface RouterOptions {
|
||||
// (undocumented)
|
||||
database: PluginDatabaseManager;
|
||||
// (undocumented)
|
||||
identity?: IdentityApi;
|
||||
// (undocumented)
|
||||
logger: Logger;
|
||||
// (undocumented)
|
||||
reader: UrlReader;
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@backstage/config": "^1.0.1",
|
||||
"@backstage/errors": "^1.1.0",
|
||||
"@backstage/integration": "^1.3.1-next.0",
|
||||
"@backstage/plugin-auth-node": "^0.2.5-next.1",
|
||||
"@backstage/plugin-catalog-backend": "^1.4.0-next.1",
|
||||
"@backstage/plugin-catalog-node": "^1.0.2-next.0",
|
||||
"@backstage/plugin-scaffolder-common": "^1.2.0-next.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ import {
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config, JsonObject } from '@backstage/config';
|
||||
import { Config, JsonObject, JsonValue } from '@backstage/config';
|
||||
import { InputError, NotFoundError, stringifyError } from '@backstage/errors';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import {
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
TemplateEntityV1beta3,
|
||||
templateEntityV1beta3Validator,
|
||||
} from '@backstage/plugin-scaffolder-common';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { validate } from 'jsonschema';
|
||||
@@ -48,6 +47,10 @@ import {
|
||||
import { createDryRunner } from '../scaffolder/dryrun';
|
||||
import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker';
|
||||
import { findTemplate, getEntityBaseUrl, getWorkingDirectory } from './helpers';
|
||||
import {
|
||||
IdentityApi,
|
||||
IdentityApiGetIdentityRequest,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
|
||||
/**
|
||||
* RouterOptions
|
||||
@@ -60,16 +63,81 @@ export interface RouterOptions {
|
||||
reader: UrlReader;
|
||||
database: PluginDatabaseManager;
|
||||
catalogClient: CatalogApi;
|
||||
|
||||
actions?: TemplateAction<any>[];
|
||||
taskWorkers?: number;
|
||||
taskBroker?: TaskBroker;
|
||||
additionalTemplateFilters?: Record<string, TemplateFilter>;
|
||||
identity?: IdentityApi;
|
||||
}
|
||||
|
||||
function isSupportedTemplate(entity: TemplateEntityV1beta3) {
|
||||
return entity.apiVersion === 'scaffolder.backstage.io/v1beta3';
|
||||
}
|
||||
|
||||
/*
|
||||
* @deprecated This function remains as the DefaultIdentityClient behaves slightly differently to the pre-existing
|
||||
* scaffolder behaviour. Specifically if the token fails to parse, the DefaultIdentityClient will raise an error.
|
||||
* The scaffolder did not raise an error in this case. As such we chose to allow it to behave as it did previously
|
||||
* until someone explicitly passes an IdentityApi. When we have reasonable confidence that most backstage deployments
|
||||
* are using the IdentityApi, we can remove this function.
|
||||
*/
|
||||
function buildDefaultIdentityClient({
|
||||
logger,
|
||||
}: {
|
||||
logger: Logger;
|
||||
}): IdentityApi {
|
||||
return {
|
||||
getIdentity: async ({ request }: IdentityApiGetIdentityRequest) => {
|
||||
const header = request.headers.authorization;
|
||||
|
||||
if (!header) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = header.match(/^Bearer\s(\S+\.\S+\.\S+)$/i)?.[1];
|
||||
if (!token) {
|
||||
throw new TypeError('Expected Bearer with JWT');
|
||||
}
|
||||
|
||||
const [_header, rawPayload, _signature] = token.split('.');
|
||||
const payload: JsonValue = JSON.parse(
|
||||
Buffer.from(rawPayload, 'base64').toString(),
|
||||
);
|
||||
|
||||
if (
|
||||
typeof payload !== 'object' ||
|
||||
payload === null ||
|
||||
Array.isArray(payload)
|
||||
) {
|
||||
throw new TypeError('Malformed JWT payload');
|
||||
}
|
||||
|
||||
const sub = payload.sub;
|
||||
if (typeof sub !== 'string') {
|
||||
throw new TypeError('Expected string sub claim');
|
||||
}
|
||||
|
||||
// Check that it's a valid ref, otherwise this will throw.
|
||||
parseEntityRef(sub);
|
||||
|
||||
return {
|
||||
identity: {
|
||||
userEntityRef: sub,
|
||||
ownershipEntityRefs: [],
|
||||
type: 'user',
|
||||
},
|
||||
token,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error(`Invalid authorization header: ${stringifyError(e)}`);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A method to create a router for the scaffolder backend plugin.
|
||||
* @public
|
||||
@@ -92,6 +160,10 @@ export async function createRouter(
|
||||
} = options;
|
||||
|
||||
const logger = parentLogger.child({ plugin: 'scaffolder' });
|
||||
|
||||
const identity: IdentityApi =
|
||||
options.identity || buildDefaultIdentityClient({ logger });
|
||||
|
||||
const workingDirectory = await getWorkingDirectory(config, logger);
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
let taskBroker: TaskBroker;
|
||||
@@ -144,10 +216,12 @@ export async function createRouter(
|
||||
'/v2/templates/:namespace/:kind/:name/parameter-schema',
|
||||
async (req, res) => {
|
||||
const { namespace, kind, name } = req.params;
|
||||
const { token } = parseBearerToken({
|
||||
header: req.headers.authorization,
|
||||
logger,
|
||||
|
||||
const userIdentity = await identity.getIdentity({
|
||||
request: req,
|
||||
});
|
||||
const token = userIdentity?.token;
|
||||
|
||||
const template = await findTemplate({
|
||||
catalogApi: catalogClient,
|
||||
entityRef: { kind, namespace, name },
|
||||
@@ -188,10 +262,12 @@ export async function createRouter(
|
||||
const { kind, namespace, name } = parseEntityRef(templateRef, {
|
||||
defaultKind: 'template',
|
||||
});
|
||||
const { token, entityRef: userEntityRef } = parseBearerToken({
|
||||
header: req.headers.authorization,
|
||||
logger,
|
||||
|
||||
const callerIdentity = await identity.getIdentity({
|
||||
request: req,
|
||||
});
|
||||
const token = callerIdentity?.token;
|
||||
const userEntityRef = callerIdentity?.identity.userEntityRef;
|
||||
|
||||
const userEntity = userEntityRef
|
||||
? await catalogClient.getEntityByRef(userEntityRef, { token })
|
||||
@@ -394,10 +470,11 @@ export async function createRouter(
|
||||
throw new InputError('Input template is not a template');
|
||||
}
|
||||
|
||||
const { token } = parseBearerToken({
|
||||
header: req.headers.authorization,
|
||||
logger,
|
||||
});
|
||||
const token = (
|
||||
await identity.getIdentity({
|
||||
request: req,
|
||||
})
|
||||
)?.token;
|
||||
|
||||
for (const parameters of [template.spec.parameters ?? []].flat()) {
|
||||
const result = validate(body.values, parameters);
|
||||
@@ -447,51 +524,3 @@ export async function createRouter(
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function parseBearerToken({
|
||||
header,
|
||||
logger,
|
||||
}: {
|
||||
header?: string;
|
||||
logger: Logger;
|
||||
}): {
|
||||
token?: string;
|
||||
entityRef?: string;
|
||||
} {
|
||||
if (!header) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const token = header.match(/^Bearer\s(\S+\.\S+\.\S+)$/i)?.[1];
|
||||
if (!token) {
|
||||
throw new TypeError('Expected Bearer with JWT');
|
||||
}
|
||||
|
||||
const [_header, rawPayload, _signature] = token.split('.');
|
||||
const payload: JsonValue = JSON.parse(
|
||||
Buffer.from(rawPayload, 'base64').toString(),
|
||||
);
|
||||
|
||||
if (
|
||||
typeof payload !== 'object' ||
|
||||
payload === null ||
|
||||
Array.isArray(payload)
|
||||
) {
|
||||
throw new TypeError('Malformed JWT payload');
|
||||
}
|
||||
|
||||
const sub = payload.sub;
|
||||
if (typeof sub !== 'string') {
|
||||
throw new TypeError('Expected string sub claim');
|
||||
}
|
||||
|
||||
// Check that it's a valid ref, otherwise this will throw.
|
||||
parseEntityRef(sub);
|
||||
|
||||
return { entityRef: sub, token };
|
||||
} catch (e) {
|
||||
logger.error(`Invalid authorization header: ${stringifyError(e)}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4006,6 +4006,8 @@ __metadata:
|
||||
"@backstage/cli": ^0.19.0-next.1
|
||||
"@backstage/config": ^1.0.1
|
||||
"@backstage/errors": ^1.1.0
|
||||
"@types/express": "*"
|
||||
express: ^4.17.1
|
||||
jose: ^4.6.0
|
||||
lodash: ^4.17.21
|
||||
msw: ^0.46.0
|
||||
@@ -6392,6 +6394,7 @@ __metadata:
|
||||
"@backstage/config": ^1.0.1
|
||||
"@backstage/errors": ^1.1.0
|
||||
"@backstage/integration": ^1.3.1-next.0
|
||||
"@backstage/plugin-auth-node": ^0.2.5-next.1
|
||||
"@backstage/plugin-catalog-backend": ^1.4.0-next.1
|
||||
"@backstage/plugin-catalog-node": ^1.0.2-next.0
|
||||
"@backstage/plugin-scaffolder-common": ^1.2.0-next.0
|
||||
|
||||
Reference in New Issue
Block a user