Merge pull request #20685 from backstage/djam/oauth2-proxy-provider
chore: add oauth2 proxy provider backend module
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
`oauth2-proxy` auth implementation has been moved to `@backstage/plugin-auth-backend-module-oauth2-proxy-provider`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-oauth2-proxy-provider': minor
|
||||
---
|
||||
|
||||
Release of `oauth2-proxy-provider` plugin
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,5 @@
|
||||
# @backstage/plugin-auth-backend-module-oauth2-proxy-provider
|
||||
|
||||
The oauth2-proxy-provider backend module for the auth plugin.
|
||||
|
||||
_This plugin was created through the Backstage CLI_
|
||||
@@ -0,0 +1,31 @@
|
||||
## API Report File for "@backstage/plugin-auth-backend-module-oauth2-proxy-provider"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="node" />
|
||||
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { ProxyAuthenticator } from '@backstage/plugin-auth-node';
|
||||
|
||||
// @public (undocumented)
|
||||
export const authModuleOauth2ProxyProvider: () => BackendFeature;
|
||||
|
||||
// @public
|
||||
export const OAUTH2_PROXY_JWT_HEADER = 'X-OAUTH2-PROXY-ID-TOKEN';
|
||||
|
||||
// @public (undocumented)
|
||||
export const oauth2ProxyAuthenticator: ProxyAuthenticator<
|
||||
unknown,
|
||||
OAuth2ProxyResult
|
||||
>;
|
||||
|
||||
// @public
|
||||
export type OAuth2ProxyResult<JWTPayload = {}> = {
|
||||
fullProfile: JWTPayload;
|
||||
accessToken: string;
|
||||
headers: IncomingHttpHeaders;
|
||||
getHeader(name: string): string | undefined;
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-plugin-auth-backend-module-oauth2-proxy-provider
|
||||
title: '@backstage/plugin-auth-backend-module-oauth2-proxy-provider'
|
||||
description: The oauth2-proxy-provider backend module for the auth plugin.
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-backend-plugin-module
|
||||
owner: maintainers
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@backstage/plugin-auth-backend-module-oauth2-proxy-provider",
|
||||
"description": "The oauth2-proxy-provider backend module for the auth plugin.",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "backend-plugin-module"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"jose": "^4.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2023 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 { AuthenticationError } from '@backstage/errors';
|
||||
import {
|
||||
createProxyAuthenticator,
|
||||
getBearerTokenFromAuthorizationHeader,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { decodeJwt } from 'jose';
|
||||
import { OAuth2ProxyResult } from './types';
|
||||
|
||||
/**
|
||||
* NOTE: This may come in handy if you're doing work on this provider:
|
||||
* plugins/auth-backend/examples/docker-compose.oauth2-proxy.yaml
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const OAUTH2_PROXY_JWT_HEADER = 'X-OAUTH2-PROXY-ID-TOKEN';
|
||||
|
||||
/** @public */
|
||||
export const oauth2ProxyAuthenticator = createProxyAuthenticator<
|
||||
unknown,
|
||||
OAuth2ProxyResult
|
||||
>({
|
||||
defaultProfileTransform: async (result: OAuth2ProxyResult) => {
|
||||
return {
|
||||
profile: {
|
||||
email: result.getHeader('x-forwarded-email'),
|
||||
displayName:
|
||||
result.getHeader('x-forwarded-preferred-username') ||
|
||||
result.getHeader('x-forwarded-user'),
|
||||
},
|
||||
};
|
||||
},
|
||||
async initialize() {},
|
||||
async authenticate({ req }) {
|
||||
try {
|
||||
const authHeader = req.header(OAUTH2_PROXY_JWT_HEADER);
|
||||
const jwt = getBearerTokenFromAuthorizationHeader(authHeader);
|
||||
const decodedJWT = jwt && decodeJwt(jwt);
|
||||
|
||||
const result = {
|
||||
fullProfile: decodedJWT || {},
|
||||
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);
|
||||
},
|
||||
};
|
||||
return { result };
|
||||
} catch (e) {
|
||||
throw new AuthenticationError('Authentication failed', e);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The oauth2-proxy-provider backend module for the auth plugin.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export { authModuleOauth2ProxyProvider } from './module';
|
||||
export {
|
||||
oauth2ProxyAuthenticator,
|
||||
OAUTH2_PROXY_JWT_HEADER,
|
||||
} from './authenticator';
|
||||
export type { OAuth2ProxyResult } from './types';
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2023 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 { createBackendModule } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
authProvidersExtensionPoint,
|
||||
createProxyAuthProviderFactory,
|
||||
commonSignInResolvers,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { oauth2ProxyAuthenticator } from './authenticator';
|
||||
import { oauth2ProxySignInResolvers } from './resolvers';
|
||||
|
||||
/** @public */
|
||||
export const authModuleOauth2ProxyProvider = createBackendModule({
|
||||
pluginId: 'auth',
|
||||
moduleId: 'oauth2ProxyProvider',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {
|
||||
providers: authProvidersExtensionPoint,
|
||||
},
|
||||
async init({ providers }) {
|
||||
providers.registerProvider({
|
||||
providerId: 'oauth2ProxyProvider',
|
||||
factory: createProxyAuthProviderFactory({
|
||||
authenticator: oauth2ProxyAuthenticator,
|
||||
signInResolverFactories: {
|
||||
...commonSignInResolvers,
|
||||
...oauth2ProxySignInResolvers,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
createSignInResolverFactory,
|
||||
SignInInfo,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { OAuth2ProxyResult } from './types';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export namespace oauth2ProxySignInResolvers {
|
||||
export const forwardedUserMatchingUserEntityName =
|
||||
createSignInResolverFactory({
|
||||
create() {
|
||||
return async (info: SignInInfo<OAuth2ProxyResult>, ctx) => {
|
||||
const name = info.result.getHeader('x-forwarded-user');
|
||||
if (!name) {
|
||||
throw new Error('Request did not contain a user');
|
||||
}
|
||||
return ctx.signInWithCatalogUser({
|
||||
entityRef: { name },
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2023 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 { IncomingHttpHeaders } from 'http';
|
||||
|
||||
/**
|
||||
* JWT header extraction result, containing the raw value and the parsed JWT
|
||||
* payload.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type OAuth2ProxyResult<JWTPayload = {}> = {
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -3,8 +3,6 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="node" />
|
||||
|
||||
import { AuthProviderConfig as AuthProviderConfig_2 } from '@backstage/plugin-auth-node';
|
||||
import { AuthProviderFactory as AuthProviderFactory_2 } from '@backstage/plugin-auth-node';
|
||||
import { AuthProviderRouteHandlers as AuthProviderRouteHandlers_2 } from '@backstage/plugin-auth-node';
|
||||
@@ -23,8 +21,8 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import express from 'express';
|
||||
import { GcpIapResult as GcpIapResult_2 } from '@backstage/plugin-auth-backend-module-gcp-iap-provider';
|
||||
import { GcpIapTokenInfo as GcpIapTokenInfo_2 } from '@backstage/plugin-auth-backend-module-gcp-iap-provider';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { OAuth2ProxyResult as OAuth2ProxyResult_2 } from '@backstage/plugin-auth-backend-module-oauth2-proxy-provider';
|
||||
import { OAuthEnvironmentHandler as OAuthEnvironmentHandler_2 } from '@backstage/plugin-auth-node';
|
||||
import { OAuthState as OAuthState_2 } from '@backstage/plugin-auth-node';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
@@ -228,13 +226,8 @@ export type GithubOAuthResult = {
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type OAuth2ProxyResult<JWTPayload = {}> = {
|
||||
fullProfile: JWTPayload;
|
||||
accessToken: string;
|
||||
headers: IncomingHttpHeaders;
|
||||
getHeader(name: string): string | undefined;
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export type OAuth2ProxyResult = OAuth2ProxyResult_2;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export class OAuthAdapter implements AuthProviderRouteHandlers {
|
||||
@@ -560,9 +553,9 @@ export const providers: Readonly<{
|
||||
}>;
|
||||
oauth2Proxy: Readonly<{
|
||||
create: (options: {
|
||||
authHandler?: AuthHandler<OAuth2ProxyResult<unknown>> | undefined;
|
||||
authHandler?: AuthHandler<OAuth2ProxyResult_2> | undefined;
|
||||
signIn: {
|
||||
resolver: SignInResolver<OAuth2ProxyResult<unknown>>;
|
||||
resolver: SignInResolver<OAuth2ProxyResult_2>;
|
||||
};
|
||||
}) => AuthProviderFactory_2;
|
||||
resolvers: never;
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-okta-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/plugin-catalog-node": "workspace:^",
|
||||
|
||||
@@ -15,4 +15,10 @@
|
||||
*/
|
||||
|
||||
export { oauth2Proxy } from './provider';
|
||||
export type { OAuth2ProxyResult } from './provider';
|
||||
import { OAuth2ProxyResult as _OAuth2ProxyResult } from '@backstage/plugin-auth-backend-module-oauth2-proxy-provider';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-backend-module-oauth2-proxy-provider` instead
|
||||
*/
|
||||
export type OAuth2ProxyResult = _OAuth2ProxyResult;
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
/*
|
||||
* 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', () => ({
|
||||
decodeJwt: jest.fn(),
|
||||
}));
|
||||
jest.mock('@backstage/catalog-client');
|
||||
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import express from 'express';
|
||||
import * as jose from 'jose';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { AuthHandler, AuthResolverContext, SignInResolver } from '../types';
|
||||
import {
|
||||
oauth2Proxy,
|
||||
Oauth2ProxyAuthProvider,
|
||||
OAuth2ProxyResult,
|
||||
OAUTH2_PROXY_JWT_HEADER,
|
||||
} from './provider';
|
||||
|
||||
describe('Oauth2ProxyAuthProvider', () => {
|
||||
const mockToken =
|
||||
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob';
|
||||
|
||||
let provider: Oauth2ProxyAuthProvider<any>;
|
||||
let logger: jest.Mocked<LoggerService>;
|
||||
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 mockJwtDecode: jest.MockedFunction<typeof jose.decodeJwt>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
mockJwtDecode = jose.decodeJwt as jest.MockedFunction<
|
||||
typeof jose.decodeJwt
|
||||
>;
|
||||
authHandler = jest.fn();
|
||||
signInResolver = jest.fn();
|
||||
logger = { error: jest.fn() } as unknown as jest.Mocked<LoggerService>;
|
||||
|
||||
mockResponse = {
|
||||
status: jest.fn(),
|
||||
end: jest.fn(),
|
||||
json: jest.fn(),
|
||||
} as unknown as jest.Mocked<express.Response>;
|
||||
|
||||
mockRequest = {
|
||||
body: {},
|
||||
header: jest.fn(),
|
||||
headers: {
|
||||
'x-mock': 'mock',
|
||||
},
|
||||
} as unknown as jest.Mocked<express.Request>;
|
||||
|
||||
provider = new Oauth2ProxyAuthProvider<any>({
|
||||
authHandler,
|
||||
signInResolver,
|
||||
resolverContext: {
|
||||
_: 'resolver-context',
|
||||
} as unknown as AuthResolverContext,
|
||||
});
|
||||
});
|
||||
|
||||
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 expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow(
|
||||
AuthenticationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the bearer token is invalid', async () => {
|
||||
mockRequest.header.mockReturnValue('Basic asdf=');
|
||||
|
||||
await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow(
|
||||
AuthenticationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return if auth header is set and valid', async () => {
|
||||
mockRequest.header.mockReturnValue(`Bearer token`);
|
||||
authHandler.mockResolvedValue({
|
||||
profile: {},
|
||||
});
|
||||
signInResolver.mockResolvedValue({
|
||||
token: mockToken,
|
||||
});
|
||||
|
||||
await provider.refresh(mockRequest, mockResponse);
|
||||
|
||||
expect(mockRequest.header).toHaveBeenCalledWith(OAUTH2_PROXY_JWT_HEADER);
|
||||
expect(mockJwtDecode).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({
|
||||
token: mockToken,
|
||||
});
|
||||
authHandler.mockResolvedValue({ profile: profile });
|
||||
mockJwtDecode.mockReturnValue(decodedToken as any);
|
||||
|
||||
await provider.refresh(mockRequest, mockResponse);
|
||||
|
||||
expect(signInResolver).toHaveBeenCalledWith(
|
||||
{
|
||||
profile: profile,
|
||||
result: {
|
||||
accessToken: 'token',
|
||||
fullProfile: decodedToken,
|
||||
getHeader: expect.any(Function),
|
||||
headers: {
|
||||
'x-mock': 'mock',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ _: 'resolver-context' },
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
backstageIdentity: {
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/jimmymarkum',
|
||||
ownershipEntityRefs: ['user:default/jimmymarkum'],
|
||||
},
|
||||
token: mockToken,
|
||||
},
|
||||
profile: { displayName: 'some value' },
|
||||
providerInfo: {
|
||||
accessToken: 'token',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth2Proxy.create()', () => {
|
||||
beforeEach(() => {
|
||||
mockRequest.header.mockReturnValue(`Bearer token`);
|
||||
authHandler.mockResolvedValue({
|
||||
profile: {},
|
||||
});
|
||||
signInResolver.mockResolvedValue({
|
||||
token: mockToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a valid provider', async () => {
|
||||
const factory = oauth2Proxy.create({
|
||||
authHandler,
|
||||
signIn: { resolver: signInResolver },
|
||||
});
|
||||
const handler = factory({
|
||||
logger,
|
||||
catalogApi: {},
|
||||
tokenIssuer: {},
|
||||
} as any);
|
||||
await handler.refresh!(mockRequest, mockResponse);
|
||||
|
||||
expect(mockRequest.header).toHaveBeenCalledWith(OAUTH2_PROXY_JWT_HEADER);
|
||||
expect(mockJwtDecode).toHaveBeenCalledWith('token');
|
||||
expect(mockResponse.json).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,164 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
|
||||
import {
|
||||
AuthHandler,
|
||||
SignInResolver,
|
||||
AuthProviderRouteHandlers,
|
||||
AuthResponse,
|
||||
AuthResolverContext,
|
||||
AuthHandlerResult,
|
||||
} from '../types';
|
||||
import { decodeJwt } from 'jose';
|
||||
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
|
||||
import { createProxyAuthProviderFactory } from '@backstage/plugin-auth-node';
|
||||
import { AuthHandler, SignInResolver } from '../types';
|
||||
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';
|
||||
|
||||
/**
|
||||
* JWT header extraction result, containing the raw value and the parsed JWT
|
||||
* payload.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type OAuth2ProxyResult<JWTPayload = {}> = {
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
interface Options<JWTPayload> {
|
||||
resolverContext: AuthResolverContext;
|
||||
signInResolver: SignInResolver<OAuth2ProxyResult<JWTPayload>>;
|
||||
authHandler: AuthHandler<OAuth2ProxyResult<JWTPayload>>;
|
||||
}
|
||||
|
||||
export class Oauth2ProxyAuthProvider<JWTPayload>
|
||||
implements AuthProviderRouteHandlers
|
||||
{
|
||||
private readonly resolverContext: AuthResolverContext;
|
||||
private readonly signInResolver: SignInResolver<
|
||||
OAuth2ProxyResult<JWTPayload>
|
||||
>;
|
||||
private readonly authHandler: AuthHandler<OAuth2ProxyResult<JWTPayload>>;
|
||||
|
||||
constructor(options: Options<JWTPayload>) {
|
||||
this.resolverContext = options.resolverContext;
|
||||
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 {
|
||||
// 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) {
|
||||
throw new AuthenticationError('Refresh failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
private async handleResult(
|
||||
result: OAuth2ProxyResult<JWTPayload>,
|
||||
): Promise<AuthResponse<{ accessToken: string }>> {
|
||||
const { profile } = await this.authHandler(result, this.resolverContext);
|
||||
|
||||
const backstageSignInResult = await this.signInResolver(
|
||||
{
|
||||
result,
|
||||
profile,
|
||||
},
|
||||
this.resolverContext,
|
||||
);
|
||||
|
||||
return {
|
||||
providerInfo: {
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
backstageIdentity: prepareBackstageIdentityResponse(
|
||||
backstageSignInResult,
|
||||
),
|
||||
profile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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'),
|
||||
},
|
||||
};
|
||||
}
|
||||
import {
|
||||
type OAuth2ProxyResult,
|
||||
oauth2ProxyAuthenticator,
|
||||
} from '@backstage/plugin-auth-backend-module-oauth2-proxy-provider';
|
||||
|
||||
/**
|
||||
* Auth provider integration for oauth2-proxy auth
|
||||
@@ -179,7 +28,7 @@ async function defaultAuthHandler(
|
||||
* @public
|
||||
*/
|
||||
export const oauth2Proxy = createAuthProviderIntegration({
|
||||
create<JWTPayload>(options: {
|
||||
create(options: {
|
||||
/**
|
||||
* Configure an auth handler to generate a profile for the user.
|
||||
*
|
||||
@@ -187,7 +36,7 @@ export const oauth2Proxy = createAuthProviderIntegration({
|
||||
* 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>;
|
||||
|
||||
/**
|
||||
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
|
||||
@@ -196,17 +45,13 @@ export const oauth2Proxy = createAuthProviderIntegration({
|
||||
/**
|
||||
* Maps an auth result to a Backstage identity for the user.
|
||||
*/
|
||||
resolver: SignInResolver<OAuth2ProxyResult<JWTPayload>>;
|
||||
resolver: SignInResolver<OAuth2ProxyResult>;
|
||||
};
|
||||
}) {
|
||||
return ({ resolverContext }) => {
|
||||
const signInResolver = options.signIn.resolver;
|
||||
const authHandler = options.authHandler;
|
||||
return new Oauth2ProxyAuthProvider<JWTPayload>({
|
||||
resolverContext,
|
||||
signInResolver,
|
||||
authHandler: authHandler ?? defaultAuthHandler,
|
||||
});
|
||||
};
|
||||
return createProxyAuthProviderFactory({
|
||||
authenticator: oauth2ProxyAuthenticator,
|
||||
profileTransform: options?.authHandler,
|
||||
signInResolver: options?.signIn?.resolver,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4873,6 +4873,20 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@backstage/plugin-auth-backend-module-oauth2-proxy-provider@workspace:^, @backstage/plugin-auth-backend-module-oauth2-proxy-provider@workspace:plugins/auth-backend-module-oauth2-proxy-provider":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-auth-backend-module-oauth2-proxy-provider@workspace:plugins/auth-backend-module-oauth2-proxy-provider"
|
||||
dependencies:
|
||||
"@backstage/backend-common": "workspace:^"
|
||||
"@backstage/backend-plugin-api": "workspace:^"
|
||||
"@backstage/backend-test-utils": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/plugin-auth-node": "workspace:^"
|
||||
jose: ^4.6.0
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@backstage/plugin-auth-backend-module-okta-provider@workspace:^, @backstage/plugin-auth-backend-module-okta-provider@workspace:plugins/auth-backend-module-okta-provider":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-auth-backend-module-okta-provider@workspace:plugins/auth-backend-module-okta-provider"
|
||||
@@ -4957,6 +4971,7 @@ __metadata:
|
||||
"@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^"
|
||||
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^"
|
||||
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^"
|
||||
"@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^"
|
||||
"@backstage/plugin-auth-backend-module-okta-provider": "workspace:^"
|
||||
"@backstage/plugin-auth-node": "workspace:^"
|
||||
"@backstage/plugin-catalog-node": "workspace:^"
|
||||
|
||||
Reference in New Issue
Block a user