diff --git a/.changeset/famous-hats-decide.md b/.changeset/famous-hats-decide.md new file mode 100644 index 0000000000..e6f3a4bd5a --- /dev/null +++ b/.changeset/famous-hats-decide.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-auth-node': minor +--- + +Added this package, to hold shared types and functionality that other backend +packages need to import. diff --git a/.changeset/giant-nails-grow.md b/.changeset/giant-nails-grow.md new file mode 100644 index 0000000000..7ea36968b9 --- /dev/null +++ b/.changeset/giant-nails-grow.md @@ -0,0 +1,51 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +The following breaking changes were made, which may imply specifically needing +to make small adjustments in your custom auth providers. + +- **BREAKING**: Moved `IdentityClient`, `BackstageSignInResult`, + `BackstageIdentityResponse`, and `BackstageUserIdentity` to + `@backstage/plugin-auth-node`. +- **BREAKING**: Removed deprecated type `BackstageIdentity`, please use + `BackstageSignInResult` from `@backstage/plugin-auth-node` instead. + +While moving over, `IdentityClient` was also changed in the following ways: + +- **BREAKING**: Made `IdentityClient.listPublicKeys` private. It was only used + in tests, and should not be part of the API surface of that class. +- **BREAKING**: Removed the static `IdentityClient.getBearerToken`. It is now + replaced by `getBearerTokenFromAuthorizationHeader` from + `@backstage/plugin-auth-node`. +- **BREAKING**: Removed the constructor. Please use the `IdentityClient.create` + static method instead. + +Since the `IdentityClient` interface is marked as experimental, this is a +breaking change without a deprecation period. + +In your auth providers, you may need to update your imports and usages as +follows (example code; yours may be slightly different): + +````diff +-import { IdentityClient } from '@backstage/plugin-auth-backend'; ++import { ++ IdentityClient, ++ getBearerTokenFromAuthorizationHeader ++} from '@backstage/plugin-auth-node'; + + // ... + +- const identity = new IdentityClient({ ++ const identity = IdentityClient.create({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + });``` + + // ... + + const token = +- IdentityClient.getBearerToken(req.headers.authorization) || ++ getBearerTokenFromAuthorizationHeader(req.headers.authorization) || + req.cookies['token']; +```` diff --git a/.changeset/little-onions-fly.md b/.changeset/little-onions-fly.md new file mode 100644 index 0000000000..8bff96d866 --- /dev/null +++ b/.changeset/little-onions-fly.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-permission-backend': patch +'@backstage/plugin-search-backend': patch +--- + +Use `getBearerTokenFromAuthorizationHeader` from `@backstage/plugin-auth-node` instead of the deprecated `IdentityClient` method. diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md index 25dd08e054..45ae38b8b7 100644 --- a/contrib/docs/tutorials/authenticate-api-requests.md +++ b/contrib/docs/tutorials/authenticate-api-requests.md @@ -15,7 +15,10 @@ import cookieParser from 'cookie-parser'; import { Request, Response, NextFunction } from 'express'; import { JWT } from 'jose'; import { URL } from 'url'; -import { IdentityClient } from '@backstage/plugin-auth-backend'; +import { + IdentityClient, + getBearerTokenFromAuthorizationHeader, +} from '@backstage/plugin-auth-node'; // ... @@ -44,7 +47,7 @@ async function main() { // ... const discovery = SingleHostDiscovery.fromConfig(config); - const identity = new IdentityClient({ + const identity = IdentityClient.create({ discovery, issuer: await discovery.getExternalBaseUrl('auth'), }); @@ -58,7 +61,7 @@ async function main() { ) => { try { const token = - IdentityClient.getBearerToken(req.headers.authorization) || + getBearerTokenFromAuthorizationHeader(req.headers.authorization) || req.cookies['token']; req.user = await identity.authenticate(token); if (!req.headers.authorization) { @@ -80,7 +83,7 @@ async function main() { const apiRouter = Router(); apiRouter.use(cookieParser()); - // The auth route must be publically available as it is used during login + // The auth route must be publicly available as it is used during login apiRouter.use('/auth', await auth(authEnv)); // Add a simple endpoint to be used when setting a token cookie apiRouter.use('/cookie', authMiddleware, (_req, res) => { diff --git a/packages/backend/package.json b/packages/backend/package.json index c2e8711757..1c709195b0 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -32,6 +32,7 @@ "@backstage/integration": "^0.7.2", "@backstage/plugin-app-backend": "^0.3.24-next.0", "@backstage/plugin-auth-backend": "^0.10.0-next.0", + "@backstage/plugin-auth-node": "^0.0.0", "@backstage/plugin-azure-devops-backend": "^0.3.3-next.0", "@backstage/plugin-badges-backend": "^0.1.18-next.0", "@backstage/plugin-catalog-backend": "^0.21.3-next.0", diff --git a/packages/backend/src/plugins/permission.ts b/packages/backend/src/plugins/permission.ts index 71a9b90311..276d16ff97 100644 --- a/packages/backend/src/plugins/permission.ts +++ b/packages/backend/src/plugins/permission.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IdentityClient } from '@backstage/plugin-auth-backend'; +import { IdentityClient } from '@backstage/plugin-auth-node'; import { createRouter } from '@backstage/plugin-permission-backend'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { @@ -40,7 +40,7 @@ export default async function createPlugin( logger, discovery, policy: new AllowAllPermissionPolicy(), - identity: new IdentityClient({ + identity: IdentityClient.create({ discovery, issuer: await discovery.getExternalBaseUrl('auth'), }), diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index aaacd05815..416da59b46 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -5,12 +5,12 @@ ```ts /// +import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; +import { BackstageSignInResult } from '@backstage/plugin-auth-node'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; -import { Entity } from '@backstage/catalog-model'; import express from 'express'; import { JsonValue } from '@backstage/types'; -import { JSONWebKey } from 'jose'; import { Logger as Logger_2 } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -128,30 +128,6 @@ export type AwsAlbProviderOptions = { }; }; -// @public @deprecated -export type BackstageIdentity = BackstageSignInResult; - -// @public -export interface BackstageIdentityResponse extends BackstageSignInResult { - identity: BackstageUserIdentity; -} - -// @public -export interface BackstageSignInResult { - // @deprecated - entity?: Entity; - // @deprecated - id: string; - token: string; -} - -// @public -export type BackstageUserIdentity = { - type: 'user'; - userEntityRef: string; - ownershipEntityRefs: string[]; -}; - // Warning: (ae-missing-release-tag) "BitbucketOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -423,20 +399,6 @@ export type GoogleProviderOptions = { }; }; -// Warning: (ae-missing-release-tag) "IdentityClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export class IdentityClient { - constructor(options: { discovery: PluginEndpointDiscovery; issuer: string }); - authenticate(token: string | undefined): Promise; - static getBearerToken( - authorizationHeader: string | undefined, - ): string | undefined; - listPublicKeys(): Promise<{ - keys: JSONWebKey[]; - }>; -} - // Warning: (ae-missing-release-tag) "microsoftEmailSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 3dab33d8da..4f2ef4e76c 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -30,6 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/plugin-auth-node": "^0.0.0", "@backstage/backend-common": "^0.10.7-next.0", "@backstage/catalog-client": "^0.5.5", "@backstage/catalog-model": "^0.9.10", diff --git a/plugins/auth-backend/src/identity/index.ts b/plugins/auth-backend/src/identity/index.ts index 73858d7e07..cb2f369668 100644 --- a/plugins/auth-backend/src/identity/index.ts +++ b/plugins/auth-backend/src/identity/index.ts @@ -15,7 +15,6 @@ */ export { createOidcRouter } from './router'; -export { IdentityClient } from './IdentityClient'; export { TokenFactory } from './TokenFactory'; export { DatabaseKeyStore } from './DatabaseKeyStore'; export { MemoryKeyStore } from './MemoryKeyStore'; diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index 66c1b42dd9..d0cade087e 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -21,7 +21,6 @@ */ export * from './service/router'; -export { IdentityClient } from './identity'; export type { TokenIssuer } from './identity'; export * from './providers'; diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index 82a43c7c64..75a3605483 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -23,10 +23,12 @@ import { stringifyEntityRef, } from '@backstage/catalog-model'; import { - AuthProviderRouteHandlers, - AuthProviderConfig, BackstageIdentityResponse, BackstageSignInResult, +} from '@backstage/plugin-auth-node'; +import { + AuthProviderRouteHandlers, + AuthProviderConfig, } from '../../providers/types'; import { AuthenticationError, diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index 6973e99569..6e5fe19859 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -16,11 +16,8 @@ import express from 'express'; import { Profile as PassportProfile } from 'passport'; -import { - RedirectInfo, - BackstageSignInResult, - ProfileInfo, -} from '../../providers/types'; +import { BackstageSignInResult } from '@backstage/plugin-auth-node'; +import { RedirectInfo, ProfileInfo } from '../../providers/types'; /** * Common options for passport.js-based OAuth providers diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 1207254e11..78814ae7e6 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -48,13 +48,6 @@ export type { // These types are needed for a postMessage from the login pop-up // to the frontend -export type { - AuthResponse, - BackstageIdentity, - BackstageUserIdentity, - BackstageIdentityResponse, - BackstageSignInResult, - ProfileInfo, -} from './types'; +export type { AuthResponse, ProfileInfo } from './types'; export { prepareBackstageIdentityResponse } from './prepareBackstageIdentityResponse'; diff --git a/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts b/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts index 24bb4d0362..8c4dcc3249 100644 --- a/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts @@ -17,6 +17,7 @@ import express from 'express'; import { Logger } from 'winston'; import { AuthenticationError } from '@backstage/errors'; +import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { AuthHandler, SignInResolver, @@ -26,7 +27,6 @@ import { } from '../types'; import { CatalogIdentityClient } from '../../lib/catalog'; import { JWT } from 'jose'; -import { IdentityClient } from '../../identity'; import { TokenIssuer } from '../../identity/types'; import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; @@ -156,7 +156,7 @@ export class Oauth2ProxyAuthProvider private getResult(req: express.Request): OAuth2ProxyResult { const authHeader = req.header(OAUTH2_PROXY_JWT_HEADER); - const jwt = IdentityClient.getBearerToken(authHeader); + const jwt = getBearerTokenFromAuthorizationHeader(authHeader); if (!jwt) { throw new AuthenticationError( diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index e77812e750..9ccdf25f74 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -234,7 +234,7 @@ export const oAuth2DefaultSignInResolver: SignInResolver< * can be passed while creating a OIDC provider. * * authHandler : called after sign in was successful, a new object must be returned which includes a profile - * signInResolver: called after sign in was successful, expects to return a new {@link BackstageSignInResult} + * signInResolver: called after sign in was successful, expects to return a new {@link @backstage/plugin-auth-node#BackstageSignInResult} * * Both options are optional. There is fallback for authHandler where the default handler expect an e-mail explicitly * otherwise it throws an error diff --git a/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts b/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts index bb99cb3487..f3b234c08e 100644 --- a/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts +++ b/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts @@ -19,7 +19,10 @@ import { parseEntityRef, stringifyEntityRef, } from '@backstage/catalog-model'; -import { BackstageIdentityResponse, BackstageSignInResult } from './types'; +import { + BackstageIdentityResponse, + BackstageSignInResult, +} from '@backstage/plugin-auth-node'; function parseJwtPayload(token: string) { const [_header, payload, _signature] = token.split('.'); @@ -28,7 +31,7 @@ function parseJwtPayload(token: string) { /** * Parses a Backstage-issued token and decorates the - * {@link BackstageIdentityResponse} with identity information sourced from the + * {@link @backstage/plugin-auth-node#BackstageIdentityResponse} with identity information sourced from the * token. * * @public diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index f3a1a95919..5bd52f0c94 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -19,8 +19,11 @@ import { TokenManager, } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; -import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; +import { + BackstageIdentityResponse, + BackstageSignInResult, +} from '@backstage/plugin-auth-node'; import express from 'express'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity/types'; @@ -161,85 +164,6 @@ export type AuthResponse = { backstageIdentity?: BackstageIdentityResponse; }; -/** - * User identity information within Backstage. - * - * @public - */ -export type BackstageUserIdentity = { - /** - * The type of identity that this structure represents. In the frontend app - * this will currently always be 'user'. - */ - type: 'user'; - - /** - * The entityRef of the user in the catalog. - * For example User:default/sandra - */ - userEntityRef: string; - - /** - * The user and group entities that the user claims ownership through - */ - ownershipEntityRefs: string[]; -}; - -/** - * A representation of a successful Backstage sign-in. - * - * Compared to the {@link BackstageIdentityResponse} this type omits - * the decoded identity information embedded in the token. - * - * @public - */ -export interface BackstageSignInResult { - /** - * An opaque ID that uniquely identifies the user within Backstage. - * - * This is typically the same as the user entity `metadata.name`. - * - * @deprecated Use the `identity` field instead - */ - id: string; - - /** - * The entity that the user is represented by within Backstage. - * - * This entity may or may not exist within the Catalog, and it can be used - * to read and store additional metadata about the user. - * - * @deprecated Use the `identity` field instead. - */ - entity?: Entity; - - /** - * The token used to authenticate the user within Backstage. - */ - token: string; -} - -/** - * The old exported symbol for {@link BackstageSignInResult}. - * - * @public - * @deprecated Use the {@link BackstageSignInResult} instead. - */ -export type BackstageIdentity = BackstageSignInResult; - -/** - * Response object containing the {@link BackstageUserIdentity} and the token - * from the authentication provider. - * - * @public - */ -export interface BackstageIdentityResponse extends BackstageSignInResult { - /** - * A plaintext description of the identity that is encapsulated within the token. - */ - identity: BackstageUserIdentity; -} - /** * Used to display login information to user, i.e. sidebar popup. * @@ -286,7 +210,7 @@ export type SignInInfo = { /** * Describes the function which handles the result of a successful - * authentication. Must return a valid {@link BackstageSignInResult}. + * authentication. Must return a valid {@link @backstage/plugin-auth-node#BackstageSignInResult}. * * @public */ diff --git a/plugins/auth-node/.eslintrc.js b/plugins/auth-node/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/auth-node/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/auth-node/README.md b/plugins/auth-node/README.md new file mode 100644 index 0000000000..3558e031b2 --- /dev/null +++ b/plugins/auth-node/README.md @@ -0,0 +1,3 @@ +# Auth Node + +Common functionality and types for the Backstage `auth` plugin. diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md new file mode 100644 index 0000000000..7840fc7d74 --- /dev/null +++ b/plugins/auth-node/api-report.md @@ -0,0 +1,43 @@ +## API Report File for "@backstage/plugin-auth-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Entity } from '@backstage/catalog-model'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +// @public +export interface BackstageIdentityResponse extends BackstageSignInResult { + identity: BackstageUserIdentity; +} + +// @public +export interface BackstageSignInResult { + // @deprecated + entity?: Entity; + // @deprecated + id: string; + token: string; +} + +// @public +export type BackstageUserIdentity = { + type: 'user'; + userEntityRef: string; + ownershipEntityRefs: string[]; +}; + +// @public +export function getBearerTokenFromAuthorizationHeader( + authorizationHeader: unknown, +): string | undefined; + +// @public +export class IdentityClient { + authenticate(token: string | undefined): Promise; + static create(options: { + discovery: PluginEndpointDiscovery; + issuer: string; + }): IdentityClient; +} +``` diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json new file mode 100644 index 0000000000..cec1006ef0 --- /dev/null +++ b/plugins/auth-node/package.json @@ -0,0 +1,38 @@ +{ + "name": "@backstage/plugin-auth-node", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.10.7-next.0", + "@backstage/catalog-model": "^0.9.10", + "@backstage/config": "^0.1.13", + "@backstage/errors": "^0.2.0", + "jose": "^1.27.1", + "node-fetch": "^2.6.1", + "winston": "^3.2.1" + }, + "devDependencies": { + "@backstage/cli": "^0.13.2-next.0", + "msw": "^0.35.0", + "uuid": "^8.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/auth-backend/src/identity/IdentityClient.test.ts b/plugins/auth-node/src/IdentityClient.test.ts similarity index 76% rename from plugins/auth-backend/src/identity/IdentityClient.test.ts rename to plugins/auth-node/src/IdentityClient.test.ts index 9f5e1ce489..72ef7f2a57 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.test.ts +++ b/plugins/auth-node/src/IdentityClient.test.ts @@ -14,19 +14,61 @@ * limitations under the License. */ -import { JWT, JSONWebKey } from 'jose'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { JSONWebKey, JWK, JWS, JWT } from 'jose'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { - getVoidLogger, - PluginEndpointDiscovery, -} from '@backstage/backend-common'; +import { v4 as uuid } from 'uuid'; import { IdentityClient } from './IdentityClient'; -import { MemoryKeyStore } from './MemoryKeyStore'; -import { TokenFactory } from './TokenFactory'; -import { KeyStore } from './types'; -const logger = getVoidLogger(); +interface AnyJWK extends Record { + use: 'sig'; + alg: string; + kid: string; + kty: string; +} + +// Simplified copy of TokenFactory in @backstage/plugin-auth-backend +class FakeTokenFactory { + private readonly keys = new Array(); + + constructor( + private readonly options: { + issuer: string; + keyDurationSeconds: number; + }, + ) {} + + async issueToken(params: { + claims: { + sub: string; + ent?: string[]; + }; + }): Promise { + const key = await JWK.generate('EC', 'P-256', { + use: 'sig', + kid: uuid(), + alg: 'ES256', + }); + this.keys.push(key.toJWK(false) as unknown 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 JWS.sign({ iss, sub, aud, iat, exp, ent }, key, { + alg: key.alg, + kid: key.kid, + }); + } + + async listPublicKeys(): Promise<{ keys: AnyJWK[] }> { + return { keys: this.keys }; + } +} function jwtKid(jwt: string): string { const { header } = JWT.decode(jwt, { complete: true }) as { @@ -48,8 +90,7 @@ const discovery: PluginEndpointDiscovery = { describe('IdentityClient', () => { let client: IdentityClient; - let factory: TokenFactory; - let keyStore: KeyStore; + let factory: FakeTokenFactory; const keyDurationSeconds = 5; beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); @@ -57,13 +98,10 @@ describe('IdentityClient', () => { afterEach(() => server.resetHandlers()); beforeEach(() => { - client = new IdentityClient({ discovery, issuer: mockBaseUrl }); - keyStore = new MemoryKeyStore(); - factory = new TokenFactory({ + client = IdentityClient.create({ discovery, issuer: mockBaseUrl }); + factory = new FakeTokenFactory({ issuer: mockBaseUrl, - keyStore: keyStore, keyDurationSeconds, - logger, }); }); @@ -83,7 +121,7 @@ describe('IdentityClient', () => { it('should use the correct endpoint', async () => { await factory.issueToken({ claims: { sub: 'foo' } }); const keys = await factory.listPublicKeys(); - const response = await client.listPublicKeys(); + const response = await (client as any).listPublicKeys(); expect(response).toEqual(keys); }); @@ -108,11 +146,9 @@ describe('IdentityClient', () => { }); it('should throw on incorrect issuer', async () => { - const hackerFactory = new TokenFactory({ + const hackerFactory = new FakeTokenFactory({ issuer: 'hacker', - keyStore, keyDurationSeconds, - logger, }); return expect(async () => { const token = await hackerFactory.issueToken({ @@ -137,11 +173,9 @@ describe('IdentityClient', () => { }); it('should throw on incorrect signing key', async () => { - const hackerFactory = new TokenFactory({ + const hackerFactory = new FakeTokenFactory({ issuer: mockBaseUrl, - keyStore: new MemoryKeyStore(), keyDurationSeconds, - logger, }); return expect(async () => { const token = await hackerFactory.issueToken({ @@ -199,38 +233,6 @@ describe('IdentityClient', () => { }); }); - describe('getBearerToken', () => { - it('should return undefined on undefined input', async () => { - const token = IdentityClient.getBearerToken(undefined); - expect(token).toBeUndefined(); - }); - - it('should return undefined on malformed input', async () => { - const token = IdentityClient.getBearerToken('malformed'); - expect(token).toBeUndefined(); - }); - - it('should return undefined on unexpected scheme', async () => { - const token = IdentityClient.getBearerToken('Basic token'); - expect(token).toBeUndefined(); - }); - - it('should return Bearer token', async () => { - const token = IdentityClient.getBearerToken('Bearer token'); - expect(token).toEqual('token'); - }); - - it('should return Bearer token despite extra space', async () => { - const token = IdentityClient.getBearerToken('Bearer \n token '); - expect(token).toEqual('token'); - }); - - it('should return Bearer token despite unconventionial case', async () => { - const token = IdentityClient.getBearerToken('bEARER token'); - expect(token).toEqual('token'); - }); - }); - describe('listPublicKeys', () => { const defaultServiceResponse: { keys: JSONWebKey[]; @@ -257,7 +259,7 @@ describe('IdentityClient', () => { }); it('should use the correct endpoint', async () => { - const response = await client.listPublicKeys(); + const response = await (client as any).listPublicKeys(); expect(response).toEqual(defaultServiceResponse); }); }); diff --git a/plugins/auth-backend/src/identity/IdentityClient.ts b/plugins/auth-node/src/IdentityClient.ts similarity index 85% rename from plugins/auth-backend/src/identity/IdentityClient.ts rename to plugins/auth-node/src/IdentityClient.ts index 552d231e1f..d8e841bf75 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.ts +++ b/plugins/auth-node/src/IdentityClient.ts @@ -14,19 +14,20 @@ * limitations under the License. */ -import fetch from 'node-fetch'; -import { JWK, JWT, JWKS, JSONWebKey } from 'jose'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { AuthenticationError } from '@backstage/errors'; -import { BackstageIdentityResponse } from '../providers/types'; +import { JSONWebKey, JWK, JWKS, JWT } from 'jose'; +import fetch from 'node-fetch'; +import { BackstageIdentityResponse } from './types'; const CLOCK_MARGIN_S = 10; /** - * A identity client to interact with auth-backend - * and authenticate backstage identity tokens + * An identity client to interact with auth-backend and authenticate Backstage + * tokens * * @experimental This is not a stable API yet + * @public */ export class IdentityClient { private readonly discovery: PluginEndpointDiscovery; @@ -34,7 +35,20 @@ export class IdentityClient { private keyStore: JWKS.KeyStore; private keyStoreUpdated: number; - constructor(options: { discovery: PluginEndpointDiscovery; issuer: string }) { + /** + * Create a new {@link IdentityClient} instance. + */ + static create(options: { + discovery: PluginEndpointDiscovery; + issuer: string; + }): IdentityClient { + return new IdentityClient(options); + } + + private constructor(options: { + discovery: PluginEndpointDiscovery; + issuer: string; + }) { this.discovery = options.discovery; this.issuer = options.issuer; this.keyStore = new JWKS.KeyStore(); @@ -84,20 +98,6 @@ export class IdentityClient { return user; } - /** - * Parses the given authorization header and returns - * the bearer token, or null if no bearer token is given - */ - static getBearerToken( - authorizationHeader: string | undefined, - ): string | undefined { - if (typeof authorizationHeader !== 'string') { - return undefined; - } - const matches = authorizationHeader.match(/Bearer\s+(\S+)/i); - return matches?.[1]; - } - /** * Returns the public signing key matching the given jwt token, * or null if no matching key was found @@ -125,7 +125,7 @@ export class IdentityClient { /** * Lists public part of keys used to sign Backstage Identity tokens */ - async listPublicKeys(): Promise<{ + private async listPublicKeys(): Promise<{ keys: JSONWebKey[]; }> { const url = `${await this.discovery.getBaseUrl( diff --git a/plugins/auth-node/src/getBearerTokenFromAuthorizationHeader.test.ts b/plugins/auth-node/src/getBearerTokenFromAuthorizationHeader.test.ts new file mode 100644 index 0000000000..0bd7b5ea93 --- /dev/null +++ b/plugins/auth-node/src/getBearerTokenFromAuthorizationHeader.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2022 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 { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader'; + +describe('getBearerToken', () => { + it('should return undefined on bad input', async () => { + expect(getBearerTokenFromAuthorizationHeader(undefined)).toBeUndefined(); + expect(getBearerTokenFromAuthorizationHeader(7)).toBeUndefined(); + expect( + getBearerTokenFromAuthorizationHeader('Bearer \n token'), + ).toBeUndefined(); + expect( + getBearerTokenFromAuthorizationHeader('Bearer token '), + ).toBeUndefined(); + }); + + it('should return undefined on malformed input', async () => { + const token = getBearerTokenFromAuthorizationHeader('malformed'); + expect(token).toBeUndefined(); + }); + + it('should return undefined on unexpected scheme', async () => { + const token = getBearerTokenFromAuthorizationHeader('Basic token'); + expect(token).toBeUndefined(); + }); + + it('should return Bearer token', async () => { + const token = getBearerTokenFromAuthorizationHeader('Bearer token'); + expect(token).toEqual('token'); + }); + + it('should return Bearer token despite unconventional case', async () => { + const token = getBearerTokenFromAuthorizationHeader('bEARER token'); + expect(token).toEqual('token'); + }); +}); diff --git a/plugins/auth-node/src/getBearerTokenFromAuthorizationHeader.ts b/plugins/auth-node/src/getBearerTokenFromAuthorizationHeader.ts new file mode 100644 index 0000000000..8451cd6ab1 --- /dev/null +++ b/plugins/auth-node/src/getBearerTokenFromAuthorizationHeader.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2022 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. + */ + +/** + * Parses the given authorization header and returns the bearer token, or + * undefined if no bearer token is given. + * + * @remarks + * + * This function is explicitly built to tolerate bad inputs safely, so you may + * call it directly with e.g. the output of `req.header('authorization')` + * without first checking that it exists. + * + * @public + */ +export function getBearerTokenFromAuthorizationHeader( + authorizationHeader: unknown, +): string | undefined { + if (typeof authorizationHeader !== 'string') { + return undefined; + } + const matches = authorizationHeader.match(/^Bearer[ ]+(\S+)$/i); + return matches?.[1]; +} diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts new file mode 100644 index 0000000000..5551f2c85d --- /dev/null +++ b/plugins/auth-node/src/index.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +/** + * Common functionality and types for the Backstage auth plugin. + * + * @packageDocumentation + */ + +export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader'; +export { IdentityClient } from './IdentityClient'; +export type { + BackstageIdentityResponse, + BackstageSignInResult, + BackstageUserIdentity, +} from './types'; diff --git a/plugins/auth-node/src/setupTests.ts b/plugins/auth-node/src/setupTests.ts new file mode 100644 index 0000000000..d3232290a7 --- /dev/null +++ b/plugins/auth-node/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export {}; diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts new file mode 100644 index 0000000000..b574c2204d --- /dev/null +++ b/plugins/auth-node/src/types.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2022 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 { Entity } from '@backstage/catalog-model'; + +/** + * A representation of a successful Backstage sign-in. + * + * Compared to the {@link BackstageIdentityResponse} this type omits + * the decoded identity information embedded in the token. + * + * @public + */ +export interface BackstageSignInResult { + /** + * An opaque ID that uniquely identifies the user within Backstage. + * + * This is typically the same as the user entity `metadata.name`. + * + * @deprecated Use the `identity` field instead + */ + id: string; + + /** + * The entity that the user is represented by within Backstage. + * + * This entity may or may not exist within the Catalog, and it can be used + * to read and store additional metadata about the user. + * + * @deprecated Use the `identity` field instead. + */ + entity?: Entity; + + /** + * The token used to authenticate the user within Backstage. + */ + token: string; +} + +/** + * Response object containing the {@link BackstageUserIdentity} and the token + * from the authentication provider. + * + * @public + */ +export interface BackstageIdentityResponse extends BackstageSignInResult { + /** + * A plaintext description of the identity that is encapsulated within the token. + */ + identity: BackstageUserIdentity; +} + +/** + * User identity information within Backstage. + * + * @public + */ +export type BackstageUserIdentity = { + /** + * The type of identity that this structure represents. In the frontend app + * this will currently always be 'user'. + */ + type: 'user'; + + /** + * The entityRef of the user in the catalog. + * For example User:default/sandra + */ + userEntityRef: string; + + /** + * The user and group entities that the user claims ownership through + */ + ownershipEntityRefs: string[]; +}; diff --git a/plugins/permission-backend/api-report.md b/plugins/permission-backend/api-report.md index 4857c8b044..843c9a315c 100644 --- a/plugins/permission-backend/api-report.md +++ b/plugins/permission-backend/api-report.md @@ -4,7 +4,7 @@ ```ts import express from 'express'; -import { IdentityClient } from '@backstage/plugin-auth-backend'; +import { IdentityClient } from '@backstage/plugin-auth-node'; import { Logger as Logger_2 } from 'winston'; import { PermissionPolicy } from '@backstage/plugin-permission-node'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; diff --git a/plugins/permission-backend/package.json b/plugins/permission-backend/package.json index bc6faae17b..01efa7af04 100644 --- a/plugins/permission-backend/package.json +++ b/plugins/permission-backend/package.json @@ -22,7 +22,7 @@ "@backstage/backend-common": "^0.10.7-next.0", "@backstage/config": "^0.1.13", "@backstage/errors": "^0.2.0", - "@backstage/plugin-auth-backend": "^0.10.0-next.0", + "@backstage/plugin-auth-node": "^0.0.0", "@backstage/plugin-permission-common": "^0.4.0", "@backstage/plugin-permission-node": "^0.4.3-next.0", "@types/express": "*", diff --git a/plugins/permission-backend/src/service/router.test.ts b/plugins/permission-backend/src/service/router.test.ts index fd09cca828..498fcb748b 100644 --- a/plugins/permission-backend/src/service/router.test.ts +++ b/plugins/permission-backend/src/service/router.test.ts @@ -17,7 +17,7 @@ import express from 'express'; import request from 'supertest'; import { getVoidLogger } from '@backstage/backend-common'; -import { IdentityClient } from '@backstage/plugin-auth-backend'; +import { IdentityClient } from '@backstage/plugin-auth-node'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { ApplyConditionsRequestEntry, diff --git a/plugins/permission-backend/src/service/router.ts b/plugins/permission-backend/src/service/router.ts index e22aec8129..413f3e6b45 100644 --- a/plugins/permission-backend/src/service/router.ts +++ b/plugins/permission-backend/src/service/router.ts @@ -24,9 +24,10 @@ import { } from '@backstage/backend-common'; import { InputError } from '@backstage/errors'; import { + getBearerTokenFromAuthorizationHeader, BackstageIdentityResponse, IdentityClient, -} from '@backstage/plugin-auth-backend'; +} from '@backstage/plugin-auth-node'; import { AuthorizeResult, AuthorizeDecision, @@ -157,7 +158,9 @@ export async function createRouter( req: Request, res: Response, ) => { - const token = IdentityClient.getBearerToken(req.header('authorization')); + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); const user = token ? await identity.authenticate(token) : undefined; const parseResult = requestSchema.safeParse(req.body); diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 43bb548a06..534ad45110 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -7,7 +7,7 @@ import { AuthorizeDecision } from '@backstage/plugin-permission-common'; import { AuthorizeQuery } from '@backstage/plugin-permission-common'; import { AuthorizeRequestOptions } from '@backstage/plugin-permission-common'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend'; +import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { Config } from '@backstage/config'; import express from 'express'; import { Identified } from '@backstage/plugin-permission-common'; diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json index 6fb58251e3..7977e9057f 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -32,7 +32,7 @@ "@backstage/backend-common": "^0.10.7-next.0", "@backstage/config": "^0.1.13", "@backstage/errors": "^0.2.0", - "@backstage/plugin-auth-backend": "^0.10.0-next.0", + "@backstage/plugin-auth-node": "^0.0.0", "@backstage/plugin-permission-common": "^0.4.0", "@types/express": "^4.17.6", "express": "^4.17.1", diff --git a/plugins/permission-node/src/policy/types.ts b/plugins/permission-node/src/policy/types.ts index 2e344d6d96..19b12b8f28 100644 --- a/plugins/permission-node/src/policy/types.ts +++ b/plugins/permission-node/src/policy/types.ts @@ -20,7 +20,7 @@ import { PermissionCondition, PermissionCriteria, } from '@backstage/plugin-permission-common'; -import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend'; +import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; /** * An authorization request to be evaluated by the {@link PermissionPolicy}. diff --git a/plugins/search-backend/package.json b/plugins/search-backend/package.json index ee064851fa..3770dbd875 100644 --- a/plugins/search-backend/package.json +++ b/plugins/search-backend/package.json @@ -24,7 +24,7 @@ "@backstage/config": "^0.1.13", "@backstage/errors": "^0.2.0", "@backstage/search-common": "^0.2.2", - "@backstage/plugin-auth-backend": "^0.10.0-next.0", + "@backstage/plugin-auth-node": "^0.0.0", "@backstage/plugin-permission-common": "^0.4.0-next.0", "@backstage/plugin-permission-node": "^0.4.3-next.0", "@backstage/plugin-search-backend-node": "^0.4.5", diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index 28a3247919..3f25c4f24a 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -22,7 +22,7 @@ import { errorHandler } from '@backstage/backend-common'; import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; import { JsonObject, JsonValue } from '@backstage/types'; -import { IdentityClient } from '@backstage/plugin-auth-backend'; +import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { DocumentTypeInfo, SearchResultSet } from '@backstage/search-common'; import { SearchEngine } from '@backstage/plugin-search-backend-node'; @@ -106,7 +106,9 @@ export async function createRouter( }, pageCursor=${query.pageCursor ?? ''}`, ); - const token = IdentityClient.getBearerToken(req.header('authorization')); + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); try { const resultSet = await engine?.query(query, { token }); diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 808af8754d..f026adbc9b 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -218,6 +218,7 @@ const NO_WARNING_PACKAGES = [ 'packages/types', 'packages/release-manifests', 'packages/version-bridge', + 'plugins/auth-node', 'plugins/catalog-backend-module-ldap', 'plugins/catalog-backend-module-msgraph', 'plugins/catalog-common',