diff --git a/.changeset/angry-candles-kick.md b/.changeset/angry-candles-kick.md new file mode 100644 index 0000000000..6f51f719c2 --- /dev/null +++ b/.changeset/angry-candles-kick.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Add new authentication provider: OAuth2Proxy diff --git a/docs/auth/index.md b/docs/auth/index.md index 4a8d225048..e407480f65 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -25,6 +25,7 @@ Backstage comes with many common authentication providers in the core library: - [Google](google/provider.md) - [Okta](okta/provider.md) - [OneLogin](onelogin/provider.md) +- [OAuth2Proxy](oauth2-proxy/provider.md) These built-in providers handle the authentication flow for a particular service including required scopes, callbacks, etc. These providers are each added to a diff --git a/docs/auth/oauth2-proxy/provider.md b/docs/auth/oauth2-proxy/provider.md new file mode 100644 index 0000000000..326b5ef463 --- /dev/null +++ b/docs/auth/oauth2-proxy/provider.md @@ -0,0 +1,105 @@ +--- +id: provider +title: OAuth 2 Proxy Provider +sidebar_label: OAuth 2 Custom Proxy +description: Adding OAuth2Proxy as an authentication provider in Backstage +--- + +The Backstage `@backstage/plugin-auth-backend` package comes with an +`oauth2proxy` authentication provider that can authenticate users by using a +[oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy) in front of an +actual Backstage instance. This enables to reuse existing authentications within +a cluster. In general the `oauth2-proxy` supports all OpenID Connect providers, +for more details check this +[list of supported providers](https://oauth2-proxy.github.io/oauth2-proxy/docs/configuration/oauth_provider). + +## Configuration + +The provider configuration can be added to your `app-config.yaml` under the root +`auth` configuration: + +```yaml +auth: + environment: development + providers: + oauth2proxy: {} +``` + +Right now no configuration options are supported. To make use of the provider, +make sure that your `oauth2-proxy` is configured correctly and provides a custom +`X-OAUTH2-PROXY-ID-TOKEN` header. To do so, enable the +`--set-authorization-header=true` of your `oauth2-proxy` and forward the +`Authorization` header as `X-OAUTH2-PROXY-ID-TOKEN`. For more details check the +[configuration docs](https://oauth2-proxy.github.io/oauth2-proxy/configuration). + +_Example for kubernetes ingress:_ + +```bash +# forward the authorization header from the auth request in the X-OAUTH2-PROXY-ID-TOKEN header +auth_request_set $name_upstream_authorization $upstream_http_authorization; +proxy_set_header X-OAUTH2-PROXY-ID-TOKEN $name_upstream_authorization; +``` + +## Adding the provider to the Backstage backend + +When using `oauth2proxy` auth you can configure it as described +[here](https://backstage.io/docs/auth/identity-resolver). + +- use the following code below to introduce changes to + `packages/backend/plugin/auth.ts`: + +```ts + providerFactories: { + oauth2proxy: createOauth2ProxyProvider<{ + id: string; + email: string; + }>({ + authHandler: async input => { + const { email } = input.fullProfile; + + return { + profile: { + email, + }, + }; + }, + signIn: { + resolver: async (signInInfo, ctx) => { + const { preferred_username: id } = signInInfo.result.fullProfile; + const sub = `user:default/${id}`; + + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub, ent: [`group:default/optional-user-group`] }, + }); + + return { id, token }; + }, + }, + }), + } +``` + +## Adding the provider to the Backstage frontend + +All Backstage apps need a `SignInPage` to be configured. Its purpose is to +establish who the user is and what their identifying credentials are, blocking +rendering the rest of the UI until that's complete, and then keeping those +credentials fresh. + +When using the OAuth2-Proxy, the Backstage UI can only be accessed after the +user has already been authenticated at the proxy. Instead of showing the user +another login page when accessing Backstage, it will handle the login in the +background. Backstage provides for this case a special `SignInPage` component +which has no UI. + +Update your `createApp` call in `packages/app/src/App.tsx`, as follows. + +```diff ++import { ProxiedSignInPage } from '@backstage/core-components'; + const app = createApp({ + components: { ++ SignInPage: props => , +``` + +After this, your app should be ready to leverage the OAuth2-Proxy for +authentication! diff --git a/microsite/sidebars.json b/microsite/sidebars.json index c0a0b9b24f..48d44e9a21 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -228,7 +228,8 @@ "auth/gitlab/provider", "auth/google/provider", "auth/okta/provider", - "auth/onelogin/provider" + "auth/onelogin/provider", + "auth/oauth2-proxy/provider" ] }, "auth/add-auth-provider", diff --git a/mkdocs.yml b/mkdocs.yml index 398863a3c0..2fcdb830d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -144,6 +144,7 @@ nav: - Google: 'auth/google/provider.md' - Okta: 'auth/okta/provider.md' - OneLogin: 'auth/onelogin/provider.md' + - OAuth2Proxy: 'auth/oauth2-proxy/provider.md' - Bitbucket: 'auth/bitbucket/provider.md' - Adding authentication providers: 'auth/add-auth-provider.md' - Using authentication and identity: 'auth/using-auth.md' diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 01de316de0..db0bf2af27 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -59,9 +59,17 @@ export type Auth0ProviderOptions = { }; }; +// @public +export type AuthContext = { + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger_2; +}; + // @public export type AuthHandler = ( input: TAuthResult, + context?: AuthContext, ) => Promise; // @public @@ -270,6 +278,11 @@ export const createOAuth2Provider: ( options?: OAuth2ProviderOptions | undefined, ) => AuthProviderFactory; +// @public +export const createOauth2ProxyProvider: ( + options: Oauth2ProxyProviderOptions, +) => AuthProviderFactory; + // Warning: (ae-missing-release-tag) "createOidcProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -436,6 +449,20 @@ export type OAuth2ProviderOptions = { }; }; +// @public +export type Oauth2ProxyProviderOptions = { + authHandler: AuthHandler>; + signIn: { + resolver: SignInResolver>; + }; +}; + +// @public +export type OAuth2ProxyResult = { + fullProfile: JWTPayload; + accessToken: string; +}; + // Warning: (ae-missing-release-tag) "OAuthAdapter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -664,11 +691,7 @@ export type SignInInfo = { // @public export type SignInResolver = ( info: SignInInfo, - context: { - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger_2; - }, + context: AuthContext, ) => Promise; // Warning: (ae-missing-release-tag) "TokenIssuer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -704,5 +727,5 @@ export type WebMessageResponse = // src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts // src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts // src/providers/github/provider.d.ts:81:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts -// src/providers/types.d.ts:88:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts +// src/providers/types.d.ts:98:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts ``` diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index a25d2ce8bc..79575c2a77 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -23,6 +23,7 @@ export * from './gitlab'; export * from './google'; export * from './microsoft'; export * from './oauth2'; +export * from './oauth2-proxy'; export * from './oidc'; export * from './okta'; export * from './onelogin'; @@ -38,6 +39,7 @@ export type { AuthProviderFactoryOptions, AuthProviderFactory, AuthHandler, + AuthContext, AuthHandlerResult, SignInResolver, SignInInfo, diff --git a/plugins/auth-backend/src/providers/oauth2-proxy/index.ts b/plugins/auth-backend/src/providers/oauth2-proxy/index.ts new file mode 100644 index 0000000000..62304b1d1f --- /dev/null +++ b/plugins/auth-backend/src/providers/oauth2-proxy/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 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 { createOauth2ProxyProvider } from './provider'; +export type { Oauth2ProxyProviderOptions, OAuth2ProxyResult } from './provider'; diff --git a/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts b/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts new file mode 100644 index 0000000000..4e1aeb88bf --- /dev/null +++ b/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts @@ -0,0 +1,214 @@ +/* + * Copyright 2021 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. + */ + +jest.mock('jose', () => ({ + JWT: { + decode: jest.fn(), + }, +})); +jest.mock('@backstage/catalog-client'); + +import express from 'express'; +import { JWT } from 'jose'; +import { Logger } from 'winston'; +import { + AuthHandler, + SignInResolver, + AuthProviderFactoryOptions, +} from '../types'; + +import { CatalogIdentityClient } from '../../lib/catalog'; +import { TokenIssuer } from '../../identity/types'; + +import { + createOauth2ProxyProvider, + Oauth2ProxyAuthProvider, + Oauth2ProxyProviderOptions, + OAuth2ProxyResult, + OAUTH2_PROXY_JWT_HEADER, +} from './provider'; + +describe('Oauth2ProxyAuthProvider', () => { + const mockToken = + 'eyblob.eyJzdWIiOiJqaW1teW1hcmt1bSIsImVudCI6WyJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iXX0=.eyblob'; + + let provider: Oauth2ProxyAuthProvider; + let logger: jest.Mocked; + let signInResolver: jest.MockedFunction< + SignInResolver> + >; + let authHandler: jest.MockedFunction>>; + let mockResponse: jest.Mocked; + let mockRequest: jest.Mocked; + let JWTMock: jest.Mocked; + + beforeEach(() => { + jest.resetAllMocks(); + + JWTMock = JWT as jest.Mocked; + authHandler = jest.fn(); + signInResolver = jest.fn(); + logger = { error: jest.fn() } as unknown as jest.Mocked; + + mockResponse = { + status: jest.fn(), + end: jest.fn(), + json: jest.fn(), + } as unknown as jest.Mocked; + + mockRequest = { + body: {}, + header: jest.fn(), + } as unknown as jest.Mocked; + + provider = new Oauth2ProxyAuthProvider({ + authHandler, + logger, + signInResolver, + catalogIdentityClient: {} as CatalogIdentityClient, + tokenIssuer: {} as TokenIssuer, + }); + }); + + describe('frameHandler()', () => { + it('should do nothing and return undefined', async () => { + const result = await provider.frameHandler(); + + expect(result).toBeUndefined(); + }); + }); + + describe('start()', () => { + it('should do nothing and return undefined', async () => { + const result = await provider.start(); + + expect(result).toBeUndefined(); + }); + }); + + describe('refresh()', () => { + it('should throw an error when auth header is missing', async () => { + mockRequest.header.mockReturnValue(undefined); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + }); + + it('should throw an error if the bearer token is invalid', async () => { + mockRequest.header.mockReturnValue('Basic asdf='); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + }); + + it('should return if auth header is set and valid', async () => { + mockRequest.header.mockReturnValue(`Bearer token`); + authHandler.mockResolvedValue({ + profile: {}, + }); + signInResolver.mockResolvedValue({ + id: 'some-id', + token: mockToken, + }); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockRequest.header).toBeCalledWith(OAUTH2_PROXY_JWT_HEADER); + expect(JWTMock.decode).toHaveBeenCalledWith('token'); + expect(mockResponse.json).toHaveBeenCalled(); + }); + + it('should load profile from authHandler and backstage identity from signInResolver', async () => { + const decodedToken = { + oid: 'oid', + name: 'name', + upn: 'john.doe@example.com', + }; + const profile = { displayName: 'some value' }; + mockRequest.header.mockReturnValue(`Bearer token`); + signInResolver.mockResolvedValue({ + id: 'some-id', + token: mockToken, + }); + authHandler.mockResolvedValue({ profile: profile }); + JWTMock.decode.mockReturnValue(decodedToken as any); + + await provider.refresh(mockRequest, mockResponse); + + expect(signInResolver).toHaveBeenCalledWith( + { + profile: profile, + result: { + accessToken: 'token', + fullProfile: decodedToken, + }, + }, + { catalogIdentityClient: {}, logger, tokenIssuer: {} }, + ); + expect(mockResponse.json).toHaveBeenCalledWith({ + backstageIdentity: { + id: 'some-id', + idToken: mockToken, + identity: { + ownershipEntityRefs: ['user:default/jimmymarkum'], + type: 'user', + userEntityRef: 'jimmymarkum', + }, + token: mockToken, + }, + profile: { displayName: 'some value' }, + providerInfo: { + accessToken: 'token', + }, + }); + }); + }); + + describe('createOauth2ProxyProvider()', () => { + beforeEach(() => { + mockRequest.header.mockReturnValue(`Bearer token`); + authHandler.mockResolvedValue({ + profile: {}, + }); + signInResolver.mockResolvedValue({ + id: 'some-id', + token: mockToken, + }); + }); + + it('should create a valid provider', async () => { + const providerOptions = { + authHandler, + signIn: { resolver: signInResolver }, + } as Oauth2ProxyProviderOptions; + const factoryOptions = { + logger, + catalogApi: {}, + tokenIssuer: {}, + } as unknown as AuthProviderFactoryOptions; + + const factory = createOauth2ProxyProvider(providerOptions); + const handler = factory(factoryOptions); + await handler.refresh!(mockRequest, mockResponse); + + expect(mockRequest.header).toBeCalledWith(OAUTH2_PROXY_JWT_HEADER); + expect(JWTMock.decode).toHaveBeenCalledWith('token'); + expect(mockResponse.json).toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts b/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts new file mode 100644 index 0000000000..eceebc5924 --- /dev/null +++ b/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2021 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 express from 'express'; +import { Logger } from 'winston'; +import { AuthenticationError } from '@backstage/errors'; +import { + AuthHandler, + SignInResolver, + AuthProviderFactory, + AuthProviderRouteHandlers, + AuthResponse, +} from '../types'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { JWT } from 'jose'; +import { IdentityClient } from '../../identity'; +import { TokenIssuer } from '../../identity/types'; +import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; + +export const OAUTH2_PROXY_JWT_HEADER = 'X-OAUTH2-PROXY-ID-TOKEN'; + +/** + * JWT header extraction result, containing the raw value and the parsed JWT + * payload. + * + * @public + */ +export type OAuth2ProxyResult = { + /** + * Parsed and decoded JWT payload. + */ + fullProfile: JWTPayload; + + /** + * Raw JWT token + */ + accessToken: string; +}; + +/** + * Options for the oauth2-proxy provider factory + * + * @public + */ +export type Oauth2ProxyProviderOptions = { + /** + * Configure an auth handler to generate a profile for the user. + */ + authHandler: AuthHandler>; + + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + signIn: { + /** + * Maps an auth result to a Backstage identity for the user. + */ + resolver: SignInResolver>; + }; +}; + +interface Options { + logger: Logger; + signInResolver: SignInResolver>; + authHandler: AuthHandler>; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; +} + +export class Oauth2ProxyAuthProvider + implements AuthProviderRouteHandlers +{ + private readonly logger: Logger; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly signInResolver: SignInResolver< + OAuth2ProxyResult + >; + private readonly authHandler: AuthHandler>; + private readonly tokenIssuer: TokenIssuer; + + constructor(options: Options) { + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; + this.tokenIssuer = options.tokenIssuer; + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + } + + frameHandler(): Promise { + return Promise.resolve(undefined); + } + + async refresh(req: express.Request, res: express.Response): Promise { + try { + const result = this.getResult(req); + + const response = await this.handleResult(result); + + res.json(response); + } catch (e) { + this.logger.error( + `Exception occurred during ${OAUTH2_PROXY_JWT_HEADER} refresh`, + e, + ); + res.status(401); + res.end(); + } + } + + start(): Promise { + return Promise.resolve(undefined); + } + + private async handleResult( + result: OAuth2ProxyResult, + ): Promise> { + const ctx = { + logger: this.logger, + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, + }; + + const { profile } = await this.authHandler(result, ctx); + + const backstageSignInResult = await this.signInResolver( + { + result, + profile, + }, + ctx, + ); + + return { + providerInfo: { + accessToken: result.accessToken, + }, + backstageIdentity: prepareBackstageIdentityResponse( + backstageSignInResult, + ), + profile, + }; + } + + private getResult(req: express.Request): OAuth2ProxyResult { + const authHeader = req.header(OAUTH2_PROXY_JWT_HEADER); + const jwt = IdentityClient.getBearerToken(authHeader); + + if (!jwt) { + throw new AuthenticationError( + `Missing or in incorrect format - Oauth2Proxy OIDC header: ${OAUTH2_PROXY_JWT_HEADER}`, + ); + } + + const decodedJWT = JWT.decode(jwt) as unknown as JWTPayload; + + return { + fullProfile: decodedJWT, + accessToken: jwt, + }; + } +} + +/** + * Factory function for oauth2-proxy auth provider + * + * @public + */ +export const createOauth2ProxyProvider = + ( + options: Oauth2ProxyProviderOptions, + ): AuthProviderFactory => + ({ catalogApi, logger, tokenIssuer }) => { + const signInResolver = options.signIn.resolver; + const authHandler = options.authHandler; + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + return new Oauth2ProxyAuthProvider({ + logger, + signInResolver, + authHandler, + tokenIssuer, + catalogIdentityClient, + }); + }; diff --git a/plugins/auth-backend/src/providers/oauth2/index.ts b/plugins/auth-backend/src/providers/oauth2/index.ts index d68208a148..767466f8f6 100644 --- a/plugins/auth-backend/src/providers/oauth2/index.ts +++ b/plugins/auth-backend/src/providers/oauth2/index.ts @@ -15,4 +15,5 @@ */ export { createOAuth2Provider } from './provider'; + export type { OAuth2ProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index dabe46a9ae..b1aad05897 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -24,6 +24,17 @@ import { TokenIssuer } from '../identity/types'; import { OAuthStartRequest } from '../lib/oauth/types'; import { CatalogIdentityClient } from '../lib/catalog'; +/** + * The context that is used for auth processing. + * + * @public + */ +export type AuthContext = { + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; +}; + export type AuthProviderConfig = { /** * The protocol://domain[:port] where the app is hosted. This is used to construct the @@ -259,11 +270,7 @@ export type SignInInfo = { */ export type SignInResolver = ( info: SignInInfo, - context: { - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; - }, + context: AuthContext, ) => Promise; /** @@ -289,6 +296,7 @@ export type AuthHandlerResult = { profile: ProfileInfo }; */ export type AuthHandler = ( input: TAuthResult, + context?: AuthContext, ) => Promise; export type StateEncoder = (