Merge branch 'master' into auth_handler_and_sign-in_resolvers_oauth2
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user