diff --git a/.changeset/hungry-goats-mate.md b/.changeset/hungry-goats-mate.md new file mode 100644 index 0000000000..733b1c7cf5 --- /dev/null +++ b/.changeset/hungry-goats-mate.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Updates the OAuth2 Proxy provider to require less infrastructure configuration. + +The auth result object of the OAuth2 Proxy now provides access to the request headers, both through the `headers` object as well as `getHeader` method. The existing logic that parses and extracts the user information from ID tokens is deprecated and will be removed in a future release. See the OAuth2 Proxy provider documentation for more details. + +The OAuth2 Proxy provider now also has a default `authHandler` implementation that reads the display name and email from the incoming request headers. diff --git a/app-config.yaml b/app-config.yaml index b3ca3c7203..1156b9b340 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -378,6 +378,8 @@ auth: clientId: ${AUTH_ATLASSIAN_CLIENT_ID} clientSecret: ${AUTH_ATLASSIAN_CLIENT_SECRET} scopes: ${AUTH_ATLASSIAN_SCOPES} + myproxy: + development: {} costInsights: engineerCost: 200000 products: diff --git a/docs/auth/oauth2-proxy/provider.md b/docs/auth/oauth2-proxy/provider.md index f366929fe8..644219ffa0 100644 --- a/docs/auth/oauth2-proxy/provider.md +++ b/docs/auth/oauth2-proxy/provider.md @@ -20,63 +20,39 @@ The provider configuration can be added to your `app-config.yaml` under the root ```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). +Right now no configuration options are supported, but the empty object is needed +to enable the provider in the auth backend. -_Example for kubernetes ingress:_ +To use the `oauth2proxy` provider you must also configure it with a sign-in resolver. +For more information about the sign-in process in general, see the +[Sign-in Identities and Resolvers](../identity-resolver.md) documentation. -```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; -``` +For the `oauth2proxy` provider, the sign-in result is quite different than other providers. +Because it's a proxy provider that can be configured to forward information through +arbitrary headers, the auth result simply just gives you access to the HTTP headers +of the incoming request. Using these you can either extract the information directly, +or grab ID or access tokens to look up additional information and/or validate the request. -## 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`: +A simple sign-in resolver might for example look like this: ```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 }; - }, - }, - }), - } +providers.oauth2Proxy.create({ + signIn: { + async resolver({ result }, ctx) { + const name = result.getHeader('x-forwarded-user'); + if (!name) { + throw new Error('Request did not contain a user') + } + return ctx.signInWithCatalogUser({ + entityRef: { name }, + }); + }, + }, +}), ``` ## Adding the provider to the Backstage frontend diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 8a60b4b4bd..cd0c42afe5 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +import { + DEFAULT_NAMESPACE, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { createRouter, providers, @@ -89,6 +93,27 @@ export default async function createPlugin( }, }, }), + + // This is an example of how to configure the OAuth2Proxy provider as well + // as how to sign a user in without a matching user entity in the catalog. + // You can try it out using `` + myproxy: providers.oauth2Proxy.create({ + signIn: { + async resolver({ result }, ctx) { + const entityRef = stringifyEntityRef({ + kind: 'user', + namespace: DEFAULT_NAMESPACE, + name: result.getHeader('x-forwarded-user')!, + }); + return ctx.issueToken({ + claims: { + sub: entityRef, + ent: [entityRef], + }, + }); + }, + }, + }), }, }); } diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 18924f7c23..a38197668e 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -12,6 +12,7 @@ import { Config } from '@backstage/config'; import { Entity } from '@backstage/catalog-model'; import express from 'express'; import { GetEntitiesRequest } from '@backstage/catalog-client'; +import { IncomingHttpHeaders } from 'http'; import { JsonValue } from '@backstage/types'; import { Logger } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; @@ -376,7 +377,7 @@ export const createOAuth2Provider: ( // @public @deprecated (undocumented) export const createOauth2ProxyProvider: (options: { - authHandler: AuthHandler>; + authHandler?: AuthHandler> | undefined; signIn: { resolver: SignInResolver>; }; @@ -563,9 +564,11 @@ export type Oauth2ProxyProviderOptions = { }; // @public -export type OAuth2ProxyResult = { +export type OAuth2ProxyResult = { fullProfile: JWTPayload; accessToken: string; + headers: IncomingHttpHeaders; + getHeader(name: string): string | undefined; }; // Warning: (ae-missing-release-tag) "OAuthAdapter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -912,7 +915,7 @@ export const providers: Readonly<{ }>; oauth2Proxy: Readonly<{ create: (options: { - authHandler: AuthHandler>; + authHandler?: AuthHandler> | undefined; signIn: { resolver: SignInResolver>; }; diff --git a/plugins/auth-backend/examples/docker-compose.oauth2-proxy.yaml b/plugins/auth-backend/examples/docker-compose.oauth2-proxy.yaml new file mode 100755 index 0000000000..f00e2e0987 --- /dev/null +++ b/plugins/auth-backend/examples/docker-compose.oauth2-proxy.yaml @@ -0,0 +1,44 @@ +#!/usr/bin/env docker-compose -f + +# This docker compose file can be used to try out the oauth2-proxy auth provider. +# You'll need to provide your own GitHub client ID and secret through +# GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET. +# +# The only modifications you need to make to run this example is to switch the +# SignInPage to the following: +# +# +# +# You also need to switch out the baseUrl and listen port of both the frontend and +# backend, but we can do that through env vars when running `yarn dev`: +# +# APP_CONFIG_app_baseUrl=http://localhost APP_CONFIG_app_listen_port=3000 \ +# APP_CONFIG_backend_baseUrl=http://localhost APP_CONFIG_backend_listen_port=7007 yarn dev +# +# Once done, you can run the following from the root and then navigate to http://localhost +# +# ./plugins/auth-backend/examples/docker-compose.oauth2-proxy.yaml up + +version: '3' +services: + proxy: + container_name: oauth2-proxy + image: quay.io/oauth2-proxy/oauth2-proxy:v7.2.1 + + # The below config assumes that you are running the frontend and backend + # in development mode, and that `host.docker.internal` resolves to the host machine. + command: >- + --cookie-secret=super-super-secret-cookie-secret + --cookie-secure=false + --http-address=0.0.0.0:80 + --email-domain=* + + --provider=github + --client-id=${GITHUB_CLIENT_ID} + --client-secret=${GITHUB_CLIENT_SECRET} + + --redirect-url=http://localhost/oauth2/callback + --upstream=http://host.docker.internal:7007/api/,http://host.docker.internal:3000 + + ports: + - 80:80 diff --git a/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts b/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts index d258c17e61..f10a81c76b 100644 --- a/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts +++ b/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts @@ -25,7 +25,7 @@ import * as jose from 'jose'; import { Logger } from 'winston'; import { AuthHandler, AuthResolverContext, SignInResolver } from '../types'; import { - createOauth2ProxyProvider, + oauth2Proxy, Oauth2ProxyAuthProvider, OAuth2ProxyResult, OAUTH2_PROXY_JWT_HEADER, @@ -64,6 +64,9 @@ describe('Oauth2ProxyAuthProvider', () => { mockRequest = { body: {}, header: jest.fn(), + headers: { + 'x-mock': 'mock', + }, } as unknown as jest.Mocked; provider = new Oauth2ProxyAuthProvider({ @@ -146,6 +149,10 @@ describe('Oauth2ProxyAuthProvider', () => { result: { accessToken: 'token', fullProfile: decodedToken, + getHeader: expect.any(Function), + headers: { + 'x-mock': 'mock', + }, }, }, { _: 'resolver-context' }, @@ -167,7 +174,7 @@ describe('Oauth2ProxyAuthProvider', () => { }); }); - describe('createOauth2ProxyProvider()', () => { + describe('oauth2Proxy.create()', () => { beforeEach(() => { mockRequest.header.mockReturnValue(`Bearer token`); authHandler.mockResolvedValue({ @@ -179,7 +186,7 @@ describe('Oauth2ProxyAuthProvider', () => { }); it('should create a valid provider', async () => { - const factory = createOauth2ProxyProvider({ + const factory = oauth2Proxy.create({ authHandler, signIn: { resolver: signInResolver }, }); diff --git a/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts b/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts index acef4a74ef..625c9e3d62 100644 --- a/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts @@ -23,10 +23,17 @@ import { AuthProviderRouteHandlers, AuthResponse, AuthResolverContext, + AuthHandlerResult, } from '../types'; import { decodeJwt } from 'jose'; import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { IncomingHttpHeaders } from 'http'; + +// NOTE: This may come in handy if you're doing work on this provider: +// +// plugins/auth-backend/examples/docker-compose.oauth2-proxy.yaml +// export const OAUTH2_PROXY_JWT_HEADER = 'X-OAUTH2-PROXY-ID-TOKEN'; @@ -36,16 +43,41 @@ export const OAUTH2_PROXY_JWT_HEADER = 'X-OAUTH2-PROXY-ID-TOKEN'; * * @public */ -export type OAuth2ProxyResult = { +export type OAuth2ProxyResult = { /** - * Parsed and decoded JWT payload. + * The parsed payload of the `accessToken`. The token is only parsed, not verified. + * + * @deprecated Access through the `headers` instead. This will be removed in a future release. */ fullProfile: JWTPayload; /** - * Raw JWT token + * The token received via the X-OAUTH2-PROXY-ID-TOKEN header. Will be an empty string + * if the header is not set. Note the this is typically an OpenID Connect token. + * + * @deprecated Access through the `headers` instead. This will be removed in a future release. */ accessToken: string; + + /** + * The headers of the incoming request from the OAuth2 proxy. This will include + * both the headers set by the client as well as the ones added by the OAuth2 proxy. + * You should only trust the headers that are injected by the OAuth2 proxy. + * + * Useful headers to use to complete the sign-in are for example `x-forwarded-user` + * and `x-forwarded-email`. See the OAuth2 proxy documentation for more information + * about the available headers and how to enable them. In particular it is possible + * to forward access and identity tokens, which can be user for additional verification + * and lookups. + */ + headers: IncomingHttpHeaders; + + /** + * Provides convenient access to the request headers. + * + * This call is simply forwarded to `req.get(name)`. + */ + getHeader(name: string): string | undefined; }; /** @@ -96,7 +128,23 @@ export class Oauth2ProxyAuthProvider async refresh(req: express.Request, res: express.Response): Promise { try { - const result = this.getResult(req); + // TODO(Rugvip): This parsing was deprecated in 1.2 and should be removed in a future release. + const authHeader = req.header(OAUTH2_PROXY_JWT_HEADER); + const jwt = getBearerTokenFromAuthorizationHeader(authHeader); + const decodedJWT = jwt && (decodeJwt(jwt) as unknown as JWTPayload); + + const result = { + fullProfile: decodedJWT || ({} as JWTPayload), + accessToken: jwt || '', + headers: req.headers, + getHeader(name: string) { + if (name.toLocaleLowerCase('en-US') === 'set-cookie') { + throw new Error('Access Set-Cookie via the headers object instead'); + } + return req.get(name); + }, + }; + const response = await this.handleResult(result); res.json(response); } catch (e) { @@ -131,24 +179,19 @@ export class Oauth2ProxyAuthProvider profile, }; } +} - private getResult(req: express.Request): OAuth2ProxyResult { - const authHeader = req.header(OAUTH2_PROXY_JWT_HEADER); - const jwt = getBearerTokenFromAuthorizationHeader(authHeader); - - if (!jwt) { - throw new AuthenticationError( - `Missing or in incorrect format - Oauth2Proxy OIDC header: ${OAUTH2_PROXY_JWT_HEADER}`, - ); - } - - const decodedJWT = decodeJwt(jwt) as unknown as JWTPayload; - - return { - fullProfile: decodedJWT, - accessToken: jwt, - }; - } +async function defaultAuthHandler( + result: OAuth2ProxyResult, +): Promise { + return { + profile: { + email: result.getHeader('x-forwarded-email'), + displayName: + result.getHeader('x-forwarded-preferred-username') || + result.getHeader('x-forwarded-user'), + }, + }; } /** @@ -160,8 +203,12 @@ export const oauth2Proxy = createAuthProviderIntegration({ create(options: { /** * Configure an auth handler to generate a profile for the user. + * + * The default implementation uses the value of the `X-Forwarded-Preferred-Username` + * header as the display name, falling back to `X-Forwarded-User`, and the value of + * the `X-Forwarded-Email` header as the email address. */ - authHandler: AuthHandler>; + authHandler?: AuthHandler>; /** * Configure sign-in for this provider, without it the provider can not be used to sign users in. @@ -179,7 +226,7 @@ export const oauth2Proxy = createAuthProviderIntegration({ return new Oauth2ProxyAuthProvider({ resolverContext, signInResolver, - authHandler, + authHandler: authHandler ?? defaultAuthHandler, }); }; },