chore: move implementation to new plugin

Signed-off-by: djamaile <rdjamaile@gmail.com>
This commit is contained in:
djamaile
2023-11-20 20:01:15 +01:00
parent eb3cddbec5
commit 7ac25759a5
8 changed files with 97 additions and 377 deletions
+5
View File
@@ -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`
@@ -15,18 +15,21 @@
*/
import { AuthenticationError } from '@backstage/errors';
import { OAuth2ProxyResult } from '@backstage/plugin-auth-backend';
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
export const OAUTH2_PROXY_JWT_HEADER = 'X-OAUTH2-PROXY-ID-TOKEN';
export const oauth2ProxyAuthenticator = createProxyAuthenticator({
export const oauth2ProxyAuthenticator = createProxyAuthenticator<
unknown,
OAuth2ProxyResult
>({
defaultProfileTransform: async (result: OAuth2ProxyResult) => {
return {
profile: {
@@ -20,3 +20,8 @@
* @packageDocumentation
*/
export { authModuleOauth2ProxyProvider } from './module';
export {
oauth2ProxyAuthenticator,
OAUTH2_PROXY_JWT_HEADER,
} from './authenticator';
export type { OAuth2ProxyResult } from './types';
@@ -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;
};
+1
View File
@@ -45,6 +45,7 @@
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-okta-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/types": "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,
});
},
});