Merge pull request #19280 from backstage/rugvip/auth-migration

auth-backend: migrate to new backend system + new authenticators pattern
This commit is contained in:
Patrik Oldsberg
2023-08-16 09:47:52 +02:00
committed by GitHub
104 changed files with 5914 additions and 1347 deletions
+575
View File
@@ -3,8 +3,100 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstageIdentityResponse as BackstageIdentityResponse_2 } from '@backstage/plugin-auth-node';
import { BackstageSignInResult as BackstageSignInResult_2 } from '@backstage/plugin-auth-node';
import { Config } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
import { EntityFilterQuery } from '@backstage/catalog-client';
import express from 'express';
import { ExtensionPoint } from '@backstage/backend-plugin-api';
import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { LoggerService } from '@backstage/backend-plugin-api';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Profile } from 'passport';
import { Request as Request_2 } from 'express';
import { Response as Response_2 } from 'express';
import { Strategy } from 'passport';
import { ZodSchema } from 'zod';
import { ZodTypeDef } from 'zod';
// @public @deprecated (undocumented)
export type AuthProviderConfig = {
baseUrl: string;
appUrl: string;
isOriginAllowed: (origin: string) => boolean;
cookieConfigurer?: CookieConfigurer;
};
// @public (undocumented)
export type AuthProviderFactory = (options: {
providerId: string;
globalConfig: AuthProviderConfig;
config: Config;
logger: LoggerService;
resolverContext: AuthResolverContext;
baseUrl: string;
appUrl: string;
isOriginAllowed: (origin: string) => boolean;
cookieConfigurer?: CookieConfigurer;
}) => AuthProviderRouteHandlers;
// @public (undocumented)
export interface AuthProviderRegistrationOptions {
// (undocumented)
factory: AuthProviderFactory;
// (undocumented)
providerId: string;
}
// @public
export interface AuthProviderRouteHandlers {
frameHandler(req: Request_2, res: Response_2): Promise<void>;
logout?(req: Request_2, res: Response_2): Promise<void>;
refresh?(req: Request_2, res: Response_2): Promise<void>;
start(req: Request_2, res: Response_2): Promise<void>;
}
// @public (undocumented)
export interface AuthProvidersExtensionPoint {
// (undocumented)
registerProvider(options: AuthProviderRegistrationOptions): void;
}
// @public (undocumented)
export const authProvidersExtensionPoint: ExtensionPoint<AuthProvidersExtensionPoint>;
// @public
export type AuthResolverCatalogUserQuery =
| {
entityRef:
| string
| {
kind?: string;
namespace?: string;
name: string;
};
}
| {
annotations: Record<string, string>;
}
| {
filter: EntityFilterQuery;
};
// @public
export type AuthResolverContext = {
issueToken(params: TokenParams): Promise<{
token: string;
}>;
findCatalogUser(query: AuthResolverCatalogUserQuery): Promise<{
entity: Entity;
}>;
signInWithCatalogUser(
query: AuthResolverCatalogUserQuery,
): Promise<BackstageSignInResult>;
};
// @public
export interface BackstageIdentityResponse extends BackstageSignInResult {
@@ -23,6 +115,99 @@ export type BackstageUserIdentity = {
ownershipEntityRefs: string[];
};
// @public (undocumented)
export type ClientAuthResponse<TProviderInfo> = {
providerInfo: TProviderInfo;
profile: ProfileInfo;
backstageIdentity?: BackstageIdentityResponse;
};
// @public
export namespace commonSignInResolvers {
const emailMatchingUserEntityProfileEmail: SignInResolverFactory<
unknown,
unknown
>;
const emailLocalPartMatchingUserEntityName: SignInResolverFactory<
unknown,
unknown
>;
}
// @public
export type CookieConfigurer = (ctx: {
providerId: string;
baseUrl: string;
callbackUrl: string;
appOrigin: string;
}) => {
domain: string;
path: string;
secure: boolean;
sameSite?: 'none' | 'lax' | 'strict';
};
// @public (undocumented)
export function createOAuthAuthenticator<TContext, TProfile>(
authenticator: OAuthAuthenticator<TContext, TProfile>,
): OAuthAuthenticator<TContext, TProfile>;
// @public (undocumented)
export function createOAuthProviderFactory<TProfile>(options: {
authenticator: OAuthAuthenticator<unknown, TProfile>;
stateTransform?: OAuthStateTransform;
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
signInResolverFactories?: {
[name in string]: SignInResolverFactory<
OAuthAuthenticatorResult<TProfile>,
unknown
>;
};
}): AuthProviderFactory;
// @public (undocumented)
export function createOAuthRouteHandlers<TProfile>(
options: OAuthRouteHandlersOptions<TProfile>,
): AuthProviderRouteHandlers;
// @public (undocumented)
export function createProxyAuthenticator<TContext, TResult>(
authenticator: ProxyAuthenticator<TContext, TResult>,
): ProxyAuthenticator<TContext, TResult>;
// @public (undocumented)
export function createProxyAuthProviderFactory<TResult>(options: {
authenticator: ProxyAuthenticator<unknown, TResult>;
profileTransform?: ProfileTransform<TResult>;
signInResolver?: SignInResolver<TResult>;
signInResolverFactories?: Record<
string,
SignInResolverFactory<TResult, unknown>
>;
}): AuthProviderFactory;
// @public (undocumented)
export function createProxyAuthRouteHandlers<TResult>(
options: ProxyAuthRouteHandlersOptions<TResult>,
): AuthProviderRouteHandlers;
// @public (undocumented)
export function createSignInResolverFactory<
TAuthResult,
TOptionsOutput,
TOptionsInput,
>(
options: SignInResolverFactoryOptions<
TAuthResult,
TOptionsOutput,
TOptionsInput
>,
): SignInResolverFactory<TAuthResult, TOptionsInput>;
// @public (undocumented)
export function decodeOAuthState(encodedState: string): OAuthState;
// @public
export class DefaultIdentityClient implements IdentityApi {
// @deprecated
@@ -34,6 +219,9 @@ export class DefaultIdentityClient implements IdentityApi {
): Promise<BackstageIdentityResponse | undefined>;
}
// @public (undocumented)
export function encodeOAuthState(state: OAuthState): string;
// @public
export function getBearerTokenFromAuthorizationHeader(
authorizationHeader: unknown,
@@ -65,4 +253,391 @@ export type IdentityClientOptions = {
issuer?: string;
algorithms?: string[];
};
// @public (undocumented)
export interface OAuthAuthenticator<TContext, TProfile> {
// (undocumented)
authenticate(
input: OAuthAuthenticatorAuthenticateInput,
ctx: TContext,
): Promise<OAuthAuthenticatorResult<TProfile>>;
// (undocumented)
defaultProfileTransform: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
// (undocumented)
initialize(ctx: { callbackUrl: string; config: Config }): TContext;
// (undocumented)
logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise<void>;
// (undocumented)
refresh(
input: OAuthAuthenticatorRefreshInput,
ctx: TContext,
): Promise<OAuthAuthenticatorResult<TProfile>>;
// (undocumented)
shouldPersistScopes?: boolean;
// (undocumented)
start(
input: OAuthAuthenticatorStartInput,
ctx: TContext,
): Promise<{
url: string;
status?: number;
}>;
}
// @public (undocumented)
export interface OAuthAuthenticatorAuthenticateInput {
// (undocumented)
req: Request_2;
}
// @public (undocumented)
export interface OAuthAuthenticatorLogoutInput {
// (undocumented)
accessToken?: string;
// (undocumented)
refreshToken?: string;
// (undocumented)
req: Request_2;
}
// @public (undocumented)
export interface OAuthAuthenticatorRefreshInput {
// (undocumented)
refreshToken: string;
// (undocumented)
req: Request_2;
// (undocumented)
scope: string;
}
// @public (undocumented)
export interface OAuthAuthenticatorResult<TProfile> {
// (undocumented)
fullProfile: TProfile;
// (undocumented)
session: OAuthSession;
}
// @public (undocumented)
export interface OAuthAuthenticatorStartInput {
// (undocumented)
req: Request_2;
// (undocumented)
scope: string;
// (undocumented)
state: string;
}
// @public (undocumented)
export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
constructor(handlers: Map<string, AuthProviderRouteHandlers>);
// (undocumented)
frameHandler(req: express.Request, res: express.Response): Promise<void>;
// (undocumented)
logout(req: express.Request, res: express.Response): Promise<void>;
// (undocumented)
static mapConfig(
config: Config,
factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers,
): OAuthEnvironmentHandler;
// (undocumented)
refresh(req: express.Request, res: express.Response): Promise<void>;
// (undocumented)
start(req: express.Request, res: express.Response): Promise<void>;
}
// @public (undocumented)
export interface OAuthRouteHandlersOptions<TProfile> {
// (undocumented)
appUrl: string;
// (undocumented)
authenticator: OAuthAuthenticator<any, TProfile>;
// (undocumented)
baseUrl: string;
// (undocumented)
config: Config;
// (undocumented)
cookieConfigurer?: CookieConfigurer;
// (undocumented)
isOriginAllowed: (origin: string) => boolean;
// (undocumented)
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
// (undocumented)
providerId: string;
// (undocumented)
resolverContext: AuthResolverContext;
// (undocumented)
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
// (undocumented)
stateTransform?: OAuthStateTransform;
}
// @public (undocumented)
export interface OAuthSession {
// (undocumented)
accessToken: string;
// (undocumented)
expiresInSeconds: number;
// (undocumented)
idToken?: string;
// (undocumented)
refreshToken?: string;
// (undocumented)
scope: string;
// (undocumented)
tokenType: string;
}
// @public
export type OAuthState = {
nonce: string;
env: string;
origin?: string;
scope?: string;
redirectUrl?: string;
flow?: string;
};
// @public (undocumented)
export type OAuthStateTransform = (
state: OAuthState,
context: {
req: Request_2;
},
) => Promise<{
state: OAuthState;
}>;
// @public (undocumented)
export type PassportDoneCallback<TResult, TPrivateInfo = never> = (
err?: Error,
result?: TResult,
privateInfo?: TPrivateInfo,
) => void;
// @public (undocumented)
export class PassportHelpers {
// (undocumented)
static executeFetchUserProfileStrategy(
providerStrategy: Strategy,
accessToken: string,
): Promise<PassportProfile>;
// (undocumented)
static executeFrameHandlerStrategy<TResult, TPrivateInfo = never>(
req: Request_2,
providerStrategy: Strategy,
options?: Record<string, string>,
): Promise<{
result: TResult;
privateInfo: TPrivateInfo;
}>;
// (undocumented)
static executeRedirectStrategy(
req: Request_2,
providerStrategy: Strategy,
options: Record<string, string>,
): Promise<{
url: string;
status?: number;
}>;
// (undocumented)
static executeRefreshTokenStrategy(
providerStrategy: Strategy,
refreshToken: string,
scope: string,
): Promise<{
accessToken: string;
refreshToken?: string;
params: any;
}>;
// (undocumented)
static transformProfile: (
profile: PassportProfile,
idToken?: string,
) => ProfileInfo;
}
// @public (undocumented)
export class PassportOAuthAuthenticatorHelper {
// (undocumented)
authenticate(
input: OAuthAuthenticatorAuthenticateInput,
): Promise<OAuthAuthenticatorResult<PassportProfile>>;
// (undocumented)
static defaultProfileTransform: ProfileTransform<
OAuthAuthenticatorResult<PassportProfile>
>;
// (undocumented)
fetchProfile(accessToken: string): Promise<PassportProfile>;
// (undocumented)
static from(strategy: Strategy): PassportOAuthAuthenticatorHelper;
// (undocumented)
refresh(
input: OAuthAuthenticatorRefreshInput,
): Promise<OAuthAuthenticatorResult<PassportProfile>>;
// (undocumented)
start(
input: OAuthAuthenticatorStartInput,
options: Record<string, string>,
): Promise<{
url: string;
status?: number;
}>;
}
// @public (undocumented)
export type PassportOAuthDoneCallback = PassportDoneCallback<
PassportOAuthResult,
PassportOAuthPrivateInfo
>;
// @public (undocumented)
export type PassportOAuthPrivateInfo = {
refreshToken?: string;
};
// @public (undocumented)
export type PassportOAuthResult = {
fullProfile: PassportProfile;
params: {
id_token?: string;
scope: string;
token_type?: string;
expires_in: number;
};
accessToken: string;
};
// @public (undocumented)
export type PassportProfile = Profile & {
avatarUrl?: string;
};
// @public
export function prepareBackstageIdentityResponse(
result: BackstageSignInResult_2,
): BackstageIdentityResponse_2;
// @public
export type ProfileInfo = {
email?: string;
displayName?: string;
picture?: string;
};
// @public
export type ProfileTransform<TResult> = (
result: TResult,
context: AuthResolverContext,
) => Promise<{
profile: ProfileInfo;
}>;
// @public (undocumented)
export interface ProxyAuthenticator<TContext, TResult> {
// (undocumented)
authenticate(
options: {
req: Request_2;
},
ctx: TContext,
): Promise<{
result: TResult;
}>;
// (undocumented)
defaultProfileTransform: ProfileTransform<TResult>;
// (undocumented)
initialize(ctx: { config: Config }): Promise<TContext>;
}
// @public (undocumented)
export interface ProxyAuthRouteHandlersOptions<TResult> {
// (undocumented)
authenticator: ProxyAuthenticator<any, TResult>;
// (undocumented)
config: Config;
// (undocumented)
profileTransform?: ProfileTransform<TResult>;
// (undocumented)
resolverContext: AuthResolverContext;
// (undocumented)
signInResolver: SignInResolver<TResult>;
}
// @public (undocumented)
export function readDeclarativeSignInResolver<TAuthResult>(
options: ReadDeclarativeSignInResolverOptions<TAuthResult>,
): SignInResolver<TAuthResult> | undefined;
// @public (undocumented)
export interface ReadDeclarativeSignInResolverOptions<TAuthResult> {
// (undocumented)
config: Config;
// (undocumented)
signInResolverFactories: {
[name in string]: SignInResolverFactory<TAuthResult, unknown>;
};
}
// @public (undocumented)
export function sendWebMessageResponse(
res: Response_2,
appOrigin: string,
response: WebMessageResponse,
): void;
// @public
export type SignInInfo<TAuthResult> = {
profile: ProfileInfo;
result: TAuthResult;
};
// @public
export type SignInResolver<TAuthResult> = (
info: SignInInfo<TAuthResult>,
context: AuthResolverContext,
) => Promise<BackstageSignInResult>;
// @public (undocumented)
export interface SignInResolverFactory<TAuthResult, TOptions> {
// (undocumented)
(
...options: undefined extends TOptions
? [options?: TOptions]
: [options: TOptions]
): SignInResolver<TAuthResult>;
// (undocumented)
optionsJsonSchema?: JsonObject;
}
// @public (undocumented)
export interface SignInResolverFactoryOptions<
TAuthResult,
TOptionsOutput,
TOptionsInput,
> {
// (undocumented)
create(options: TOptionsOutput): SignInResolver<TAuthResult>;
// (undocumented)
optionsSchema?: ZodSchema<TOptionsOutput, ZodTypeDef, TOptionsInput>;
}
// @public
export type TokenParams = {
claims: {
sub: string;
ent?: string[];
} & Record<string, JsonValue>;
};
// @public
export type WebMessageResponse =
| {
type: 'authorization_response';
response: ClientAuthResponse<unknown>;
}
| {
type: 'authorization_response';
error: Error;
};
```
+13 -1
View File
@@ -29,19 +29,31 @@
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/types": "workspace:^",
"@types/express": "*",
"@types/passport": "^1.0.3",
"express": "^4.17.1",
"jose": "^4.6.0",
"lodash": "^4.17.21",
"node-fetch": "^2.6.7",
"winston": "^3.2.1"
"passport": "^0.6.0",
"winston": "^3.2.1",
"zod": "^3.21.4",
"zod-to-json-schema": "^3.21.4"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"cookie-parser": "^1.4.6",
"express-promise-router": "^4.1.1",
"lodash": "^4.17.21",
"msw": "^1.0.0",
"supertest": "^6.1.3",
"uuid": "^8.0.0"
},
"files": [
@@ -0,0 +1,35 @@
/*
* 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 { createExtensionPoint } from '@backstage/backend-plugin-api';
import { AuthProviderFactory } from '../types';
/** @public */
export interface AuthProviderRegistrationOptions {
providerId: string;
factory: AuthProviderFactory;
}
/** @public */
export interface AuthProvidersExtensionPoint {
registerProvider(options: AuthProviderRegistrationOptions): void;
}
/** @public */
export const authProvidersExtensionPoint =
createExtensionPoint<AuthProvidersExtensionPoint>({
id: 'auth.providers',
});
+21
View File
@@ -0,0 +1,21 @@
/*
* 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.
*/
export {
authProvidersExtensionPoint,
type AuthProviderRegistrationOptions,
type AuthProvidersExtensionPoint,
} from './AuthProvidersExtensionPoint';
@@ -0,0 +1,28 @@
/*
* 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 { WebMessageResponse } from '../sendWebMessageResponse';
export function parseWebMessageResponse(text: string): {
response: WebMessageResponse;
origin: string;
} {
const [response, origin] = text.matchAll(/decodeURIComponent\('(.+?)'\)/g);
return {
response: JSON.parse(decodeURIComponent(response[1])),
origin: decodeURIComponent(origin[1]),
};
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2020 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 {
sendWebMessageResponse,
type WebMessageResponse,
} from './sendWebMessageResponse';
@@ -0,0 +1,194 @@
/*
* Copyright 2020 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 {
safelyEncodeURIComponent,
sendWebMessageResponse,
type WebMessageResponse,
} from './sendWebMessageResponse';
import { parseWebMessageResponse } from './__testUtils__/parseWebMessageResponse';
describe('oauth helpers', () => {
describe('safelyEncodeURIComponent', () => {
it('encodes all occurrences of single quotes', () => {
expect(safelyEncodeURIComponent("a'ö'b")).toBe('a%27%C3%B6%27b');
});
});
describe('sendWebMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = {
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
},
backstageIdentity: {
token: 'a.b.c',
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: 'a',
},
},
},
};
const encoded = safelyEncodeURIComponent(JSON.stringify(data));
sendWebMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toHaveBeenCalledTimes(3);
expect(mockResponse.end).toHaveBeenCalledTimes(1);
expect(mockResponse.end).toHaveBeenCalledWith(
expect.stringContaining(encoded),
);
});
it('should post a message back with payload error', () => {
const mockResponse = {
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
error: new Error('Unknown error occurred'),
};
const encoded = safelyEncodeURIComponent(
JSON.stringify({
type: 'authorization_response',
error: { name: 'Error', message: 'Unknown error occurred' },
}),
);
sendWebMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toHaveBeenCalledTimes(3);
expect(mockResponse.end).toHaveBeenCalledTimes(1);
expect(mockResponse.end).toHaveBeenCalledWith(
expect.stringContaining(encoded),
);
});
it('should call postMessage twice but only one of them with target *', () => {
let responseBody = '';
const mockResponse = {
end: jest.fn(body => {
responseBody = body;
return this;
}),
setHeader: jest.fn().mockReturnThis(),
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
},
backstageIdentity: {
token: 'a.b.c',
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: 'a',
},
},
},
};
sendWebMessageResponse(mockResponse, appOrigin, data);
expect(parseWebMessageResponse(responseBody).response).toEqual({
type: 'authorization_response',
response: {
providerInfo: expect.any(Object),
profile: {
email: 'foo@bar.com',
},
backstageIdentity: expect.any(Object),
},
});
const errData: WebMessageResponse = {
type: 'authorization_response',
error: new Error('Unknown error occurred'),
};
sendWebMessageResponse(mockResponse, appOrigin, errData);
expect(parseWebMessageResponse(responseBody).response).toEqual({
type: 'authorization_response',
error: {
name: 'Error',
message: 'Unknown error occurred',
},
});
});
it('handles single quotes and unicode chars safely', () => {
const mockResponse = {
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
displayName: "Adam l'Hôpital",
},
backstageIdentity: {
token: 'a.b.c',
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: 'a',
},
},
},
};
sendWebMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toHaveBeenCalledTimes(3);
expect(mockResponse.end).toHaveBeenCalledTimes(1);
expect(mockResponse.end).toHaveBeenCalledWith(
expect.stringContaining('Adam%20l%27H%C3%B4pital'),
);
});
});
});
@@ -0,0 +1,93 @@
/*
* Copyright 2020 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 { Response } from 'express';
import crypto from 'crypto';
import { ClientAuthResponse } from '../types';
import { serializeError } from '@backstage/errors';
/**
* Payload sent as a post message after the auth request is complete.
* If successful then has a valid payload with Auth information else contains an error.
*
* @public
*/
export type WebMessageResponse =
| {
type: 'authorization_response';
response: ClientAuthResponse<unknown>;
}
| {
type: 'authorization_response';
error: Error;
};
/** @internal */
export function safelyEncodeURIComponent(value: string): string {
// Note the g at the end of the regex; all occurrences of single quotes must
// be replaced, which encodeURIComponent does not do itself by default
return encodeURIComponent(value).replace(/'/g, '%27');
}
/** @public */
export function sendWebMessageResponse(
res: Response,
appOrigin: string,
response: WebMessageResponse,
): void {
const jsonData = JSON.stringify(response, (_, value) => {
if (value instanceof Error) {
return serializeError(value);
}
return value;
});
const base64Data = safelyEncodeURIComponent(jsonData);
const base64Origin = safelyEncodeURIComponent(appOrigin);
// NOTE: It is absolutely imperative that we use the safe encoder above, to
// be sure that the js code below does not allow the injection of malicious
// data.
// TODO: Make target app origin configurable globally
//
// postMessage fails silently if the targetOrigin is disallowed.
// So 2 postMessages are sent from the popup to the parent window.
// First, the origin being used to post the actual authorization response is
// shared with the parent window with a postMessage with targetOrigin '*'.
// Second, the actual authorization response is sent with the app origin
// as the targetOrigin.
// If the first message was received but the actual auth response was
// never received, the event listener can conclude that targetOrigin
// was disallowed, indicating potential misconfiguration.
//
const script = `
var authResponse = decodeURIComponent('${base64Data}');
var origin = decodeURIComponent('${base64Origin}');
var originInfo = {'type': 'config_info', 'targetOrigin': origin};
(window.opener || window.parent).postMessage(originInfo, '*');
(window.opener || window.parent).postMessage(JSON.parse(authResponse), origin);
setTimeout(() => {
window.close();
}, 100); // same as the interval of the core-app-api lib/loginPopup.ts (to address race conditions)
`;
const hash = crypto.createHash('sha256').update(script).digest('base64');
res.setHeader('Content-Type', 'text/html');
res.setHeader('X-Frame-Options', 'sameorigin');
res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`);
res.end(`<html><body><script>${script}</script></body></html>`);
}
@@ -26,7 +26,7 @@ import { setupServer } from 'msw/node';
import { v4 as uuid } from 'uuid';
import { DefaultIdentityClient } from './DefaultIdentityClient';
import { IdentityApiGetIdentityRequest } from './types';
import { IdentityApiGetIdentityRequest } from './IdentityApi';
interface AnyJWK extends Record<string, string> {
use: 'sig';
@@ -25,12 +25,9 @@ import {
jwtVerify,
} from 'jose';
import { GetKeyFunction } from 'jose/dist/types/types';
import {
BackstageIdentityResponse,
IdentityApiGetIdentityRequest,
} from './types';
import { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader';
import { IdentityApi } from './IdentityApi';
import { IdentityApi, IdentityApiGetIdentityRequest } from './IdentityApi';
import { BackstageIdentityResponse } from '../types';
const CLOCK_MARGIN_S = 10;
@@ -13,10 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
BackstageIdentityResponse,
IdentityApiGetIdentityRequest,
} from './types';
import { Request } from 'express';
import { BackstageIdentityResponse } from '../types';
/**
* Options to request the identity from a Backstage backend request
*
* @public
*/
export type IdentityApiGetIdentityRequest = {
request: Request<unknown>;
};
/**
* An identity client api to authenticate Backstage
@@ -18,7 +18,7 @@ import {
DefaultIdentityClient,
IdentityClientOptions,
} from './DefaultIdentityClient';
import { BackstageIdentityResponse } from './types';
import { BackstageIdentityResponse } from '../types';
/**
* An identity client to interact with auth-backend and authenticate Backstage
+24
View File
@@ -0,0 +1,24 @@
/*
* 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.
*/
export { prepareBackstageIdentityResponse } from './prepareBackstageIdentityResponse';
export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader';
export {
DefaultIdentityClient,
type IdentityClientOptions,
} from './DefaultIdentityClient';
export { IdentityClient } from './IdentityClient';
export type { IdentityApi, IdentityApiGetIdentityRequest } from './IdentityApi';
@@ -0,0 +1,41 @@
/*
* Copyright 2020 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 { prepareBackstageIdentityResponse } from './prepareBackstageIdentityResponse';
function mkToken(payload: unknown) {
return `a.${Buffer.from(JSON.stringify(payload), 'utf8').toString(
'base64',
)}.z`;
}
describe('prepareBackstageIdentityResponse', () => {
it('parses a complete token to determine the identity', () => {
const token = mkToken({ sub: 'k:ns/n', ent: ['k:ns/o'] });
expect(
prepareBackstageIdentityResponse({
token,
}),
).toEqual({
token,
identity: {
type: 'user',
userEntityRef: 'k:ns/n',
ownershipEntityRefs: ['k:ns/o'],
},
});
});
});
@@ -0,0 +1,52 @@
/*
* Copyright 2020 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 { InputError } from '@backstage/errors';
import {
BackstageIdentityResponse,
BackstageSignInResult,
} from '@backstage/plugin-auth-node';
function parseJwtPayload(token: string) {
const [_header, payload, _signature] = token.split('.');
return JSON.parse(Buffer.from(payload, 'base64').toString());
}
/**
* Parses a Backstage-issued token and decorates the
* {@link @backstage/plugin-auth-node#BackstageIdentityResponse} with identity information sourced from the
* token.
*
* @public
*/
export function prepareBackstageIdentityResponse(
result: BackstageSignInResult,
): BackstageIdentityResponse {
if (!result.token) {
throw new InputError(`Identity response must return a token`);
}
const { sub, ent } = parseJwtPayload(result.token);
return {
...result,
identity: {
type: 'user',
userEntityRef: sub,
ownershipEntityRefs: ent ?? [],
},
};
}
+19 -6
View File
@@ -20,14 +20,27 @@
* @packageDocumentation
*/
export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader';
export { DefaultIdentityClient } from './DefaultIdentityClient';
export { IdentityClient } from './IdentityClient';
export type { IdentityApi } from './IdentityApi';
export type { IdentityClientOptions } from './DefaultIdentityClient';
export * from './extensions';
export * from './flow';
export * from './identity';
export * from './oauth';
export * from './passport';
export * from './proxy';
export * from './sign-in';
export type {
AuthProviderConfig,
AuthProviderRouteHandlers,
AuthProviderFactory,
AuthResolverCatalogUserQuery,
AuthResolverContext,
BackstageIdentityResponse,
BackstageSignInResult,
BackstageUserIdentity,
IdentityApiGetIdentityRequest,
ClientAuthResponse,
CookieConfigurer,
ProfileInfo,
ProfileTransform,
SignInInfo,
SignInResolver,
TokenParams,
} from './types';
@@ -0,0 +1,127 @@
/*
* 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 { Request, Response } from 'express';
import { CookieConfigurer } from '../types';
const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
const TEN_MINUTES_MS = 600 * 1000;
const defaultCookieConfigurer: CookieConfigurer = ({
callbackUrl,
providerId,
appOrigin,
}) => {
const { hostname: domain, pathname, protocol } = new URL(callbackUrl);
const secure = protocol === 'https:';
// For situations where the auth-backend is running on a
// different domain than the app, we set the SameSite attribute
// to 'none' to allow third-party access to the cookie, but
// only if it's in a secure context (https).
let sameSite: ReturnType<CookieConfigurer>['sameSite'] = 'lax';
if (new URL(appOrigin).hostname !== domain && secure) {
sameSite = 'none';
}
// If the provider supports callbackUrls, the pathname will
// contain the complete path to the frame handler so we need
// to slice off the trailing part of the path.
const path = pathname.endsWith(`${providerId}/handler/frame`)
? pathname.slice(0, -'/handler/frame'.length)
: `${pathname}/${providerId}`;
return { domain, path, secure, sameSite };
};
/** @internal */
export class OAuthCookieManager {
private readonly cookieConfigurer: CookieConfigurer;
private readonly nonceCookie: string;
private readonly refreshTokenCookie: string;
private readonly grantedScopeCookie: string;
constructor(
private readonly options: {
providerId: string;
defaultAppOrigin: string;
baseUrl: string;
callbackUrl: string;
cookieConfigurer?: CookieConfigurer;
},
) {
this.cookieConfigurer = options.cookieConfigurer ?? defaultCookieConfigurer;
this.nonceCookie = `${options.providerId}-nonce`;
this.refreshTokenCookie = `${options.providerId}-refresh-token`;
this.grantedScopeCookie = `${options.providerId}-granted-scope`;
}
private getConfig(origin?: string, pathSuffix: string = '') {
const cookieConfig = this.cookieConfigurer({
providerId: this.options.providerId,
baseUrl: this.options.baseUrl,
callbackUrl: this.options.callbackUrl,
appOrigin: origin ?? this.options.defaultAppOrigin,
});
return {
httpOnly: true,
sameSite: 'lax' as const,
...cookieConfig,
path: cookieConfig.path + pathSuffix,
};
}
setNonce(res: Response, nonce: string, origin?: string) {
res.cookie(this.nonceCookie, nonce, {
maxAge: TEN_MINUTES_MS,
...this.getConfig(origin, '/handler'),
});
}
setRefreshToken(res: Response, refreshToken: string, origin?: string) {
res.cookie(this.refreshTokenCookie, refreshToken, {
maxAge: THOUSAND_DAYS_MS,
...this.getConfig(origin),
});
}
removeRefreshToken(res: Response, origin?: string) {
res.cookie(this.refreshTokenCookie, '', {
maxAge: 0,
...this.getConfig(origin),
});
}
setGrantedScopes(res: Response, scope: string, origin?: string) {
res.cookie(this.grantedScopeCookie, scope, {
maxAge: THOUSAND_DAYS_MS,
...this.getConfig(origin),
});
}
getNonce(req: Request) {
return req.cookies[this.nonceCookie];
}
getRefreshToken(req: Request) {
return req.cookies[this.refreshTokenCookie];
}
getGrantedScopes(req: Request) {
return req.cookies[this.grantedScopeCookie];
}
}
@@ -0,0 +1,97 @@
/*
* Copyright 2020 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 { Config } from '@backstage/config';
import { InputError, NotFoundError } from '@backstage/errors';
import { AuthProviderRouteHandlers } from '../types';
import { decodeOAuthState } from './state';
/** @public */
export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
static mapConfig(
config: Config,
factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers,
) {
const envs = config.keys();
const handlers = new Map<string, AuthProviderRouteHandlers>();
for (const env of envs) {
const envConfig = config.getConfig(env);
const handler = factoryFunc(envConfig);
handlers.set(env, handler);
}
return new OAuthEnvironmentHandler(handlers);
}
constructor(
private readonly handlers: Map<string, AuthProviderRouteHandlers>,
) {}
async start(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
await provider.start(req, res);
}
async frameHandler(
req: express.Request,
res: express.Response,
): Promise<void> {
const provider = this.getProviderForEnv(req);
await provider.frameHandler(req, res);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
await provider.refresh?.(req, res);
}
async logout(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
await provider.logout?.(req, res);
}
private getEnvFromRequest(req: express.Request): string | undefined {
const reqEnv = req.query.env?.toString();
if (reqEnv) {
return reqEnv;
}
const stateParams = req.query.state?.toString();
if (!stateParams) {
return undefined;
}
const { env } = decodeOAuthState(stateParams);
return env;
}
private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers {
const env: string | undefined = this.getEnvFromRequest(req);
if (!env) {
throw new InputError(`Must specify 'env' query to select environment`);
}
const handler = this.handlers.get(env);
if (!handler) {
throw new NotFoundError(
`No configuration available for the '${env}' environment of this provider.`,
);
}
return handler;
}
}
@@ -0,0 +1,137 @@
/*
* 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 { Strategy } from 'passport';
import {
PassportDoneCallback,
PassportHelpers,
PassportProfile,
} from '../passport';
import { ProfileTransform } from '../types';
import {
OAuthAuthenticatorAuthenticateInput,
OAuthAuthenticatorRefreshInput,
OAuthAuthenticatorResult,
OAuthAuthenticatorStartInput,
} from './types';
/** @public */
export type PassportOAuthResult = {
fullProfile: PassportProfile;
params: {
id_token?: string;
scope: string;
token_type?: string;
expires_in: number;
};
accessToken: string;
};
/** @public */
export type PassportOAuthPrivateInfo = {
refreshToken?: string;
};
/** @public */
export type PassportOAuthDoneCallback = PassportDoneCallback<
PassportOAuthResult,
PassportOAuthPrivateInfo
>;
/** @public */
export class PassportOAuthAuthenticatorHelper {
static defaultProfileTransform: ProfileTransform<
OAuthAuthenticatorResult<PassportProfile>
> = async input => ({
profile: PassportHelpers.transformProfile(
input.fullProfile,
input.session.idToken,
),
});
static from(strategy: Strategy) {
return new PassportOAuthAuthenticatorHelper(strategy);
}
readonly #strategy: Strategy;
private constructor(strategy: Strategy) {
this.#strategy = strategy;
}
async start(
input: OAuthAuthenticatorStartInput,
options: Record<string, string>,
): Promise<{ url: string; status?: number }> {
return PassportHelpers.executeRedirectStrategy(input.req, this.#strategy, {
scope: input.scope,
state: input.state,
...options,
});
}
async authenticate(
input: OAuthAuthenticatorAuthenticateInput,
): Promise<OAuthAuthenticatorResult<PassportProfile>> {
const { result, privateInfo } =
await PassportHelpers.executeFrameHandlerStrategy<
PassportOAuthResult,
PassportOAuthPrivateInfo
>(input.req, this.#strategy);
return {
fullProfile: result.fullProfile as PassportProfile,
session: {
accessToken: result.accessToken,
tokenType: result.params.token_type ?? 'bearer',
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
idToken: result.params.id_token,
refreshToken: privateInfo.refreshToken,
},
};
}
async refresh(
input: OAuthAuthenticatorRefreshInput,
): Promise<OAuthAuthenticatorResult<PassportProfile>> {
const result = await PassportHelpers.executeRefreshTokenStrategy(
this.#strategy,
input.refreshToken,
input.scope,
);
const fullProfile = await this.fetchProfile(result.accessToken);
return {
fullProfile,
session: {
accessToken: result.accessToken,
tokenType: result.params.token_type ?? 'bearer',
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
idToken: result.params.id_token,
refreshToken: result.refreshToken,
},
};
}
async fetchProfile(accessToken: string): Promise<PassportProfile> {
const profile = await PassportHelpers.executeFetchUserProfileStrategy(
this.#strategy,
accessToken,
);
return profile;
}
}
@@ -0,0 +1,66 @@
/*
* 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 { readDeclarativeSignInResolver } from '../sign-in';
import {
AuthProviderFactory,
ProfileTransform,
SignInResolver,
} from '../types';
import { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler';
import { createOAuthRouteHandlers } from './createOAuthRouteHandlers';
import { OAuthStateTransform } from './state';
import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types';
import { SignInResolverFactory } from '../sign-in/createSignInResolverFactory';
/** @public */
export function createOAuthProviderFactory<TProfile>(options: {
authenticator: OAuthAuthenticator<unknown, TProfile>;
stateTransform?: OAuthStateTransform;
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
signInResolverFactories?: {
[name in string]: SignInResolverFactory<
OAuthAuthenticatorResult<TProfile>,
unknown
>;
};
}): AuthProviderFactory {
return ctx => {
return OAuthEnvironmentHandler.mapConfig(ctx.config, envConfig => {
const signInResolver =
options.signInResolver ??
readDeclarativeSignInResolver({
config: envConfig,
signInResolverFactories: options.signInResolverFactories ?? {},
});
return createOAuthRouteHandlers<TProfile>({
authenticator: options.authenticator,
appUrl: ctx.appUrl,
baseUrl: ctx.baseUrl,
config: envConfig,
isOriginAllowed: ctx.isOriginAllowed,
cookieConfigurer: ctx.cookieConfigurer,
providerId: ctx.providerId,
resolverContext: ctx.resolverContext,
stateTransform: options.stateTransform,
profileTransform: options.profileTransform,
signInResolver,
});
});
};
}
@@ -0,0 +1,742 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import express from 'express';
import request, { SuperAgentTest } from 'supertest';
import cookieParser from 'cookie-parser';
import PromiseRouter from 'express-promise-router';
import { AuthProviderRouteHandlers, AuthResolverContext } from '../types';
import { createOAuthRouteHandlers } from './createOAuthRouteHandlers';
import { OAuthAuthenticator } from './types';
import { errorHandler } from '@backstage/backend-common';
import { encodeOAuthState, OAuthState } from './state';
import { PassportProfile } from '../passport';
import { parseWebMessageResponse } from '../flow/__testUtils__/parseWebMessageResponse';
const mockAuthenticator: jest.Mocked<OAuthAuthenticator<unknown, unknown>> = {
initialize: jest.fn(_r => ({ ctx: 'authenticator' })),
start: jest.fn(),
authenticate: jest.fn(),
refresh: jest.fn(),
logout: jest.fn(),
defaultProfileTransform: jest.fn(async (_r, _c) => ({ profile: {} })),
};
const mockBackstageToken = `a.${btoa(
JSON.stringify({ sub: 'user:default/mock', ent: [] }),
)}.c`;
const mockSession = {
accessToken: 'access-token',
expiresInSeconds: 3,
scope: 'my-scope',
tokenType: 'bear',
idToken: 'id-token',
refreshToken: 'refresh-token',
};
const baseConfig = {
authenticator: mockAuthenticator,
appUrl: 'http://127.0.0.1',
baseUrl: 'http://127.0.0.1:7007',
isOriginAllowed: () => true,
providerId: 'my-provider',
config: new ConfigReader({}),
resolverContext: { ctx: 'resolver' } as unknown as AuthResolverContext,
};
function wrapInApp(handlers: AuthProviderRouteHandlers) {
const app = express();
const router = PromiseRouter();
router.use(cookieParser());
app.use('/my-provider', router);
app.use(errorHandler());
router.get('/start', handlers.start.bind(handlers));
router.get('/handler/frame', handlers.frameHandler.bind(handlers));
router.post('/handler/frame', handlers.frameHandler.bind(handlers));
if (handlers.logout) {
router.post('/logout', handlers.logout.bind(handlers));
}
if (handlers.refresh) {
router.get('/refresh', handlers.refresh.bind(handlers));
router.post('/refresh', handlers.refresh.bind(handlers));
}
return app;
}
function getNonceCookie(test: SuperAgentTest) {
return test.jar.getCookie('my-provider-nonce', {
domain: '127.0.0.1',
path: '/my-provider/handler',
script: false,
secure: false,
});
}
function getRefreshTokenCookie(test: SuperAgentTest) {
return test.jar.getCookie('my-provider-refresh-token', {
domain: '127.0.0.1',
path: '/my-provider',
script: false,
secure: false,
});
}
function getGrantedScopesCookie(test: SuperAgentTest) {
return test.jar.getCookie('my-provider-granted-scope', {
domain: '127.0.0.1',
path: '/my-provider',
script: false,
secure: false,
});
}
describe('createOAuthRouteHandlers', () => {
afterEach(() => jest.clearAllMocks());
it('should be created', () => {
const handlers = createOAuthRouteHandlers(baseConfig);
expect(handlers).toEqual({
start: expect.any(Function),
frameHandler: expect.any(Function),
refresh: expect.any(Function),
logout: expect.any(Function),
});
});
describe('start', () => {
it('should require an env query', async () => {
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app).get('/my-provider/start');
expect(res.status).toBe(400);
expect(res.body).toMatchObject({
error: {
name: 'InputError',
message: 'No env provided in request query parameters',
},
});
});
it('should start', async () => {
const agent = request.agent(
wrapInApp(createOAuthRouteHandlers(baseConfig)),
);
mockAuthenticator.start.mockResolvedValue({
url: 'https://example.com/redirect',
});
const res = await agent.get(
'/my-provider/start?env=development&scope=my-scope',
);
const { value: nonce } = getNonceCookie(agent);
expect(res.text).toBe('');
expect(res.status).toBe(302);
expect(res.get('Location')).toBe('https://example.com/redirect');
expect(res.get('Content-Length')).toBe('0');
expect(mockAuthenticator.start).toHaveBeenCalledWith(
{
req: expect.anything(),
scope: 'my-scope',
state: encodeOAuthState({
nonce: decodeURIComponent(nonce),
env: 'development',
}),
},
{ ctx: 'authenticator' },
);
});
it('should start with additional parameters, transform state, and persist scopes', async () => {
const agent = request.agent(
wrapInApp(
createOAuthRouteHandlers({
...baseConfig,
authenticator: {
...mockAuthenticator,
shouldPersistScopes: true,
},
stateTransform: async state => ({
state: { ...state, nonce: '123' },
}),
}),
),
);
mockAuthenticator.start.mockResolvedValue({
url: 'https://example.com/redirect',
});
const res = await agent.get('/my-provider/start').query({
env: 'development',
scope: 'my-scope',
origin: 'https://remotehost',
redirectUrl: 'https://remotehost/redirect',
flow: 'redirect',
});
expect(res.text).toBe('');
expect(res.status).toBe(302);
expect(res.get('Location')).toBe('https://example.com/redirect');
expect(res.get('Content-Length')).toBe('0');
expect(mockAuthenticator.start).toHaveBeenCalledWith(
{
req: expect.anything(),
scope: 'my-scope',
state: encodeOAuthState({
nonce: '123',
env: 'development',
origin: 'https://remotehost',
redirectUrl: 'https://remotehost/redirect',
flow: 'redirect',
scope: 'my-scope',
}),
},
{ ctx: 'authenticator' },
);
});
});
describe('frameHandler', () => {
it('should authenticate', async () => {
const agent = request.agent(
wrapInApp(createOAuthRouteHandlers(baseConfig)),
);
agent.jar.setCookie(
'my-provider-nonce=123',
'127.0.0.1',
'/my-provider/handler',
);
mockAuthenticator.authenticate.mockResolvedValue({
fullProfile: { id: 'id' } as PassportProfile,
session: mockSession,
});
const res = await agent.get('/my-provider/handler/frame').query({
state: encodeOAuthState({
env: 'development',
nonce: '123',
} as OAuthState),
});
expect(mockAuthenticator.authenticate).toHaveBeenCalledWith(
{ req: expect.anything() },
{ ctx: 'authenticator' },
);
expect(res.status).toBe(200);
expect(parseWebMessageResponse(res.text).response).toEqual({
type: 'authorization_response',
response: {
profile: {},
providerInfo: {
accessToken: 'access-token',
expiresInSeconds: 3,
idToken: 'id-token',
scope: 'my-scope',
},
},
});
expect(getRefreshTokenCookie(agent).value).toBe('refresh-token');
expect(getGrantedScopesCookie(agent)).toBeUndefined();
});
it('should authenticate with sign-in, profile transform, and persisted scopes', async () => {
const agent = request.agent(
wrapInApp(
createOAuthRouteHandlers({
...baseConfig,
authenticator: {
...mockAuthenticator,
shouldPersistScopes: true,
},
profileTransform: async () => ({ profile: { email: 'em@i.l' } }),
signInResolver: async () => ({ token: mockBackstageToken }),
}),
),
);
agent.jar.setCookie(
'my-provider-nonce=123',
'127.0.0.1',
'/my-provider/handler',
);
mockAuthenticator.authenticate.mockResolvedValue({
fullProfile: { id: 'id' } as PassportProfile,
session: mockSession,
});
const res = await agent.get('/my-provider/handler/frame').query({
state: encodeOAuthState({
env: 'development',
nonce: '123',
scope: 'my-scope',
} as OAuthState),
});
expect(res.status).toBe(200);
expect(parseWebMessageResponse(res.text).response).toEqual({
type: 'authorization_response',
response: {
profile: { email: 'em@i.l' },
providerInfo: {
accessToken: 'access-token',
expiresInSeconds: 3,
idToken: 'id-token',
scope: 'my-scope',
},
backstageIdentity: {
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: 'user:default/mock',
},
token: mockBackstageToken,
},
},
});
expect(getRefreshTokenCookie(agent).value).toBe('refresh-token');
expect(getGrantedScopesCookie(agent).value).toBe('my-scope');
});
it('should redirect with persisted scope', async () => {
const agent = request.agent(
wrapInApp(
createOAuthRouteHandlers({
...baseConfig,
authenticator: {
...mockAuthenticator,
shouldPersistScopes: true,
},
profileTransform: async () => ({ profile: { email: 'em@i.l' } }),
signInResolver: async () => ({ token: mockBackstageToken }),
}),
),
);
agent.jar.setCookie(
'my-provider-nonce=123',
'127.0.0.1',
'/my-provider/handler',
);
mockAuthenticator.authenticate.mockResolvedValue({
fullProfile: { id: 'id' } as PassportProfile,
session: mockSession,
});
const res = await agent.get('/my-provider/handler/frame').query({
state: encodeOAuthState({
env: 'development',
nonce: '123',
scope: 'my-scope',
flow: 'redirect',
redirectUrl: 'https://127.0.0.1:3000/redirect',
} as OAuthState),
});
expect(res.status).toBe(302);
expect(res.get('Location')).toBe('https://127.0.0.1:3000/redirect');
expect(getRefreshTokenCookie(agent).value).toBe('refresh-token');
expect(getGrantedScopesCookie(agent).value).toBe('my-scope');
});
it('should require a valid origin', async () => {
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.get('/my-provider/handler/frame')
.query({
state: encodeOAuthState({
env: 'development',
nonce: '123',
origin: 'invalid-origin',
}),
});
expect(res.status).toBe(200);
expect(parseWebMessageResponse(res.text).response).toEqual({
type: 'authorization_response',
error: {
name: 'NotAllowedError',
message: 'App origin is invalid, failed to parse',
},
});
});
it('should reject origins that are not allowed', async () => {
const app = wrapInApp(
createOAuthRouteHandlers({
...baseConfig,
isOriginAllowed: () => false,
}),
);
const res = await request(app)
.get('/my-provider/handler/frame')
.query({
state: encodeOAuthState({
env: 'development',
nonce: '123',
origin: 'http://localhost:3000',
}),
});
expect(res.status).toBe(200);
expect(parseWebMessageResponse(res.text).response).toEqual({
type: 'authorization_response',
error: {
name: 'NotAllowedError',
message: "Origin 'http://localhost:3000' is not allowed",
},
});
});
it('should reject missing state env', async () => {
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.get('/my-provider/handler/frame')
.query({
state: encodeOAuthState({
nonce: '123',
} as OAuthState),
});
expect(res.status).toBe(200);
expect(parseWebMessageResponse(res.text).response).toEqual({
type: 'authorization_response',
error: {
name: 'NotAllowedError',
message: 'OAuth state is invalid, missing env',
},
});
});
it('should reject missing cookie nonce', async () => {
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.get('/my-provider/handler/frame')
.query({
state: encodeOAuthState({
env: 'development',
nonce: '123',
}),
});
expect(res.status).toBe(200);
expect(parseWebMessageResponse(res.text).response).toEqual({
type: 'authorization_response',
error: {
name: 'NotAllowedError',
message: 'Auth response is missing cookie nonce',
},
});
});
it('should reject missing state nonce', async () => {
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.get('/my-provider/handler/frame')
.query({
state: encodeOAuthState({
env: 'development',
} as OAuthState),
});
expect(res.status).toBe(200);
expect(parseWebMessageResponse(res.text).response).toEqual({
type: 'authorization_response',
error: {
name: 'NotAllowedError',
message: 'OAuth state is invalid, missing nonce',
},
});
});
it('should reject mismatched nonce', async () => {
const agent = request.agent(
wrapInApp(createOAuthRouteHandlers(baseConfig)),
);
agent.jar.setCookie(
'my-provider-nonce=456',
'127.0.0.1',
'/my-provider/handler',
);
const res = await agent.get('/my-provider/handler/frame').query({
state: encodeOAuthState({
env: 'development',
nonce: '123',
} as OAuthState),
});
expect(res.status).toBe(200);
expect(parseWebMessageResponse(res.text).response).toEqual({
type: 'authorization_response',
error: {
name: 'NotAllowedError',
message: 'Invalid nonce',
},
});
});
});
describe('refresh', () => {
it('should refresh', async () => {
const agent = request.agent(
wrapInApp(createOAuthRouteHandlers(baseConfig)),
);
agent.jar.setCookie(
'my-provider-refresh-token=refresh-token',
'127.0.0.1',
'/my-provider',
);
mockAuthenticator.refresh.mockImplementation(async ({ scope }) => ({
fullProfile: { id: 'id' } as PassportProfile,
session: { ...mockSession, scope },
}));
const res = await agent
.post('/my-provider/refresh')
.set('X-Requested-With', 'XMLHttpRequest')
.query({ scope: 'my-scope' });
expect(mockAuthenticator.refresh).toHaveBeenCalledWith(
{
req: expect.anything(),
refreshToken: 'refresh-token',
scope: 'my-scope',
},
{ ctx: 'authenticator' },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({
profile: {},
providerInfo: {
accessToken: 'access-token',
expiresInSeconds: 3,
idToken: 'id-token',
scope: 'my-scope',
},
});
});
it('should refresh with sign-in, profile transform, and persisted scopes', async () => {
const agent = request.agent(
wrapInApp(
createOAuthRouteHandlers({
...baseConfig,
authenticator: {
...mockAuthenticator,
shouldPersistScopes: true,
},
profileTransform: async () => ({ profile: { email: 'em@i.l' } }),
signInResolver: async () => ({ token: mockBackstageToken }),
}),
),
);
agent.jar.setCookie(
'my-provider-refresh-token=refresh-token',
'127.0.0.1',
'/my-provider',
);
agent.jar.setCookie(
'my-provider-granted-scope=persisted-scope',
'127.0.0.1',
'/my-provider',
);
mockAuthenticator.refresh.mockImplementation(async ({ scope }) => ({
fullProfile: { id: 'id' } as PassportProfile,
session: { ...mockSession, scope, refreshToken: 'new-refresh-token' },
}));
const res = await agent
.post('/my-provider/refresh')
.set('X-Requested-With', 'XMLHttpRequest');
expect(mockAuthenticator.refresh).toHaveBeenCalledWith(
{
req: expect.anything(),
refreshToken: 'refresh-token',
scope: 'persisted-scope',
},
{ ctx: 'authenticator' },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({
profile: { email: 'em@i.l' },
providerInfo: {
accessToken: 'access-token',
expiresInSeconds: 3,
idToken: 'id-token',
scope: 'persisted-scope',
},
backstageIdentity: {
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: 'user:default/mock',
},
token: mockBackstageToken,
},
});
expect(getRefreshTokenCookie(agent).value).toBe('new-refresh-token');
});
it('should forward errors', async () => {
const agent = request.agent(
wrapInApp(createOAuthRouteHandlers(baseConfig)),
);
agent.jar.setCookie(
'my-provider-refresh-token=refresh-token',
'127.0.0.1',
'/my-provider',
);
mockAuthenticator.refresh.mockRejectedValue(new Error('NOPE'));
const res = await agent
.post('/my-provider/refresh')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(401);
expect(res.body).toMatchObject({
error: {
name: 'AuthenticationError',
message: 'Refresh failed; caused by Error: NOPE',
},
});
});
it('should require refresh cookie', async () => {
const res = await request(wrapInApp(createOAuthRouteHandlers(baseConfig)))
.post('/my-provider/refresh')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(401);
expect(res.body).toMatchObject({
error: {
name: 'AuthenticationError',
message:
'Refresh failed; caused by InputError: Missing session cookie',
},
});
});
it('should reject requests without CSRF header', async () => {
const res = await request(
wrapInApp(createOAuthRouteHandlers(baseConfig)),
).post('/my-provider/refresh');
expect(res.status).toBe(401);
expect(res.body).toMatchObject({
error: {
name: 'AuthenticationError',
message: 'Invalid X-Requested-With header',
},
});
});
it('should reject requests with invalid CSRF header', async () => {
const res = await request(wrapInApp(createOAuthRouteHandlers(baseConfig)))
.post('/my-provider/refresh')
.set('X-Requested-With', 'invalid');
expect(res.status).toBe(401);
expect(res.body).toMatchObject({
error: {
name: 'AuthenticationError',
message: 'Invalid X-Requested-With header',
},
});
});
});
describe('logout', () => {
it('should log out', async () => {
const agent = request.agent(
wrapInApp(createOAuthRouteHandlers(baseConfig)),
);
agent.jar.setCookie(
'my-provider-refresh-token=my-refresh-token',
'127.0.0.1',
'/my-provider',
);
expect(getRefreshTokenCookie(agent).value).toBe('my-refresh-token');
const res = await agent
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({});
expect(getRefreshTokenCookie(agent)).toBeUndefined();
});
it('should reject requests without CSRF header', async () => {
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app).post('/my-provider/logout');
expect(res.status).toBe(401);
expect(res.body).toMatchObject({
error: {
name: 'AuthenticationError',
message: 'Invalid X-Requested-With header',
},
});
});
it('should reject requests with invalid CSRF header', async () => {
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.post('/my-provider/logout')
.set('X-Requested-With', 'wrong-value');
expect(res.status).toBe(401);
expect(res.body).toMatchObject({
error: {
name: 'AuthenticationError',
message: 'Invalid X-Requested-With header',
},
});
});
});
});
@@ -0,0 +1,343 @@
/*
* Copyright 2020 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 crypto from 'crypto';
import { URL } from 'url';
import {
AuthenticationError,
InputError,
isError,
NotAllowedError,
} from '@backstage/errors';
import {
encodeOAuthState,
decodeOAuthState,
OAuthStateTransform,
OAuthState,
} from './state';
import { sendWebMessageResponse } from '../flow';
import { prepareBackstageIdentityResponse } from '../identity';
import { OAuthCookieManager } from './OAuthCookieManager';
import {
AuthProviderRouteHandlers,
AuthResolverContext,
ClientAuthResponse,
CookieConfigurer,
ProfileTransform,
SignInResolver,
} from '../types';
import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types';
import { Config } from '@backstage/config';
/** @public */
export interface OAuthRouteHandlersOptions<TProfile> {
authenticator: OAuthAuthenticator<any, TProfile>;
appUrl: string;
baseUrl: string;
isOriginAllowed: (origin: string) => boolean;
providerId: string;
config: Config;
resolverContext: AuthResolverContext;
stateTransform?: OAuthStateTransform;
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
cookieConfigurer?: CookieConfigurer;
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
}
/** @internal */
type ClientOAuthResponse = ClientAuthResponse<{
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* (Optional) Id token issued for the signed in user.
*/
idToken?: string;
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
/**
* Scopes granted for the access token.
*/
scope: string;
}>;
/** @public */
export function createOAuthRouteHandlers<TProfile>(
options: OAuthRouteHandlersOptions<TProfile>,
): AuthProviderRouteHandlers {
const {
authenticator,
config,
baseUrl,
appUrl,
providerId,
isOriginAllowed,
cookieConfigurer,
resolverContext,
signInResolver,
} = options;
const defaultAppOrigin = new URL(appUrl).origin;
const callbackUrl =
config.getOptionalString('callbackUrl') ??
`${baseUrl}/${providerId}/handler/frame`;
const stateTransform = options.stateTransform ?? (state => ({ state }));
const profileTransform =
options.profileTransform ?? authenticator.defaultProfileTransform;
const authenticatorCtx = authenticator.initialize({ config, callbackUrl });
const cookieManager = new OAuthCookieManager({
baseUrl,
callbackUrl,
defaultAppOrigin,
providerId,
cookieConfigurer,
});
return {
async start(
this: never,
req: express.Request,
res: express.Response,
): Promise<void> {
// retrieve scopes from request
const scope = req.query.scope?.toString() ?? '';
const env = req.query.env?.toString();
const origin = req.query.origin?.toString();
const redirectUrl = req.query.redirectUrl?.toString();
const flow = req.query.flow?.toString();
if (!env) {
throw new InputError('No env provided in request query parameters');
}
const nonce = crypto.randomBytes(16).toString('base64');
// set a nonce cookie before redirecting to oauth provider
cookieManager.setNonce(res, nonce, origin);
const state: OAuthState = { nonce, env, origin, redirectUrl, flow };
// If scopes are persisted then we pass them through the state so that we
// can set the cookie on successful auth
if (authenticator.shouldPersistScopes) {
state.scope = scope;
}
const { state: transformedState } = await stateTransform(state, { req });
const encodedState = encodeOAuthState(transformedState);
const { url, status } = await options.authenticator.start(
{ req, scope, state: encodedState },
authenticatorCtx,
);
res.statusCode = status || 302;
res.setHeader('Location', url);
res.setHeader('Content-Length', '0');
res.end();
},
async frameHandler(
this: never,
req: express.Request,
res: express.Response,
): Promise<void> {
let appOrigin = defaultAppOrigin;
try {
const state = decodeOAuthState(req.query.state?.toString() ?? '');
if (state.origin) {
try {
appOrigin = new URL(state.origin).origin;
} catch {
throw new NotAllowedError('App origin is invalid, failed to parse');
}
if (!isOriginAllowed(appOrigin)) {
throw new NotAllowedError(`Origin '${appOrigin}' is not allowed`);
}
}
// The same nonce is passed through cookie and state, and they must match
const cookieNonce = cookieManager.getNonce(req);
const stateNonce = state.nonce;
if (!cookieNonce) {
throw new NotAllowedError('Auth response is missing cookie nonce');
}
if (cookieNonce !== stateNonce) {
throw new NotAllowedError('Invalid nonce');
}
const result = await authenticator.authenticate(
{ req },
authenticatorCtx,
);
const { profile } = await profileTransform(result, resolverContext);
const response: ClientOAuthResponse = {
profile,
providerInfo: {
idToken: result.session.idToken,
accessToken: result.session.accessToken,
scope: result.session.scope,
expiresInSeconds: result.session.expiresInSeconds,
},
};
if (signInResolver) {
const identity = await signInResolver(
{ profile, result },
resolverContext,
);
response.backstageIdentity =
prepareBackstageIdentityResponse(identity);
}
// Store the scope that we have been granted for this session. This is useful if
// the provider does not return granted scopes on refresh or if they are normalized.
if (authenticator.shouldPersistScopes && state.scope) {
cookieManager.setGrantedScopes(res, state.scope, appOrigin);
result.session.scope = state.scope;
}
if (result.session.refreshToken) {
// set new refresh token
cookieManager.setRefreshToken(
res,
result.session.refreshToken,
appOrigin,
);
}
// When using the redirect flow we rely on refresh token we just
// acquired to get a new session once we're back in the app.
if (state.flow === 'redirect') {
if (!state.redirectUrl) {
throw new InputError(
'No redirectUrl provided in request query parameters',
);
}
res.redirect(state.redirectUrl);
return;
}
// post message back to popup if successful
sendWebMessageResponse(res, appOrigin, {
type: 'authorization_response',
response,
});
} catch (error) {
const { name, message } = isError(error)
? error
: new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value
// post error message back to popup if failure
sendWebMessageResponse(res, appOrigin, {
type: 'authorization_response',
error: { name, message },
});
}
},
async logout(
this: never,
req: express.Request,
res: express.Response,
): Promise<void> {
// We use this as a lightweight CSRF protection
if (req.header('X-Requested-With') !== 'XMLHttpRequest') {
throw new AuthenticationError('Invalid X-Requested-With header');
}
if (authenticator.logout) {
const refreshToken = cookieManager.getRefreshToken(req);
await authenticator.logout({ req, refreshToken }, authenticatorCtx);
}
// remove refresh token cookie if it is set
cookieManager.removeRefreshToken(res, req.get('origin'));
res.status(200).end();
},
async refresh(
this: never,
req: express.Request,
res: express.Response,
): Promise<void> {
// We use this as a lightweight CSRF protection
if (req.header('X-Requested-With') !== 'XMLHttpRequest') {
throw new AuthenticationError('Invalid X-Requested-With header');
}
try {
const refreshToken = cookieManager.getRefreshToken(req);
// throw error if refresh token is missing in the request
if (!refreshToken) {
throw new InputError('Missing session cookie');
}
let scope = req.query.scope?.toString() ?? '';
if (authenticator.shouldPersistScopes) {
scope = cookieManager.getGrantedScopes(req);
}
const result = await authenticator.refresh(
{ req, scope, refreshToken },
authenticatorCtx,
);
const { profile } = await profileTransform(result, resolverContext);
const newRefreshToken = result.session.refreshToken;
if (newRefreshToken && newRefreshToken !== refreshToken) {
cookieManager.setRefreshToken(
res,
newRefreshToken,
req.get('origin'),
);
}
const response: ClientOAuthResponse = {
profile,
providerInfo: {
idToken: result.session.idToken,
accessToken: result.session.accessToken,
scope: result.session.scope,
expiresInSeconds: result.session.expiresInSeconds,
},
};
if (signInResolver) {
const identity = await signInResolver(
{ profile, result },
resolverContext,
);
response.backstageIdentity =
prepareBackstageIdentityResponse(identity);
}
res.status(200).json(response);
} catch (error) {
throw new AuthenticationError('Refresh failed', error);
}
},
};
}
+44
View File
@@ -0,0 +1,44 @@
/*
* 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.
*/
export {
createOAuthRouteHandlers,
type OAuthRouteHandlersOptions,
} from './createOAuthRouteHandlers';
export {
PassportOAuthAuthenticatorHelper,
type PassportOAuthDoneCallback,
type PassportOAuthPrivateInfo,
type PassportOAuthResult,
} from './PassportOAuthAuthenticatorHelper';
export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler';
export { createOAuthProviderFactory } from './createOAuthProviderFactory';
export {
encodeOAuthState,
decodeOAuthState,
type OAuthState,
type OAuthStateTransform,
} from './state';
export {
createOAuthAuthenticator,
type OAuthAuthenticator,
type OAuthAuthenticatorAuthenticateInput,
type OAuthAuthenticatorLogoutInput,
type OAuthAuthenticatorRefreshInput,
type OAuthAuthenticatorResult,
type OAuthAuthenticatorStartInput,
type OAuthSession,
} from './types';
+62
View File
@@ -0,0 +1,62 @@
/*
* 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 pickBy from 'lodash/pickBy';
import { Request } from 'express';
import { NotAllowedError } from '@backstage/errors';
/**
* A type for the serialized value in the `state` parameter of the OAuth authorization flow
* @public
*/
export type OAuthState = {
nonce: string;
env: string;
origin?: string;
scope?: string;
redirectUrl?: string;
flow?: string;
};
/** @public */
export type OAuthStateTransform = (
state: OAuthState,
context: { req: Request },
) => Promise<{ state: OAuthState }>;
/** @public */
export function encodeOAuthState(state: OAuthState): string {
const stateString = new URLSearchParams(
pickBy<string>(state, value => value !== undefined),
).toString();
return Buffer.from(stateString, 'utf-8').toString('hex');
}
/** @public */
export function decodeOAuthState(encodedState: string): OAuthState {
const state = Object.fromEntries(
new URLSearchParams(Buffer.from(encodedState, 'hex').toString('utf-8')),
);
if (!state.env || state.env?.length === 0) {
throw new NotAllowedError('OAuth state is invalid, missing env');
}
if (!state.nonce || state.nonce?.length === 0) {
throw new NotAllowedError('OAuth state is invalid, missing nonce');
}
return state as OAuthState;
}
+88
View File
@@ -0,0 +1,88 @@
/*
* 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 { Config } from '@backstage/config';
import { Request } from 'express';
import { ProfileTransform } from '../types';
/** @public */
export interface OAuthSession {
accessToken: string;
tokenType: string;
idToken?: string;
scope: string;
expiresInSeconds: number;
refreshToken?: string;
}
/** @public */
export interface OAuthAuthenticatorStartInput {
scope: string;
state: string;
req: Request;
}
/** @public */
export interface OAuthAuthenticatorAuthenticateInput {
req: Request;
}
/** @public */
export interface OAuthAuthenticatorRefreshInput {
scope: string;
refreshToken: string;
req: Request;
}
/** @public */
export interface OAuthAuthenticatorLogoutInput {
accessToken?: string;
refreshToken?: string;
req: Request;
}
/** @public */
export interface OAuthAuthenticatorResult<TProfile> {
fullProfile: TProfile;
session: OAuthSession;
}
/** @public */
export interface OAuthAuthenticator<TContext, TProfile> {
defaultProfileTransform: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
shouldPersistScopes?: boolean;
initialize(ctx: { callbackUrl: string; config: Config }): TContext;
start(
input: OAuthAuthenticatorStartInput,
ctx: TContext,
): Promise<{ url: string; status?: number }>;
authenticate(
input: OAuthAuthenticatorAuthenticateInput,
ctx: TContext,
): Promise<OAuthAuthenticatorResult<TProfile>>;
refresh(
input: OAuthAuthenticatorRefreshInput,
ctx: TContext,
): Promise<OAuthAuthenticatorResult<TProfile>>;
logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise<void>;
}
/** @public */
export function createOAuthAuthenticator<TContext, TProfile>(
authenticator: OAuthAuthenticator<TContext, TProfile>,
): OAuthAuthenticator<TContext, TProfile> {
return authenticator;
}
@@ -0,0 +1,249 @@
/*
* 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 { Request } from 'express';
import { Strategy } from 'passport';
import { PassportProfile } from './types';
import { ProfileInfo } from '../types';
// Re-declared here to avoid direct dependency on passport-oauth2
/** @internal */
interface InternalOAuthError extends Error {
oauthError?: {
data?: string;
};
}
/** @internal */
function decodeJwtPayload(token: string): Record<string, string> {
const payloadStr = token.split('.')[1];
if (!payloadStr) {
throw new Error('Invalid JWT token');
}
let payload: unknown;
try {
payload = JSON.parse(
Buffer.from(
payloadStr.replace(/-/g, '+').replace(/_/g, '/'),
'base64',
).toString('utf8'),
);
} catch (e) {
throw new Error('Invalid JWT token');
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Invalid JWT token');
}
return payload as Record<string, string>;
}
/** @public */
export class PassportHelpers {
private constructor() {}
static transformProfile = (
profile: PassportProfile,
idToken?: string,
): ProfileInfo => {
let email: string | undefined = undefined;
if (profile.emails && profile.emails.length > 0) {
const [firstEmail] = profile.emails;
email = firstEmail.value;
}
let picture: string | undefined = undefined;
if (profile.avatarUrl) {
picture = profile.avatarUrl;
} else if (profile.photos && profile.photos.length > 0) {
const [firstPhoto] = profile.photos;
picture = firstPhoto.value;
}
let displayName: string | undefined =
profile.displayName ?? profile.username ?? profile.id;
if ((!email || !picture || !displayName) && idToken) {
try {
const decoded: Record<string, string> = decodeJwtPayload(idToken);
if (!email && decoded.email) {
email = decoded.email;
}
if (!picture && decoded.picture) {
picture = decoded.picture;
}
if (!displayName && decoded.name) {
displayName = decoded.name;
}
} catch (e) {
throw new Error(`Failed to parse id token and get profile info, ${e}`);
}
}
return {
email,
picture,
displayName,
};
};
static async executeRedirectStrategy(
req: Request,
providerStrategy: Strategy,
options: Record<string, string>,
): Promise<{
/**
* URL to redirect to
*/
url: string;
/**
* Status code to use for the redirect
*/
status?: number;
}> {
return new Promise(resolve => {
const strategy = Object.create(providerStrategy);
strategy.redirect = (url: string, status?: number) => {
resolve({ url, status: status ?? undefined });
};
strategy.authenticate(req, { ...options });
});
}
static async executeFrameHandlerStrategy<TResult, TPrivateInfo = never>(
req: Request,
providerStrategy: Strategy,
options?: Record<string, string>,
): Promise<{ result: TResult; privateInfo: TPrivateInfo }> {
return new Promise((resolve, reject) => {
const strategy = Object.create(providerStrategy);
strategy.success = (result: any, privateInfo: any) => {
resolve({ result, privateInfo });
};
strategy.fail = (
info: { type: 'success' | 'error'; message?: string },
// _status: number,
) => {
reject(new Error(`Authentication rejected, ${info.message ?? ''}`));
};
strategy.error = (error: InternalOAuthError) => {
let message = `Authentication failed, ${error.message}`;
if (error.oauthError?.data) {
try {
const errorData = JSON.parse(error.oauthError.data);
if (errorData.message) {
message += ` - ${errorData.message}`;
}
} catch (parseError) {
message += ` - ${error.oauthError}`;
}
}
reject(new Error(message));
};
strategy.redirect = () => {
reject(new Error('Unexpected redirect'));
};
strategy.authenticate(req, { ...(options ?? {}) });
});
}
static async executeRefreshTokenStrategy(
providerStrategy: Strategy,
refreshToken: string,
scope: string,
): Promise<{
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* Optionally, the server can issue a new Refresh Token for the user
*/
refreshToken?: string;
params: any;
}> {
return new Promise((resolve, reject) => {
const anyStrategy = providerStrategy as any;
const OAuth2 = anyStrategy._oauth2.constructor;
const oauth2 = new OAuth2(
anyStrategy._oauth2._clientId,
anyStrategy._oauth2._clientSecret,
anyStrategy._oauth2._baseSite,
anyStrategy._oauth2._authorizeUrl,
anyStrategy._refreshURL || anyStrategy._oauth2._accessTokenUrl,
anyStrategy._oauth2._customHeaders,
);
oauth2.getOAuthAccessToken(
refreshToken,
{
scope,
grant_type: 'refresh_token',
},
(
err: Error | null,
accessToken: string,
newRefreshToken: string,
params: any,
) => {
if (err) {
reject(
new Error(`Failed to refresh access token ${err.toString()}`),
);
}
if (!accessToken) {
reject(
new Error(
`Failed to refresh access token, no access token received`,
),
);
}
resolve({
accessToken,
refreshToken: newRefreshToken,
params,
});
},
);
});
}
static async executeFetchUserProfileStrategy(
providerStrategy: Strategy,
accessToken: string,
): Promise<PassportProfile> {
return new Promise((resolve, reject) => {
const anyStrategy = providerStrategy as unknown as {
userProfile(accessToken: string, callback: Function): void;
};
anyStrategy.userProfile(
accessToken,
(error: Error, rawProfile: PassportProfile) => {
if (error) {
reject(error);
} else {
resolve(rawProfile);
}
},
);
});
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { PassportHelpers } from './PassportHelpers';
export type { PassportDoneCallback, PassportProfile } from './types';
+29
View File
@@ -0,0 +1,29 @@
/*
* 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 { Profile } from 'passport';
/** @public */
export type PassportProfile = Profile & {
avatarUrl?: string;
};
/** @public */
export type PassportDoneCallback<TResult, TPrivateInfo = never> = (
err?: Error,
result?: TResult,
privateInfo?: TPrivateInfo,
) => void;
@@ -0,0 +1,61 @@
/*
* 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 {
readDeclarativeSignInResolver,
SignInResolverFactory,
} from '../sign-in';
import {
AuthProviderFactory,
ProfileTransform,
SignInResolver,
} from '../types';
import { createProxyAuthRouteHandlers } from './createProxyRouteHandlers';
import { ProxyAuthenticator } from './types';
/** @public */
export function createProxyAuthProviderFactory<TResult>(options: {
authenticator: ProxyAuthenticator<unknown, TResult>;
profileTransform?: ProfileTransform<TResult>;
signInResolver?: SignInResolver<TResult>;
signInResolverFactories?: Record<
string,
SignInResolverFactory<TResult, unknown>
>;
}): AuthProviderFactory {
return ctx => {
const signInResolver =
options.signInResolver ??
readDeclarativeSignInResolver({
config: ctx.config,
signInResolverFactories: options.signInResolverFactories ?? {},
});
if (!signInResolver) {
throw new Error(
`No sign-in resolver configured for proxy auth provider '${ctx.providerId}'`,
);
}
return createProxyAuthRouteHandlers<TResult>({
signInResolver,
config: ctx.config,
authenticator: options.authenticator,
resolverContext: ctx.resolverContext,
profileTransform: options.profileTransform,
});
};
}
@@ -0,0 +1,80 @@
/*
* Copyright 2020 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 { Request, Response } from 'express';
import { Config } from '@backstage/config';
import {
AuthProviderRouteHandlers,
AuthResolverContext,
ClientAuthResponse,
ProfileTransform,
SignInResolver,
} from '../types';
import { ProxyAuthenticator } from './types';
import { prepareBackstageIdentityResponse } from '../identity';
import { NotImplementedError } from '@backstage/errors';
/** @public */
export interface ProxyAuthRouteHandlersOptions<TResult> {
authenticator: ProxyAuthenticator<any, TResult>;
config: Config;
resolverContext: AuthResolverContext;
signInResolver: SignInResolver<TResult>;
profileTransform?: ProfileTransform<TResult>;
}
/** @public */
export function createProxyAuthRouteHandlers<TResult>(
options: ProxyAuthRouteHandlersOptions<TResult>,
): AuthProviderRouteHandlers {
const { authenticator, config, resolverContext, signInResolver } = options;
const profileTransform =
options.profileTransform ?? authenticator.defaultProfileTransform;
const authenticatorCtx = authenticator.initialize({ config });
return {
async start(): Promise<void> {
throw new NotImplementedError('Not implemented');
},
async frameHandler(): Promise<void> {
throw new NotImplementedError('Not implemented');
},
async refresh(this: never, req: Request, res: Response): Promise<void> {
const { result } = await authenticator.authenticate(
{ req },
authenticatorCtx,
);
const { profile } = await profileTransform(result, resolverContext);
const identity = await signInResolver(
{ profile, result },
resolverContext,
);
const response: ClientAuthResponse<{}> = {
profile,
providerInfo: {},
backstageIdentity: prepareBackstageIdentityResponse(identity),
};
res.status(200).json(response);
},
};
}
+22
View File
@@ -0,0 +1,22 @@
/*
* 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.
*/
export { createProxyAuthenticator, type ProxyAuthenticator } from './types';
export { createProxyAuthProviderFactory } from './createProxyAuthProviderFactory';
export {
createProxyAuthRouteHandlers,
type ProxyAuthRouteHandlersOptions,
} from './createProxyRouteHandlers';
+36
View File
@@ -0,0 +1,36 @@
/*
* 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 { Config } from '@backstage/config';
import { Request } from 'express';
import { ProfileTransform } from '../types';
/** @public */
export interface ProxyAuthenticator<TContext, TResult> {
defaultProfileTransform: ProfileTransform<TResult>;
initialize(ctx: { config: Config }): Promise<TContext>;
authenticate(
options: { req: Request },
ctx: TContext,
): Promise<{ result: TResult }>;
}
/** @public */
export function createProxyAuthenticator<TContext, TResult>(
authenticator: ProxyAuthenticator<TContext, TResult>,
): ProxyAuthenticator<TContext, TResult> {
return authenticator;
}
@@ -0,0 +1,73 @@
/*
* 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 } from './createSignInResolverFactory';
/**
* A collection of common sign-in resolvers that work with any auth provider.
*
* @public
*/
export namespace commonSignInResolvers {
/**
* A common sign-in resolver that looks up the user using their email address
* as email of the entity.
*/
export const emailMatchingUserEntityProfileEmail =
createSignInResolverFactory({
create() {
return async (info, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error(
'Login failed, user profile does not contain an email',
);
}
return ctx.signInWithCatalogUser({
filter: {
'spec.profile.email': profile.email,
},
});
};
},
});
/**
* A common sign-in resolver that looks up the user using the local part of
* their email address as the entity name.
*/
export const emailLocalPartMatchingUserEntityName =
createSignInResolverFactory({
create() {
return async (info, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error(
'Login failed, user profile does not contain an email',
);
}
const [localPart] = profile.email.split('@');
return ctx.signInWithCatalogUser({
entityRef: { name: localPart },
});
};
},
});
}
@@ -0,0 +1,75 @@
/*
* 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 { ZodSchema, ZodTypeDef } from 'zod';
import { SignInResolver } from '../types';
import zodToJsonSchema from 'zod-to-json-schema';
import { JsonObject } from '@backstage/types';
import { InputError } from '@backstage/errors';
/** @public */
export interface SignInResolverFactory<TAuthResult, TOptions> {
(
...options: undefined extends TOptions
? [options?: TOptions]
: [options: TOptions]
): SignInResolver<TAuthResult>;
optionsJsonSchema?: JsonObject;
}
/** @public */
export interface SignInResolverFactoryOptions<
TAuthResult,
TOptionsOutput,
TOptionsInput,
> {
optionsSchema?: ZodSchema<TOptionsOutput, ZodTypeDef, TOptionsInput>;
create(options: TOptionsOutput): SignInResolver<TAuthResult>;
}
/** @public */
export function createSignInResolverFactory<
TAuthResult,
TOptionsOutput,
TOptionsInput,
>(
options: SignInResolverFactoryOptions<
TAuthResult,
TOptionsOutput,
TOptionsInput
>,
): SignInResolverFactory<TAuthResult, TOptionsInput> {
const { optionsSchema } = options;
if (!optionsSchema) {
return (resolverOptions?: TOptionsInput) => {
if (resolverOptions) {
throw new InputError('sign-in resolver does not accept options');
}
return options.create(undefined as TOptionsOutput);
};
}
const factory = (
...[resolverOptions]: undefined extends TOptionsInput
? [options?: TOptionsInput]
: [options: TOptionsInput]
) => {
const parsedOptions = optionsSchema.parse(resolverOptions);
return options.create(parsedOptions);
};
factory.optionsJsonSchema = zodToJsonSchema(optionsSchema) as JsonObject;
return factory;
}
+26
View File
@@ -0,0 +1,26 @@
/*
* 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.
*/
export {
createSignInResolverFactory,
type SignInResolverFactory,
type SignInResolverFactoryOptions,
} from './createSignInResolverFactory';
export {
readDeclarativeSignInResolver,
type ReadDeclarativeSignInResolverOptions,
} from './readDeclarativeSignInResolver';
export { commonSignInResolvers } from './commonSignInResolvers';
@@ -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 { Config } from '@backstage/config';
import { JsonObject } from '@backstage/types';
import { SignInResolver } from '../types';
import { SignInResolverFactory } from './createSignInResolverFactory';
/** @public */
export interface ReadDeclarativeSignInResolverOptions<TAuthResult> {
config: Config;
signInResolverFactories: {
[name in string]: SignInResolverFactory<TAuthResult, unknown>;
};
}
/** @public */
export function readDeclarativeSignInResolver<TAuthResult>(
options: ReadDeclarativeSignInResolverOptions<TAuthResult>,
): SignInResolver<TAuthResult> | undefined {
const resolvers =
options.config
.getOptionalConfigArray('signIn.resolvers')
?.map(resolverConfig => {
const resolverName = resolverConfig.getString('resolver');
if (!Object.hasOwn(options.signInResolverFactories, resolverName)) {
throw new Error(
`Sign-in resolver '${resolverName}' is not available`,
);
}
const resolver = options.signInResolverFactories[resolverName];
const { resolver: _ignored, ...resolverOptions } =
resolverConfig.get<JsonObject>();
return resolver(
Object.keys(resolverOptions).length > 0 ? resolverOptions : undefined,
);
}) ?? [];
if (resolvers.length === 0) {
return undefined;
}
return async (profile, context) => {
for (const resolver of resolvers) {
try {
return await resolver(profile, context);
} catch (error) {
if (error?.name === 'NotFoundError') {
continue;
}
throw error;
}
}
throw new Error('Failed to sign-in, unable to resolve user identity');
};
}
+304 -10
View File
@@ -14,7 +14,12 @@
* limitations under the License.
*/
import { Request } from 'express';
import { LoggerService } from '@backstage/backend-plugin-api';
import { EntityFilterQuery } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { JsonValue } from '@backstage/types';
import { Request, Response } from 'express';
/**
* A representation of a successful Backstage sign-in.
@@ -31,15 +36,6 @@ export interface BackstageSignInResult {
token: string;
}
/**
* Options to request the identity from a Backstage backend request
*
* @public
*/
export type IdentityApiGetIdentityRequest = {
request: Request<unknown>;
};
/**
* Response object containing the {@link BackstageUserIdentity} and the token
* from the authentication provider.
@@ -76,3 +72,301 @@ export type BackstageUserIdentity = {
*/
ownershipEntityRefs: string[];
};
/**
* A query for a single user in the catalog.
*
* If `entityRef` is used, the default kind is `'User'`.
*
* If `annotations` are used, all annotations must be present and
* match the provided value exactly. Only entities of kind `'User'` will be considered.
*
* If `filter` are used they are passed on as they are to the `CatalogApi`.
*
* Regardless of the query method, the query must match exactly one entity
* in the catalog, or an error will be thrown.
*
* @public
*/
export type AuthResolverCatalogUserQuery =
| {
entityRef:
| string
| {
kind?: string;
namespace?: string;
name: string;
};
}
| {
annotations: Record<string, string>;
}
| {
filter: EntityFilterQuery;
};
/**
* Parameters used to issue new Backstage Tokens
*
* @public
*/
export type TokenParams = {
/**
* The claims that will be embedded within the token. At a minimum, this should include
* the subject claim, `sub`. It is common to also list entity ownership relations in the
* `ent` list. Additional claims may also be added at the developer's discretion except
* for the following list, which will be overwritten by the TokenIssuer: `iss`, `aud`,
* `iat`, and `exp`. The Backstage team also maintains the right add new claims in the future
* without listing the change as a "breaking change".
*/
claims: {
/** The token subject, i.e. User ID */
sub: string;
/** A list of entity references that the user claims ownership through */
ent?: string[];
} & Record<string, JsonValue>;
};
/**
* The context that is used for auth processing.
*
* @public
*/
export type AuthResolverContext = {
/**
* Issues a Backstage token using the provided parameters.
*/
issueToken(params: TokenParams): Promise<{ token: string }>;
/**
* Finds a single user in the catalog using the provided query.
*
* See {@link AuthResolverCatalogUserQuery} for details.
*/
findCatalogUser(
query: AuthResolverCatalogUserQuery,
): Promise<{ entity: Entity }>;
/**
* Finds a single user in the catalog using the provided query, and then
* issues an identity for that user using default ownership resolution.
*
* See {@link AuthResolverCatalogUserQuery} for details.
*/
signInWithCatalogUser(
query: AuthResolverCatalogUserQuery,
): Promise<BackstageSignInResult>;
};
/**
* Any Auth provider needs to implement this interface which handles the routes in the
* auth backend. Any auth API requests from the frontend reaches these methods.
*
* The routes in the auth backend API are tied to these methods like below
*
* `/auth/[provider]/start -> start`
* `/auth/[provider]/handler/frame -> frameHandler`
* `/auth/[provider]/refresh -> refresh`
* `/auth/[provider]/logout -> logout`
*
* @public
*/
export interface AuthProviderRouteHandlers {
/**
* Handles the start route of the API. This initiates a sign in request with an auth provider.
*
* Request
* - scopes for the auth request (Optional)
* Response
* - redirect to the auth provider for the user to sign in or consent.
* - sets a nonce cookie and also pass the nonce as 'state' query parameter in the redirect request
*/
start(req: Request, res: Response): Promise<void>;
/**
* Once the user signs in or consents in the OAuth screen, the auth provider redirects to the
* callbackURL which is handled by this method.
*
* Request
* - to contain a nonce cookie and a 'state' query parameter
* Response
* - postMessage to the window with a payload that contains accessToken, expiryInSeconds?, idToken? and scope.
* - sets a refresh token cookie if the auth provider supports refresh tokens
*/
frameHandler(req: Request, res: Response): Promise<void>;
/**
* (Optional) If the auth provider supports refresh tokens then this method handles
* requests to get a new access token.
*
* Other types of providers may also use this method to implement its own logic to create new sessions
* upon request. For example, this can be used to create a new session for a provider that handles requests
* from an authenticating proxy.
*
* Request
* - to contain a refresh token cookie and scope (Optional) query parameter.
* Response
* - payload with accessToken, expiryInSeconds?, idToken?, scope and user profile information.
*/
refresh?(req: Request, res: Response): Promise<void>;
/**
* (Optional) Handles sign out requests
*
* Response
* - removes the refresh token cookie
*/
logout?(req: Request, res: Response): Promise<void>;
}
/**
* @public
* @deprecated Use top-level properties passed to `AuthProviderFactory` instead
*/
export type AuthProviderConfig = {
/**
* The protocol://domain[:port] where the app is hosted. This is used to construct the
* callbackURL to redirect to once the user signs in to the auth provider.
*/
baseUrl: string;
/**
* The base URL of the app as provided by app.baseUrl
*/
appUrl: string;
/**
* A function that is called to check whether an origin is allowed to receive the authentication result.
*/
isOriginAllowed: (origin: string) => boolean;
/**
* The function used to resolve cookie configuration based on the auth provider options.
*/
cookieConfigurer?: CookieConfigurer;
};
/** @public */
export type AuthProviderFactory = (options: {
providerId: string;
/** @deprecated Use top-level properties instead */
globalConfig: AuthProviderConfig;
config: Config;
logger: LoggerService;
resolverContext: AuthResolverContext;
/**
* The protocol://domain[:port] where the app is hosted. This is used to construct the
* callbackURL to redirect to once the user signs in to the auth provider.
*/
baseUrl: string;
/**
* The base URL of the app as provided by app.baseUrl
*/
appUrl: string;
/**
* A function that is called to check whether an origin is allowed to receive the authentication result.
*/
isOriginAllowed: (origin: string) => boolean;
/**
* The function used to resolve cookie configuration based on the auth provider options.
*/
cookieConfigurer?: CookieConfigurer;
}) => AuthProviderRouteHandlers;
/** @public */
export type ClientAuthResponse<TProviderInfo> = {
providerInfo: TProviderInfo;
profile: ProfileInfo;
backstageIdentity?: BackstageIdentityResponse;
};
/**
* Type of sign in information context. Includes the profile information and
* authentication result which contains auth related information.
*
* @public
*/
export type SignInInfo<TAuthResult> = {
/**
* The simple profile passed down for use in the frontend.
*/
profile: ProfileInfo;
/**
* The authentication result that was received from the authentication
* provider.
*/
result: TAuthResult;
};
/**
* Describes the function which handles the result of a successful
* authentication. Must return a valid {@link @backstage/plugin-auth-node#BackstageSignInResult}.
*
* @public
*/
export type SignInResolver<TAuthResult> = (
info: SignInInfo<TAuthResult>,
context: AuthResolverContext,
) => Promise<BackstageSignInResult>;
/**
* Describes the function that transforms the result of a successful
* authentication into a {@link ProfileInfo} object.
*
* This function may optionally throw an error in order to reject authentication.
*
* @public
*/
export type ProfileTransform<TResult> = (
result: TResult,
context: AuthResolverContext,
) => Promise<{ profile: ProfileInfo }>;
/**
* Used to display login information to user, i.e. sidebar popup.
*
* It is also temporarily used as the profile of the signed-in user's Backstage
* identity, but we want to replace that with data from identity and/org catalog
* service
*
* @public
*/
export type ProfileInfo = {
/**
* Email ID of the signed in user.
*/
email?: string;
/**
* Display name that can be presented to the signed in user.
*/
displayName?: string;
/**
* URL to an image that can be used as the display image or avatar of the
* signed in user.
*/
picture?: string;
};
/**
* The callback used to resolve the cookie configuration for auth providers that use cookies.
* @public
*/
export type CookieConfigurer = (ctx: {
/** ID of the auth provider that this configuration applies to */
providerId: string;
/** The externally reachable base URL of the auth-backend plugin */
baseUrl: string;
/** The configured callback URL of the auth provider */
callbackUrl: string;
/** The origin URL of the app */
appOrigin: string;
}) => {
domain: string;
path: string;
secure: boolean;
sameSite?: 'none' | 'lax' | 'strict';
};