feat: add oauth2proxy provider

Signed-off-by: Adrian Barwicki <adrian.barwicki.extern@sda.se>
Signed-off-by: Dominik Schwank <dominik.schwank@sda.se>
This commit is contained in:
Adrian Barwicki
2021-12-15 23:03:40 +01:00
committed by Dominik Schwank
parent 2a13ac8902
commit 6e92ee6267
12 changed files with 590 additions and 12 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Add new authentication provider: OAuth2Proxy
+1
View File
@@ -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
+105
View File
@@ -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 => <ProxiedSignInPage {...props} provider="oauth2-proxy" />,
```
After this, your app should be ready to leverage the OAuth2-Proxy for
authentication!
+2 -1
View File
@@ -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",
+1
View File
@@ -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'
+29 -6
View File
@@ -59,9 +59,17 @@ export type Auth0ProviderOptions = {
};
};
// @public
export type AuthContext = {
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger_2;
};
// @public
export type AuthHandler<TAuthResult> = (
input: TAuthResult,
context?: AuthContext,
) => Promise<AuthHandlerResult>;
// @public
@@ -270,6 +278,11 @@ export const createOAuth2Provider: (
options?: OAuth2ProviderOptions | undefined,
) => AuthProviderFactory;
// @public
export const createOauth2ProxyProvider: <JWTPayload>(
options: Oauth2ProxyProviderOptions<JWTPayload>,
) => 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<JWTPayload> = {
authHandler: AuthHandler<OAuth2ProxyResult<JWTPayload>>;
signIn: {
resolver: SignInResolver<OAuth2ProxyResult<JWTPayload>>;
};
};
// @public
export type OAuth2ProxyResult<JWTPayload> = {
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<TAuthResult> = {
// @public
export type SignInResolver<TAuthResult> = (
info: SignInInfo<TAuthResult>,
context: {
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger_2;
},
context: AuthContext,
) => Promise<BackstageSignInResult>;
// 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
```
@@ -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,
@@ -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';
@@ -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<any>;
let logger: jest.Mocked<Logger>;
let signInResolver: jest.MockedFunction<
SignInResolver<OAuth2ProxyResult<any>>
>;
let authHandler: jest.MockedFunction<AuthHandler<OAuth2ProxyResult<any>>>;
let mockResponse: jest.Mocked<express.Response>;
let mockRequest: jest.Mocked<express.Request>;
let JWTMock: jest.Mocked<typeof JWT>;
beforeEach(() => {
jest.resetAllMocks();
JWTMock = JWT as jest.Mocked<typeof JWT>;
authHandler = jest.fn();
signInResolver = jest.fn();
logger = { error: jest.fn() } as unknown as jest.Mocked<Logger>;
mockResponse = {
status: jest.fn(),
end: jest.fn(),
json: jest.fn(),
} as unknown as jest.Mocked<express.Response>;
mockRequest = {
body: {},
header: jest.fn(),
} as unknown as jest.Mocked<express.Request>;
provider = new Oauth2ProxyAuthProvider<any>({
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<any>;
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();
});
});
});
@@ -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<JWTPayload> = {
/**
* Parsed and decoded JWT payload.
*/
fullProfile: JWTPayload;
/**
* Raw JWT token
*/
accessToken: string;
};
/**
* Options for the oauth2-proxy provider factory
*
* @public
*/
export type Oauth2ProxyProviderOptions<JWTPayload> = {
/**
* Configure an auth handler to generate a profile for the user.
*/
authHandler: AuthHandler<OAuth2ProxyResult<JWTPayload>>;
/**
* 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<OAuth2ProxyResult<JWTPayload>>;
};
};
interface Options<JWTPayload> {
logger: Logger;
signInResolver: SignInResolver<OAuth2ProxyResult<JWTPayload>>;
authHandler: AuthHandler<OAuth2ProxyResult<JWTPayload>>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
}
export class Oauth2ProxyAuthProvider<JWTPayload>
implements AuthProviderRouteHandlers
{
private readonly logger: Logger;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly signInResolver: SignInResolver<
OAuth2ProxyResult<JWTPayload>
>;
private readonly authHandler: AuthHandler<OAuth2ProxyResult<JWTPayload>>;
private readonly tokenIssuer: TokenIssuer;
constructor(options: Options<JWTPayload>) {
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this.tokenIssuer = options.tokenIssuer;
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
}
frameHandler(): Promise<void> {
return Promise.resolve(undefined);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
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<void> {
return Promise.resolve(undefined);
}
private async handleResult(
result: OAuth2ProxyResult<JWTPayload>,
): Promise<AuthResponse<{ accessToken: string }>> {
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<JWTPayload> {
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 =
<JWTPayload>(
options: Oauth2ProxyProviderOptions<JWTPayload>,
): AuthProviderFactory =>
({ catalogApi, logger, tokenIssuer }) => {
const signInResolver = options.signIn.resolver;
const authHandler = options.authHandler;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
return new Oauth2ProxyAuthProvider<JWTPayload>({
logger,
signInResolver,
authHandler,
tokenIssuer,
catalogIdentityClient,
});
};
@@ -15,4 +15,5 @@
*/
export { createOAuth2Provider } from './provider';
export type { OAuth2ProviderOptions } from './provider';
+13 -5
View File
@@ -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<TAuthResult> = {
*/
export type SignInResolver<TAuthResult> = (
info: SignInInfo<TAuthResult>,
context: {
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
},
context: AuthContext,
) => Promise<BackstageSignInResult>;
/**
@@ -289,6 +296,7 @@ export type AuthHandlerResult = { profile: ProfileInfo };
*/
export type AuthHandler<TAuthResult> = (
input: TAuthResult,
context?: AuthContext,
) => Promise<AuthHandlerResult>;
export type StateEncoder = (