Merge pull request #11216 from backstage/rugvip/oauth2-proxy
auth-backend: oauth2 proxy refactor + integration example + doc updates
This commit is contained in:
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user