Merge branch 'master' into auth_handler_and_sign-in_resolvers_oauth2

This commit is contained in:
Pepijn
2021-08-25 14:12:37 +02:00
committed by GitHub
1182 changed files with 35221 additions and 11372 deletions
@@ -150,7 +150,7 @@ export class TokenFactory implements TokenIssuer {
// the new one. This also needs to be implemented cross-service though, meaning new services
// that boot up need to be able to grab an existing key to use for signing.
this.logger.info(`Created new signing key ${key.kid}`);
await this.keyStore.addKey((key.toJWK(false) as unknown) as AnyJWK);
await this.keyStore.addKey(key.toJWK(false) as unknown as AnyJWK);
// At this point we are allowed to start using the new key
return key as JSONWebKey;
+1
View File
@@ -16,6 +16,7 @@
export * from './service/router';
export { IdentityClient } from './identity';
export type { TokenIssuer } from './identity';
export * from './providers';
// flow package provides 2 functions
@@ -15,7 +15,11 @@
*/
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import {
RELATION_MEMBER_OF,
UserEntity,
UserEntityV1alpha1,
} from '@backstage/catalog-model';
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from './CatalogIdentityClient';
@@ -37,12 +41,12 @@ describe('CatalogIdentityClient', () => {
afterEach(() => jest.resetAllMocks());
it('passes through the correct search params', async () => {
it('findUser passes through the correct search params', async () => {
catalogApi.getEntities.mockResolvedValueOnce({ items: [{} as UserEntity] });
tokenIssuer.issueToken.mockResolvedValue('my-token');
const client = new CatalogIdentityClient({
catalogApi: catalogApi,
tokenIssuer: tokenIssuer,
catalogApi,
tokenIssuer,
});
await client.findUser({ annotations: { key: 'value' } });
@@ -62,4 +66,88 @@ describe('CatalogIdentityClient', () => {
},
});
});
it('resolveCatalogMembership resolves membership', async () => {
const mockUsers: Array<UserEntityV1alpha1> = [
{
apiVersion: 'backstage.io/v1beta1',
kind: 'User',
metadata: {
name: 'inigom',
},
spec: {
memberOf: ['team-a'],
},
relations: [
{
type: RELATION_MEMBER_OF,
target: {
kind: 'Group',
namespace: 'default',
name: 'team-a',
},
},
],
},
{
apiVersion: 'backstage.io/v1beta1',
kind: 'User',
metadata: {
name: 'mpatinkin',
namespace: 'reality',
},
spec: {
memberOf: ['screen-actors-guild'],
},
relations: [
{
type: RELATION_MEMBER_OF,
target: {
kind: 'Group',
namespace: 'reality',
name: 'screen-actors-guild',
},
},
],
},
];
catalogApi.getEntities.mockResolvedValueOnce({ items: mockUsers });
const client = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const claims = await client.resolveCatalogMembership({
entityRefs: ['inigom', 'User:default/imontoya', 'User:reality/mpatinkin'],
});
expect(catalogApi.getEntities).toHaveBeenCalledWith({
filter: [
{
kind: 'user',
'metadata.namespace': 'default',
'metadata.name': 'inigom',
},
{
kind: 'user',
'metadata.namespace': 'default',
'metadata.name': 'imontoya',
},
{
kind: 'user',
'metadata.namespace': 'reality',
'metadata.name': 'mpatinkin',
},
],
});
expect(claims).toMatchObject([
'user:default/inigom',
'user:default/imontoya',
'user:reality/mpatinkin',
'group:default/team-a',
'group:reality/screen-actors-guild',
]);
});
});
@@ -14,15 +14,27 @@
* limitations under the License.
*/
import { Logger } from 'winston';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import {
EntityName,
parseEntityRef,
RELATION_MEMBER_OF,
stringifyEntityRef,
UserEntity,
} from '@backstage/catalog-model';
import { TokenIssuer } from '../../identity';
type UserQuery = {
annotations: Record<string, string>;
};
type MemberClaimQuery = {
entityRefs: string[];
logger?: Logger;
};
/**
* A catalog client tailored for reading out identity data from the catalog.
*/
@@ -64,4 +76,62 @@ export class CatalogIdentityClient {
return items[0] as UserEntity;
}
/**
* Resolve additional entity claims from the catalog, using the passed-in entity names. Designed
* to be used within a `signInResolver` where additional entity claims might be provided, but
* group membership and transient group membership lean on imported catalog relations.
*
* Returns a superset of the entity names that can be passed directly to `issueToken` as `ent`.
*/
async resolveCatalogMembership({
entityRefs,
logger,
}: MemberClaimQuery): Promise<string[]> {
const resolvedEntityRefs = entityRefs
.map((ref: string) => {
try {
const parsedRef = parseEntityRef(ref.toLocaleLowerCase('en-US'), {
defaultKind: 'user',
defaultNamespace: 'default',
});
return parsedRef;
} catch {
logger?.warn(`Failed to parse entityRef from ${ref}, ignoring`);
return null;
}
})
.filter((ref): ref is EntityName => ref !== null);
const filter = resolvedEntityRefs.map(ref => ({
kind: ref.kind,
'metadata.namespace': ref.namespace,
'metadata.name': ref.name,
}));
const entities = await this.catalogApi
.getEntities({ filter })
.then(r => r.items);
if (entityRefs.length !== entities.length) {
const foundEntityNames = entities.map(stringifyEntityRef);
const missingEntityNames = resolvedEntityRefs
.map(stringifyEntityRef)
.filter(s => !foundEntityNames.includes(s));
logger?.debug(`Entities not found for refs ${missingEntityNames.join()}`);
}
const memberOf = entities.flatMap(
e =>
e!.relations
?.filter(r => r.type === RELATION_MEMBER_OF)
.map(r => r.target) ?? [],
);
const newEntityRefs = [
...new Set(resolvedEntityRefs.concat(memberOf).map(stringifyEntityRef)),
];
logger?.debug(`Found catalog membership: ${newEntityRefs.join()}`);
return newEntityRefs;
}
}
@@ -32,10 +32,10 @@ describe('oauth helpers', () => {
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
const mockResponse = {
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
@@ -64,10 +64,10 @@ describe('oauth helpers', () => {
});
it('should post a message back with payload error', () => {
const mockResponse = ({
const mockResponse = {
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
@@ -84,13 +84,13 @@ describe('oauth helpers', () => {
it('should call postMessage twice but only one of them with target *', () => {
let responseBody = '';
const mockResponse = ({
const mockResponse = {
end: jest.fn(body => {
responseBody = body;
return this;
}),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
@@ -128,10 +128,10 @@ describe('oauth helpers', () => {
});
it('handles single quotes and unicode chars safely', () => {
const mockResponse = ({
const mockResponse = {
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
@@ -164,23 +164,23 @@ describe('oauth helpers', () => {
describe('ensuresXRequestedWith', () => {
it('should return false if no header present', () => {
const mockRequest = ({
const mockRequest = {
header: () => jest.fn(),
} as unknown) as express.Request;
} as unknown as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return false if header present with incorrect value', () => {
const mockRequest = ({
const mockRequest = {
header: () => 'INVALID',
} as unknown) as express.Request;
} as unknown as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return true if header present with correct value', () => {
const mockRequest = ({
const mockRequest = {
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
} as unknown as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
});
});
@@ -64,6 +64,7 @@ describe('OAuthAdapter', () => {
issueToken: async () => 'my-id-token',
listPublicKeys: async () => ({ keys: [] }),
},
isOriginAllowed: () => false,
};
it('sets the correct headers in start', async () => {
@@ -71,19 +72,19 @@ describe('OAuthAdapter', () => {
providerInstance,
oAuthProviderOptions,
);
const mockRequest = ({
const mockRequest = {
query: {
scope: 'user',
env: 'development',
},
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
statusCode: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.start(mockRequest, mockResponse);
// nonce cookie checks
@@ -105,23 +106,24 @@ describe('OAuthAdapter', () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
isOriginAllowed: () => false,
});
const state = { nonce: 'nonce', env: 'development' };
const mockRequest = ({
const mockRequest = {
cookies: {
'test-provider-nonce': 'nonce',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.frameHandler(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
@@ -139,22 +141,23 @@ describe('OAuthAdapter', () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
isOriginAllowed: () => false,
});
const mockRequest = ({
const mockRequest = {
cookies: {
'test-provider-nonce': 'nonce',
},
query: {
state: 'nonce',
},
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.frameHandler(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledTimes(0);
@@ -164,17 +167,18 @@ describe('OAuthAdapter', () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
isOriginAllowed: () => false,
});
const mockRequest = ({
const mockRequest = {
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.logout(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
@@ -190,20 +194,21 @@ describe('OAuthAdapter', () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
isOriginAllowed: () => false,
});
const mockRequest = ({
const mockRequest = {
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'token',
},
query: {},
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledTimes(1);
@@ -220,20 +225,21 @@ describe('OAuthAdapter', () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
isOriginAllowed: () => false,
});
const mockRequest = ({
const mockRequest = {
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'token',
},
query: {},
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
send: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.send).toHaveBeenCalledTimes(1);
@@ -22,11 +22,16 @@ import {
BackstageIdentity,
AuthProviderConfig,
} from '../../providers/types';
import { InputError } from '@backstage/errors';
import { InputError, NotAllowedError } from '@backstage/errors';
import { TokenIssuer } from '../../identity/types';
import { verifyNonce } from './helpers';
import { readState, verifyNonce } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
import { OAuthHandlers, OAuthStartRequest, OAuthRefreshRequest } from './types';
import {
OAuthHandlers,
OAuthStartRequest,
OAuthRefreshRequest,
OAuthState,
} from './types';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
@@ -40,6 +45,7 @@ export type Options = {
cookiePath: string;
appOrigin: string;
tokenIssuer: TokenIssuer;
isOriginAllowed: (origin: string) => boolean;
};
export class OAuthAdapter implements AuthProviderRouteHandlers {
@@ -61,6 +67,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
cookieDomain: url.hostname,
cookiePath,
secure,
isOriginAllowed: config.isOriginAllowed,
});
}
@@ -73,6 +80,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
// retrieve scopes from request
const scope = req.query.scope?.toString() ?? '';
const env = req.query.env?.toString();
const origin = req.query.origin?.toString();
if (!env) {
throw new InputError('No env provided in request query parameters');
@@ -86,7 +94,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
// set a nonce cookie before redirecting to oauth provider
this.setNonceCookie(res, nonce);
const state = { nonce: nonce, env: env };
const state = { nonce, env, origin };
const forwardReq = Object.assign(req, { scope, state });
const { url, status } = await this.handlers.start(
@@ -103,7 +111,22 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
req: express.Request,
res: express.Response,
): Promise<void> {
let appOrigin = this.options.appOrigin;
try {
const state: OAuthState = readState(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 (!this.options.isOriginAllowed(appOrigin)) {
throw new NotAllowedError(`Origin '${appOrigin}' is not allowed`);
}
}
// verify nonce cookie and state cookie on callback
verifyNonce(req, this.options.providerId);
@@ -117,11 +140,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
response.providerInfo.scope = grantedScopes;
}
if (!this.options.disableRefresh) {
if (!refreshToken) {
throw new InputError('Missing refresh token');
}
if (refreshToken && !this.options.disableRefresh) {
// set new refresh token
this.setRefreshTokenCookie(res, refreshToken);
}
@@ -129,13 +148,13 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
await this.populateIdentity(response.backstageIdentity);
// post message back to popup if successful
return postMessageResponse(res, this.options.appOrigin, {
return postMessageResponse(res, appOrigin, {
type: 'authorization_response',
response,
});
} catch (error) {
// post error message back to popup if failure
return postMessageResponse(res, this.options.appOrigin, {
return postMessageResponse(res, appOrigin, {
type: 'authorization_response',
error: {
name: error.name,
@@ -151,10 +170,9 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
return;
}
if (!this.options.disableRefresh) {
// remove refresh token cookie before logout
this.removeRefreshTokenCookie(res);
}
// remove refresh token cookie if it is set
this.removeRefreshTokenCookie(res);
res.status(200).send('logout!');
}
@@ -15,30 +15,60 @@
*/
import express from 'express';
import { verifyNonce, encodeState } from './helpers';
import { verifyNonce, encodeState, readState } from './helpers';
describe('OAuthProvider Utils', () => {
describe('encodeState', () => {
it('should serialized values', () => {
const state = {
nonce: '123',
env: 'development',
origin: 'https://example.com',
};
const encoded = encodeState(state);
expect(encoded).toBe(
Buffer.from(
'nonce=123&env=development&origin=https%3A%2F%2Fexample.com',
).toString('hex'),
);
expect(readState(encoded)).toEqual(state);
});
it('should not include undefined values', () => {
const state = { nonce: '123', env: 'development', origin: undefined };
const encoded = encodeState(state);
expect(encoded).toBe(
Buffer.from('nonce=123&env=development').toString('hex'),
);
expect(readState(encoded)).toEqual(state);
});
});
describe('verifyNonce', () => {
it('should throw error if cookie nonce missing', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
const mockRequest = {
cookies: {},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
} as unknown as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Auth response is missing cookie nonce');
});
it('should throw error if state nonce missing', () => {
const mockRequest = ({
const mockRequest = {
cookies: {
'providera-nonce': 'NONCE',
},
query: {},
} as unknown) as express.Request;
} as unknown as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid state passed via request');
@@ -46,14 +76,14 @@ describe('OAuthProvider Utils', () => {
it('should throw error if nonce mismatch', () => {
const state = { nonce: 'NONCEB', env: 'development' };
const mockRequest = ({
const mockRequest = {
cookies: {
'providera-nonce': 'NONCEA',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
} as unknown as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid nonce');
@@ -61,14 +91,14 @@ describe('OAuthProvider Utils', () => {
it('should not throw any error if nonce matches', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
const mockRequest = {
cookies: {
'providera-nonce': 'NONCE',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
} as unknown as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).not.toThrow();
@@ -16,6 +16,7 @@
import express from 'express';
import { OAuthState } from './types';
import pickBy from 'lodash/pickBy';
export const readState = (stateString: string): OAuthState => {
const state = Object.fromEntries(
@@ -29,18 +30,16 @@ export const readState = (stateString: string): OAuthState => {
) {
throw Error(`Invalid state passed via request`);
}
return {
nonce: state.nonce,
env: state.env,
};
return state as OAuthState;
};
export const encodeState = (state: OAuthState): string => {
const searchParams = new URLSearchParams();
searchParams.append('nonce', state.nonce);
searchParams.append('env', state.env);
const stateString = new URLSearchParams(
pickBy(state, value => value !== undefined),
).toString();
return Buffer.from(searchParams.toString(), 'utf-8').toString('hex');
return Buffer.from(stateString, 'utf-8').toString('hex');
};
export const verifyNonce = (req: express.Request, providerId: string) => {
+2 -3
View File
@@ -77,6 +77,7 @@ export type OAuthState = {
*/
nonce: string;
env: string;
origin?: string;
};
export type OAuthStartRequest = express.Request<{}> & {
@@ -106,9 +107,7 @@ export interface OAuthHandlers {
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
*/
handler(
req: express.Request,
): Promise<{
handler(req: express.Request): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
@@ -23,7 +23,7 @@ import {
executeRefreshTokenStrategy,
} from './PassportStrategyHelper';
const mockRequest = ({} as unknown) as express.Request;
const mockRequest = {} as unknown as express.Request;
describe('PassportStrategyHelper', () => {
class MyCustomRedirectStrategy extends passport.Strategy {
@@ -17,9 +17,11 @@
import express from 'express';
import passport from 'passport';
import jwtDecoder from 'jwt-decode';
import { ProfileInfo, RedirectInfo } from '../../providers/types';
import { InternalOAuthError } from 'passport-oauth2';
import { PassportProfile } from './types';
import { ProfileInfo, RedirectInfo } from '../../providers/types';
export type PassportDoneCallback<Res, Private = never> = (
err?: Error,
response?: Res,
@@ -27,11 +29,9 @@ export type PassportDoneCallback<Res, Private = never> = (
) => void;
export const makeProfileInfo = (
profile: passport.Profile,
profile: PassportProfile,
idToken?: string,
): ProfileInfo => {
let { displayName } = profile;
let email: string | undefined = undefined;
if (profile.emails && profile.emails.length > 0) {
const [firstEmail] = profile.emails;
@@ -39,11 +39,16 @@ export const makeProfileInfo = (
}
let picture: string | undefined = undefined;
if (profile.photos && profile.photos.length > 0) {
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> = jwtDecoder(idToken);
@@ -193,12 +198,12 @@ type ProviderStrategy = {
export const executeFetchUserProfileStrategy = async (
providerStrategy: passport.Strategy,
accessToken: string,
): Promise<passport.Profile> => {
): Promise<PassportProfile> => {
return new Promise((resolve, reject) => {
const anyStrategy = (providerStrategy as unknown) as ProviderStrategy;
const anyStrategy = providerStrategy as unknown as ProviderStrategy;
anyStrategy.userProfile(
accessToken,
(error: Error, rawProfile: passport.Profile) => {
(error: Error, rawProfile: PassportProfile) => {
if (error) {
reject(error);
} else {
@@ -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.
*/
import passport from 'passport';
export type PassportProfile = passport.Profile & {
avatarUrl?: string;
};
@@ -79,22 +79,22 @@ describe('AwsALBAuthProvider', () => {
getEntityByName: jest.fn(),
};
const mockRequest = ({
const mockRequest = {
header: jest.fn(() => {
return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo';
}),
} as unknown) as express.Request;
const mockRequestWithoutJwt = ({
} as unknown as express.Request;
const mockRequestWithoutJwt = {
header: jest.fn(() => {
return undefined;
}),
} as unknown) as express.Request;
const mockResponse = ({
} as unknown as express.Request;
const mockResponse = {
end: jest.fn(),
header: () => jest.fn(),
json: jest.fn().mockReturnThis(),
status: jest.fn(),
} as unknown) as express.Response;
} as unknown as express.Response;
describe('should transform to type OAuthResponse', () => {
it('when JWT is valid and identity is resolved successfully', async () => {
@@ -15,4 +15,4 @@
*/
export { createGithubProvider } from './provider';
export type { GithubProviderOptions } from './provider';
export type { GithubOAuthResult, GithubProviderOptions } from './provider';
@@ -15,21 +15,47 @@
*/
import { Profile as PassportProfile } from 'passport';
import { GithubAuthProvider } from './provider';
import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
import {
GithubAuthProvider,
GithubOAuthResult,
githubDefaultSignInResolver,
} from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { OAuthResult } from '../../lib/oauth';
import { makeProfileInfo } from '../../lib/passport/PassportStrategyHelper';
const mockFrameHandler = (jest.spyOn(
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown) as jest.MockedFunction<
) as unknown as jest.MockedFunction<
() => Promise<{
result: Omit<OAuthResult, 'params'> & { params: { scope: string } };
result: GithubOAuthResult;
privateInfo: { refreshToken?: string };
}>
>;
describe('GithubAuthProvider', () => {
const tokenIssuer: TokenIssuer = {
listPublicKeys: jest.fn(),
async issueToken(params) {
return `token-for-${params.claims.sub}`;
},
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new GithubAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
signInResolver: githubDefaultSignInResolver,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
callbackUrl: 'mock',
clientId: 'mock',
clientSecret: 'mock',
@@ -63,11 +89,10 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'jimmymarkum',
token: 'token-for-jimmymarkum',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: undefined,
idToken: undefined,
scope: 'read:scope',
},
profile: {
@@ -80,6 +105,7 @@ describe('GithubAuthProvider', () => {
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
@@ -87,7 +113,7 @@ describe('GithubAuthProvider', () => {
it('when "email" is missing, it should be able to create the profile without it', async () => {
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const fullProfile = ({
const fullProfile = {
id: 'uid-123',
username: 'jimmymarkum',
provider: 'github',
@@ -99,7 +125,7 @@ describe('GithubAuthProvider', () => {
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
},
],
} as unknown) as PassportProfile;
} as unknown as PassportProfile;
const params = {
scope: 'read:scope',
@@ -108,11 +134,10 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'jimmymarkum',
token: 'token-for-jimmymarkum',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: undefined,
idToken: undefined,
scope: 'read:scope',
},
profile: {
@@ -124,6 +149,7 @@ describe('GithubAuthProvider', () => {
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
@@ -131,7 +157,7 @@ describe('GithubAuthProvider', () => {
it('when "displayName" is missing, it should be able to create the profile and map "displayName" with "username"', async () => {
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const fullProfile = ({
const fullProfile = {
id: 'uid-123',
username: 'jimmymarkum',
provider: 'github',
@@ -143,7 +169,7 @@ describe('GithubAuthProvider', () => {
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
},
],
} as unknown) as PassportProfile;
} as unknown as PassportProfile;
const params = {
scope: 'read:scope',
@@ -151,11 +177,10 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'jimmymarkum',
token: 'token-for-jimmymarkum',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: undefined,
idToken: undefined,
scope: 'read:scope',
},
profile: {
@@ -167,6 +192,7 @@ describe('GithubAuthProvider', () => {
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
@@ -178,11 +204,11 @@ describe('GithubAuthProvider', () => {
const fullProfile = {
id: 'ipd12039',
username: 'daveboyle',
provider: 'gitlab',
provider: 'github',
displayName: 'Dave Boyle',
emails: [
{
value: 'daveboyle@gitlab.org',
value: 'daveboyle@github.org',
},
],
};
@@ -194,25 +220,63 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'daveboyle',
token: 'token-for-daveboyle',
},
providerInfo: {
accessToken:
'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe',
scope: 'read:user',
expiresInSeconds: undefined,
idToken: undefined,
},
profile: {
displayName: 'Dave Boyle',
email: 'daveboyle@gitlab.org',
email: 'daveboyle@github.org',
},
};
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
});
it('should forward a refresh token', async () => {
mockFrameHandler.mockResolvedValueOnce({
result: {
fullProfile: {
id: 'ipd12039',
provider: 'github',
displayName: 'Dave Boyle',
},
accessToken: 'a.b.c',
params: {
scope: 'read:user',
expires_in: '123',
},
},
privateInfo: { refreshToken: 'refresh-me' },
});
const response = await provider.handler({} as any);
expect(response).toEqual({
response: {
backstageIdentity: {
id: 'ipd12039',
token: 'token-for-ipd12039',
},
providerInfo: {
accessToken: 'a.b.c',
scope: 'read:user',
expiresInSeconds: 123,
},
profile: {
displayName: 'Dave Boyle',
},
},
refreshToken: 'refresh-me',
});
});
});
});
@@ -15,14 +15,23 @@
*/
import express from 'express';
import { Logger } from 'winston';
import { Profile as PassportProfile } from 'passport';
import { Strategy as GithubStrategy } from 'passport-github2';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
RedirectInfo,
AuthProviderFactory,
AuthHandler,
SignInResolver,
} from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
@@ -30,19 +39,52 @@ import {
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
OAuthResult,
OAuthRefreshRequest,
OAuthResponse,
} from '../../lib/oauth';
import { CatalogIdentityClient } from '../../lib/catalog';
import { TokenIssuer } from '../../identity';
type PrivateInfo = {
refreshToken?: string;
};
export type GithubOAuthResult = {
fullProfile: PassportProfile;
params: {
scope: string;
expires_in?: string;
refresh_token_expires_in?: string;
};
accessToken: string;
refreshToken?: string;
};
export type GithubAuthProviderOptions = OAuthProviderOptions & {
tokenUrl?: string;
userProfileUrl?: string;
authorizationUrl?: string;
signInResolver?: SignInResolver<GithubOAuthResult>;
authHandler: AuthHandler<GithubOAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class GithubAuthProvider implements OAuthHandlers {
private readonly _strategy: GithubStrategy;
private readonly signInResolver?: SignInResolver<GithubOAuthResult>;
private readonly authHandler: AuthHandler<GithubOAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: GithubAuthProviderOptions) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.tokenIssuer = options.tokenIssuer;
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this._strategy = new GithubStrategy(
{
clientID: options.clientId,
@@ -54,12 +96,12 @@ export class GithubAuthProvider implements OAuthHandlers {
},
(
accessToken: any,
_refreshToken: any,
refreshToken: any,
params: any,
fullProfile: any,
done: PassportDoneCallback<OAuthResult>,
done: PassportDoneCallback<GithubOAuthResult, PrivateInfo>,
) => {
done(undefined, { fullProfile, params, accessToken });
done(undefined, { fullProfile, params, accessToken }, { refreshToken });
},
);
}
@@ -72,42 +114,109 @@ export class GithubAuthProvider implements OAuthHandlers {
}
async handler(req: express.Request) {
const {
result: { fullProfile, accessToken, params },
} = await executeFrameHandlerStrategy<OAuthResult>(req, this._strategy);
const profile = makeProfileInfo(
{
...fullProfile,
id: fullProfile.username || fullProfile.id,
displayName:
fullProfile.displayName || fullProfile.username || fullProfile.id,
},
params.id_token,
);
const { result, privateInfo } = await executeFrameHandlerStrategy<
GithubOAuthResult,
PrivateInfo
>(req, this._strategy);
return {
response: {
profile,
providerInfo: {
accessToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
},
backstageIdentity: {
id: fullProfile.username || fullProfile.id,
},
},
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
}
private async handleResult(result: GithubOAuthResult) {
const { profile } = await this.authHandler(result);
const expiresInStr = result.params.expires_in;
const response: OAuthResponse = {
providerInfo: {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds:
expiresInStr === undefined ? undefined : Number(expiresInStr),
},
profile,
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
}
return response;
}
}
export type GithubProviderOptions = {};
export const githubDefaultSignInResolver: SignInResolver<GithubOAuthResult> =
async (info, ctx) => {
const { fullProfile } = info.result;
const userId = fullProfile.username || fullProfile.id;
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
export type GithubProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<GithubOAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver?: SignInResolver<GithubOAuthResult>;
};
};
export const createGithubProvider = (
_options?: GithubProviderOptions,
options?: GithubProviderOptions,
): AuthProviderFactory => {
return ({ providerId, globalConfig, config, tokenIssuer }) =>
return ({
providerId,
globalConfig,
config,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
@@ -125,6 +234,27 @@ export const createGithubProvider = (
: undefined;
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<GithubOAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
});
const signInResolverFn =
options?.signIn?.resolver ?? githubDefaultSignInResolver;
const signInResolver: SignInResolver<GithubOAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new GithubAuthProvider({
clientId,
clientSecret,
@@ -132,10 +262,14 @@ export const createGithubProvider = (
tokenUrl,
userProfileUrl,
authorizationUrl,
signInResolver,
authHandler,
tokenIssuer,
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
persistScopes: true,
providerId,
tokenIssuer,
@@ -14,14 +14,17 @@
* limitations under the License.
*/
import { GitlabAuthProvider } from './provider';
import { GitlabAuthProvider, gitlabDefaultSignInResolver } from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { OAuthResult } from '../../lib/oauth';
import { getVoidLogger } from '../../../../../packages/backend-common/src';
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = (jest.spyOn(
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown) as jest.MockedFunction<() => Promise<{ result: OAuthResult }>>;
) as unknown as jest.MockedFunction<() => Promise<{ result: OAuthResult }>>;
describe('GitlabAuthProvider', () => {
it('should transform to type OAuthResponse', async () => {
@@ -60,12 +63,12 @@ describe('GitlabAuthProvider', () => {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: 100,
scope: 'user_read write_repository',
idToken: undefined,
},
profile: {
email: 'jimmymarkum@gmail.com',
displayName: 'Jimmy Markum',
picture:
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
picture: 'http://gitlab.com/lols',
},
},
},
@@ -102,21 +105,43 @@ describe('GitlabAuthProvider', () => {
accessToken:
'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe',
expiresInSeconds: 200,
idToken: undefined,
scope: 'read_repository',
},
profile: {
displayName: 'Dave Boyle',
email: 'daveboyle@gitlab.org',
picture: 'http://gitlab.com/lols',
},
},
},
];
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new GitlabAuthProvider({
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
baseUrl: 'mock',
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
displayName: fullProfile.displayName,
picture: 'http://gitlab.com/lols',
},
}),
signInResolver: gitlabDefaultSignInResolver,
logger: getVoidLogger(),
});
for (const test of tests) {
mockFrameHandler.mockResolvedValueOnce(test.input);
@@ -16,6 +16,8 @@
import express from 'express';
import { Strategy as GitlabStrategy } from 'passport-gitlab2';
import { Logger } from 'winston';
import {
executeRedirectStrategy,
executeFrameHandlerStrategy,
@@ -24,7 +26,12 @@ import {
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
RedirectInfo,
AuthProviderFactory,
SignInResolver,
AuthHandler,
} from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
@@ -36,10 +43,8 @@ import {
encodeState,
OAuthResult,
} from '../../lib/oauth';
type FullProfile = OAuthResult['fullProfile'] & {
avatarUrl?: string;
};
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from '../../lib/catalog';
type PrivateInfo = {
refreshToken: string;
@@ -47,29 +52,54 @@ type PrivateInfo = {
export type GitlabAuthProviderOptions = OAuthProviderOptions & {
baseUrl: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
function transformProfile(fullProfile: FullProfile) {
const profile = makeProfileInfo({
...fullProfile,
photos: [
...(fullProfile.photos ?? []),
...(fullProfile.avatarUrl ? [{ value: fullProfile.avatarUrl }] : []),
],
});
export const gitlabDefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile, result } = info;
let id = result.fullProfile.id;
let id = fullProfile.id;
if (profile.email) {
id = profile.email.split('@')[0];
}
return { id, profile };
}
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: id, ent: [`user:default/${id}`] },
});
return { id, token };
};
export const gitlabDefaultAuthHandler: AuthHandler<OAuthResult> = async ({
fullProfile,
params,
}) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
export class GitlabAuthProvider implements OAuthHandlers {
private readonly _strategy: GitlabStrategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: GitlabAuthProviderOptions) {
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this.tokenIssuer = options.tokenIssuer;
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this._strategy = new GitlabStrategy(
{
clientID: options.clientId,
@@ -109,23 +139,9 @@ export class GitlabAuthProvider implements OAuthHandlers {
OAuthResult,
PrivateInfo
>(req, this._strategy);
const { accessToken, params } = result;
const { id, profile } = transformProfile(result.fullProfile);
return {
response: {
profile,
providerInfo: {
accessToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
idToken: params.id_token,
},
backstageIdentity: {
id,
},
},
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
@@ -145,30 +161,78 @@ export class GitlabAuthProvider implements OAuthHandlers {
this._strategy,
accessToken,
);
const { id, profile } = transformProfile(fullProfile);
return {
profile,
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: newRefreshToken,
});
}
private async handleResult(result: OAuthResult): Promise<OAuthResponse> {
const { profile } = await this.authHandler(result);
const response: OAuthResponse = {
providerInfo: {
accessToken,
refreshToken: newRefreshToken, // GitLab expires the old refresh token when used
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
backstageIdentity: {
id,
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
}
return response;
}
}
export type GitlabProviderOptions = {};
export type GitlabProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
/**
* Maps an auth result to a Backstage identity for the user.
*
* Set to `'email'` to use the default email-based sign in resolver, which will search
* the catalog for a single user entity that has a matching `microsoft.com/email` annotation.
*/
signIn?: {
resolver?: SignInResolver<OAuthResult>;
};
};
export const createGitlabProvider = (
_options?: GitlabProviderOptions,
options?: GitlabProviderOptions,
): AuthProviderFactory => {
return ({ providerId, globalConfig, config, tokenIssuer }) =>
return ({
providerId,
globalConfig,
config,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
@@ -176,11 +240,34 @@ export const createGitlabProvider = (
const baseUrl = audience || 'https://gitlab.com';
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> =
options?.authHandler ?? gitlabDefaultAuthHandler;
const signInResolverFn =
options?.signIn?.resolver ?? gitlabDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new GitlabAuthProvider({
clientId,
clientSecret,
callbackUrl,
baseUrl,
authHandler,
signInResolver,
catalogIdentityClient,
logger,
tokenIssuer,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -21,10 +21,10 @@ import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = (jest.spyOn(
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown) as jest.MockedFunction<
) as unknown as jest.MockedFunction<
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
@@ -40,8 +40,9 @@ describe('createGoogleProvider', () => {
const provider = new GoogleAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient,
tokenIssuer: (tokenIssuer as unknown) as TokenIssuer,
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
@@ -240,13 +240,10 @@ export type GoogleProviderOptions = {
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
/**
* Maps an auth result to a Backstage identity for the user.
*
* Set to `'email'` to use the default email-based sign in resolver, which will search
* the catalog for a single user entity that has a matching `google.com/email` annotation.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver?: SignInResolver<OAuthResult>;
};
};
@@ -14,9 +14,12 @@
* limitations under the License.
*/
export * from './gitlab';
export * from './google';
export * from './microsoft';
export * from './oauth2';
export * from './okta';
export { factories as defaultAuthProviderFactories } from './factories';
// Export the minimal interface required for implementing a
@@ -21,10 +21,10 @@ import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = (jest.spyOn(
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown) as jest.MockedFunction<
) as unknown as jest.MockedFunction<
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
@@ -40,8 +40,9 @@ describe('createMicrosoftProvider', () => {
const provider = new MicrosoftAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient,
tokenIssuer: (tokenIssuer as unknown) as TokenIssuer,
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
@@ -220,24 +220,22 @@ export const microsoftEmailSignInResolver: SignInResolver<OAuthResult> = async (
return { id: entity.metadata.name, entity, token };
};
export const microsoftDefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
export const microsoftDefaultSignInResolver: SignInResolver<OAuthResult> =
async (info, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Profile contained no email');
}
if (!profile.email) {
throw new Error('Profile contained no email');
}
const userId = profile.email.split('@')[0];
const userId = profile.email.split('@')[0];
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
return { id: userId, token };
};
export type MicrosoftProviderOptions = {
/**
@@ -249,13 +247,10 @@ export type MicrosoftProviderOptions = {
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
/**
* Maps an auth result to a Backstage identity for the user.
*
* Set to `'email'` to use the default email-based sign in resolver, which will search
* the catalog for a single user entity that has a matching `microsoft.com/email` annotation.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver?: SignInResolver<OAuthResult>;
};
};
@@ -269,6 +269,8 @@ export const createOAuth2Provider = (
const authorizationUrl = envConfig.getString('authorizationUrl');
const tokenUrl = envConfig.getString('tokenUrl');
const scope = envConfig.getOptionalString('scope');
const disableRefresh =
envConfig.getOptionalBoolean('disableRefresh') ?? false;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
@@ -306,7 +308,7 @@ export const createOAuth2Provider = (
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
disableRefresh,
providerId,
tokenIssuer,
});
@@ -70,7 +70,7 @@ describe('OidcAuthProvider', () => {
rest.get('https://oidc.test/.well-known/openid-configuration', handler),
);
const provider = new OidcAuthProvider(clientMetadata);
const { strategy } = ((await (provider as any).implementation) as any) as {
const { strategy } = (await (provider as any).implementation) as any as {
strategy: {
_client: ClientMetadata;
_issuer: IssuerMetadata;
@@ -138,7 +138,7 @@ describe('OidcAuthProvider', () => {
const req = {
method: 'GET',
url: 'https://oidc.test/?code=test2',
session: ({ 'oidc:oidc.test': 'test' } as any) as Session,
session: { 'oidc:oidc.test': 'test' } as any as Session,
} as express.Request;
await provider.handler(req);
expect(requestSequence).toEqual([0, 1, 2].map(i => requests[i].url));
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export { createOktaProvider } from './provider';
export { createOktaProvider, oktaEmailSignInResolver } from './provider';
export type { OktaProviderOptions } from './provider';
@@ -0,0 +1,105 @@
/*
* 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 { OktaAuthProvider } from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { OAuthResult } from '../../lib/oauth';
import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown as jest.MockedFunction<
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
describe('createOktaProvider', () => {
it('should auth', async () => {
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new OktaAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
displayName: fullProfile.displayName,
},
}),
audience: 'http://example.com',
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
});
mockFrameHandler.mockResolvedValueOnce({
result: {
fullProfile: {
emails: [
{
type: 'work',
value: 'conrad@example.com',
},
],
displayName: 'Conrad',
name: {
familyName: 'Ribas',
givenName: 'Francisco',
},
id: 'conrad',
provider: 'okta',
photos: [
{
value: 'some-data',
},
],
},
params: {
id_token: 'idToken',
scope: 'scope',
expires_in: 123,
},
accessToken: 'accessToken',
},
privateInfo: {
refreshToken: 'wacka',
},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual({
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
},
});
});
});
@@ -35,8 +35,16 @@ import {
executeFetchUserProfileStrategy,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
AuthProviderFactory,
AuthHandler,
RedirectInfo,
SignInResolver,
} from '../types';
import { StateStore } from 'passport-oauth2';
import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog';
import { TokenIssuer } from '../../identity';
import { Logger } from 'winston';
type PrivateInfo = {
refreshToken: string;
@@ -44,10 +52,20 @@ type PrivateInfo = {
export type OktaAuthProviderOptions = OAuthProviderOptions & {
audience: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class OktaAuthProvider implements OAuthHandlers {
private readonly _strategy: any;
private readonly _signInResolver?: SignInResolver<OAuthResult>;
private readonly _authHandler: AuthHandler<OAuthResult>;
private readonly _tokenIssuer: TokenIssuer;
private readonly _catalogIdentityClient: CatalogIdentityClient;
private readonly _logger: Logger;
/**
* Due to passport-okta-oauth forcing options.state = true,
@@ -67,6 +85,12 @@ export class OktaAuthProvider implements OAuthHandlers {
};
constructor(options: OktaAuthProviderOptions) {
this._signInResolver = options.signInResolver;
this._authHandler = options.authHandler;
this._tokenIssuer = options.tokenIssuer;
this._catalogIdentityClient = options.catalogIdentityClient;
this._logger = options.logger;
this._strategy = new OktaStrategy(
{
clientID: options.clientId,
@@ -117,18 +141,8 @@ export class OktaAuthProvider implements OAuthHandlers {
PrivateInfo
>(req, this._strategy);
const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
return {
response: await this.populateIdentity({
profile,
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
}),
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
@@ -144,52 +158,154 @@ export class OktaAuthProvider implements OAuthHandlers {
this._strategy,
accessToken,
);
const profile = makeProfileInfo(fullProfile, params.id_token);
return this.populateIdentity({
providerInfo: {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
}
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
private async handleResult(result: OAuthResult) {
const { profile } = await this._authHandler(result);
if (!profile.email) {
throw new Error('Okta profile contained no email');
const response: OAuthResponse = {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
};
if (this._signInResolver) {
response.backstageIdentity = await this._signInResolver(
{
result,
profile,
},
{
tokenIssuer: this._tokenIssuer,
catalogIdentityClient: this._catalogIdentityClient,
logger: this._logger,
},
);
}
// TODO(Rugvip): Hardcoded to the local part of the email for now
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
return response;
}
}
export type OktaProviderOptions = {};
export const oktaEmailSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Okta profile contained no email');
}
const entity = await ctx.catalogIdentityClient.findUser({
annotations: {
'okta.com/email': profile.email,
},
});
const claims = getEntityClaims(entity);
const token = await ctx.tokenIssuer.issueToken({ claims });
return { id: entity.metadata.name, entity, token };
};
export const oktaDefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Okta profile contained no email');
}
// TODO(Rugvip): Hardcoded to the local part of the email for now
const userId = profile.email.split('@')[0];
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
export type OktaProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver?: SignInResolver<OAuthResult>;
};
};
export const createOktaProvider = (
_options?: OktaProviderOptions,
): AuthProviderFactory => {
return ({ providerId, globalConfig, config, tokenIssuer }) =>
return ({
providerId,
globalConfig,
config,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> = _options?.authHandler
? _options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolverFn =
_options?.signIn?.resolver ?? oktaDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new OktaAuthProvider({
audience,
clientId,
clientSecret,
callbackUrl,
authHandler,
signInResolver,
tokenIssuer,
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -132,6 +132,7 @@ export const createSamlProvider = (
| SignatureAlgorithm
| undefined,
digestAlgorithm: config.getOptionalString('digestAlgorithm'),
acceptedClockSkewMs: config.getOptionalNumber('acceptedClockSkewMs'),
tokenIssuer,
appUrl: globalConfig.appUrl,
@@ -34,6 +34,11 @@ export type AuthProviderConfig = {
* 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;
};
export type RedirectInfo = {
@@ -0,0 +1,54 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import { createOriginFilter } from './router';
describe('Auth origin filtering', () => {
const config = new ConfigReader({
app: {
baseUrl: 'http://example.com/extra-path',
},
auth: {
experimentalExtraAllowedOrigins: ['https://test-*.example.net'],
},
});
it('Will explode, invalid origin', () => {
const origin = 'https://test.example.net';
expect(createOriginFilter(config)(origin)).toBeFalsy();
});
it('Will explode, invalid origin domain', () => {
const origin = 'https://test-1234.examplee.net';
expect(createOriginFilter(config)(origin)).toBeFalsy();
});
it("Won't explode, uses app origin", () => {
const origin = 'http://example.com';
expect(createOriginFilter(config)(origin)).toBeTruthy();
});
it("Won't explode, valid origin with numbers", () => {
const origin = 'https://test-1234.example.net';
expect(createOriginFilter(config)(origin)).toBeTruthy();
});
it("Won't explode, valid origin with chars and numbers", () => {
const origin = 'https://test-test1234.example.net';
expect(createOriginFilter(config)(origin)).toBeTruthy();
});
});
+27 -1
View File
@@ -32,6 +32,7 @@ import { Config } from '@backstage/config';
import { createOidcRouter, DatabaseKeyStore, TokenFactory } from '../identity';
import session from 'express-session';
import passport from 'passport';
import { Minimatch } from 'minimatch';
type ProviderFactories = { [s: string]: AuthProviderFactory };
@@ -88,6 +89,8 @@ export async function createRouter({
const providersConfig = config.getConfig('auth.providers');
const configuredProviders = providersConfig.keys();
const isOriginAllowed = createOriginFilter(config);
for (const [providerId, providerFactory] of Object.entries(
allProviderFactories,
)) {
@@ -96,7 +99,7 @@ export async function createRouter({
try {
const provider = providerFactory({
providerId,
globalConfig: { baseUrl: authUrl, appUrl },
globalConfig: { baseUrl: authUrl, appUrl, isOriginAllowed },
config: providersConfig.getConfig(providerId),
logger,
tokenIssuer,
@@ -158,3 +161,26 @@ export async function createRouter({
return router;
}
export function createOriginFilter(
config: Config,
): (origin: string) => boolean {
const appUrl = config.getString('app.baseUrl');
const { origin: appOrigin } = new URL(appUrl);
const allowedOrigins = config.getOptionalStringArray(
'auth.experimentalExtraAllowedOrigins',
);
const allowedOriginPatterns =
allowedOrigins?.map(
pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }),
) ?? [];
return origin => {
if (origin === appOrigin) {
return true;
}
return allowedOriginPatterns.some(pattern => pattern.match(origin));
};
}