Merge pull request #11216 from backstage/rugvip/oauth2-proxy

auth-backend: oauth2 proxy refactor + integration example + doc updates
This commit is contained in:
Patrik Oldsberg
2022-05-03 11:07:24 +02:00
committed by GitHub
8 changed files with 190 additions and 77 deletions
+9
View File
@@ -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.
+2
View File
@@ -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:
+24 -48
View File
@@ -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
+25
View File
@@ -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 `<ProxiedSignInPage {...props} provider="myproxy" />`
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],
},
});
},
},
}),
},
});
}
+6 -3
View File
@@ -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<OAuth2ProxyResult<unknown>>;
authHandler?: AuthHandler<OAuth2ProxyResult<unknown>> | undefined;
signIn: {
resolver: SignInResolver<OAuth2ProxyResult<unknown>>;
};
@@ -563,9 +564,11 @@ export type Oauth2ProxyProviderOptions<JWTPayload> = {
};
// @public
export type OAuth2ProxyResult<JWTPayload> = {
export type OAuth2ProxyResult<JWTPayload = {}> = {
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<OAuth2ProxyResult<unknown>>;
authHandler?: AuthHandler<OAuth2ProxyResult<unknown>> | undefined;
signIn: {
resolver: SignInResolver<OAuth2ProxyResult<unknown>>;
};
@@ -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:
#
# <ProxiedSignInPage {...props} provider="myproxy" />
#
# 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
@@ -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<express.Request>;
provider = new Oauth2ProxyAuthProvider<any>({
@@ -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 },
});
@@ -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<JWTPayload> = {
export type OAuth2ProxyResult<JWTPayload = {}> = {
/**
* 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<JWTPayload>
async refresh(req: express.Request, res: express.Response): Promise<void> {
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<JWTPayload>
profile,
};
}
}
private getResult(req: express.Request): OAuth2ProxyResult<JWTPayload> {
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<unknown>,
): Promise<AuthHandlerResult> {
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<JWTPayload>(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<OAuth2ProxyResult<JWTPayload>>;
authHandler?: AuthHandler<OAuth2ProxyResult<JWTPayload>>;
/**
* 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<JWTPayload>({
resolverContext,
signInResolver,
authHandler,
authHandler: authHandler ?? defaultAuthHandler,
});
};
},