Merge pull request #12542 from RoadieHQ/identity-api-client-interface

Identity api client interface
This commit is contained in:
Patrik Oldsberg
2022-09-01 12:44:19 +02:00
committed by GitHub
33 changed files with 1969 additions and 680 deletions
+29 -1
View File
@@ -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[];
};
```
+2
View File
@@ -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;
}
}
}
+37
View File
@@ -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>;
}
+14 -111
View File
@@ -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);
}
}
+4 -1
View File
@@ -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';
+11
View File
@@ -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'),
}),
+2 -2
View File
@@ -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,
+3
View File
@@ -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;
+1
View File
@@ -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 {};
}
}