Merge branch 'master' into feature/gitlab-auth-provider
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,19 +71,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
|
||||
@@ -108,20 +108,20 @@ describe('OAuthAdapter', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -141,20 +141,20 @@ describe('OAuthAdapter', () => {
|
||||
disableRefresh: true,
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -166,15 +166,15 @@ describe('OAuthAdapter', () => {
|
||||
disableRefresh: 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);
|
||||
@@ -192,18 +192,18 @@ describe('OAuthAdapter', () => {
|
||||
disableRefresh: 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);
|
||||
@@ -222,18 +222,18 @@ describe('OAuthAdapter', () => {
|
||||
disableRefresh: true,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -21,24 +21,24 @@ describe('OAuthProvider Utils', () => {
|
||||
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 +46,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 +61,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();
|
||||
|
||||
@@ -29,18 +29,14 @@ 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(state).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) => {
|
||||
|
||||
@@ -106,9 +106,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 {
|
||||
|
||||
@@ -195,7 +195,7 @@ export const executeFetchUserProfileStrategy = async (
|
||||
accessToken: string,
|
||||
): Promise<passport.Profile> => {
|
||||
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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -19,10 +19,10 @@ import { GithubAuthProvider } from './provider';
|
||||
import * as helpers from '../../lib/passport/PassportStrategyHelper';
|
||||
import { OAuthResult } from '../../lib/oauth';
|
||||
|
||||
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 } };
|
||||
}>
|
||||
@@ -87,7 +87,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 +99,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',
|
||||
@@ -131,7 +131,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 +143,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',
|
||||
|
||||
@@ -21,10 +21,10 @@ 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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
export * from './gitlab';
|
||||
export * from './google';
|
||||
export * from './microsoft';
|
||||
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 = {
|
||||
/**
|
||||
|
||||
@@ -177,6 +177,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 provider = new OAuth2AuthProvider({
|
||||
clientId,
|
||||
@@ -188,7 +190,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,157 @@ 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.
|
||||
*/
|
||||
/**
|
||||
* 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 `okta.com/email` annotation.
|
||||
*/
|
||||
signIn?: {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user