Use BackstageUserIdentity, fix tests

Co-authored-by: blam <ben@blam.sh>
Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Johan Haals <johan.haals@gmail.com>
This commit is contained in:
Johan Haals
2021-11-30 13:14:48 +01:00
committed by blam
parent 32b0443660
commit e9471d274c
20 changed files with 107 additions and 67 deletions
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ApiRef, createApiRef } from '../system';
import { ProfileInfo } from './auth';
import { BackstageUserIdentity, ProfileInfo } from './auth';
/*
@@ -24,26 +24,6 @@ import { ProfileInfo } from './auth';
*/
/**
* User identity information within Backstage.
*
* @public
*/
export type BackstageUserIdentity = {
type: 'user';
/**
* The entityRef of the user in the catalog.
* For example User:default/sandra
*/
userEntityRef: string;
/**
* The user and group entities that the user claims ownership through
*/
ownershipEntityRefs: string[];
};
/**
* The Identity API used to identify and get information about the signed in user.
*
@@ -96,7 +96,15 @@ describe('IdentityClient', () => {
it('should accept fresh token', async () => {
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const response = await client.authenticate(token);
expect(response).toEqual({ id: 'foo', idToken: token });
expect(response).toEqual({
id: 'foo',
token: token,
identity: {
ownershipEntityRefs: [],
type: 'user',
userEntityRef: 'foo',
},
});
});
it('should throw on incorrect issuer', async () => {
@@ -159,7 +167,15 @@ describe('IdentityClient', () => {
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime);
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const response = await client.authenticate(token);
expect(response).toEqual({ id: 'foo', idToken: token });
expect(response).toEqual({
id: 'foo',
token: token,
identity: {
ownershipEntityRefs: [],
type: 'user',
userEntityRef: 'foo',
},
});
});
it('should not be fooled by the none algorithm', async () => {
@@ -16,8 +16,9 @@
import fetch from 'node-fetch';
import { JWK, JWT, JWKS, JSONWebKey } from 'jose';
import { BackstageIdentity } from '../providers';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { AuthenticationError } from '@backstage/errors';
import { BackstageIdentityResponse } from '../providers/types';
const CLOCK_MARGIN_S = 10;
@@ -45,15 +46,17 @@ export class IdentityClient {
* Returns a BackstageIdentity (user) matching the token.
* The method throws an error if verification fails.
*/
async authenticate(token: string | undefined): Promise<BackstageIdentity> {
async authenticate(
token: string | undefined,
): Promise<BackstageIdentityResponse> {
// Extract token from header
if (!token) {
throw new Error('No token specified');
throw new AuthenticationError('No token specified');
}
// Get signing key matching token
const key = await this.getKey(token);
if (!key) {
throw new Error('No signing key matching token found');
throw new AuthenticationError('No signing key matching token found');
}
// Verify token claims and signature
// Note: Claims must match those set by TokenFactory when issuing tokens
@@ -62,12 +65,21 @@ export class IdentityClient {
algorithms: ['ES256'],
audience: 'backstage',
issuer: this.issuer,
}) as { sub: string };
}) as { sub: string; ent: string[] };
// Verified, return the matching user as BackstageIdentity
// TODO: Settle internal user format/properties
const user: BackstageIdentity = {
if (!decoded.sub) {
throw new AuthenticationError('No user sub found in token');
}
const user: BackstageIdentityResponse = {
id: decoded.sub,
idToken: token,
token,
identity: {
type: 'user',
userEntityRef: decoded.sub,
ownershipEntityRefs: decoded.ent ?? [],
},
};
return user;
}
@@ -51,7 +51,7 @@ describe('oauth helpers', () => {
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
token: 'a.b.c',
},
},
};
@@ -106,7 +106,7 @@ describe('oauth helpers', () => {
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
token: 'a.b.c',
},
},
};
@@ -148,7 +148,7 @@ describe('oauth helpers', () => {
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
token: 'a.b.c',
},
},
};
@@ -31,6 +31,7 @@ const mockResponseData = {
},
backstageIdentity: {
id: 'foo',
token: '',
},
};
@@ -19,8 +19,8 @@ import crypto from 'crypto';
import { URL } from 'url';
import {
AuthProviderRouteHandlers,
BackstageIdentity,
AuthProviderConfig,
BackstageIdentityResponse,
} from '../../providers/types';
import {
AuthenticationError,
@@ -228,17 +228,17 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
* If the response from the OAuth provider includes a Backstage identity, we
* make sure it's populated with all the information we can derive from the user ID.
*/
private async populateIdentity(identity?: BackstageIdentity) {
private async populateIdentity(identity?: BackstageIdentityResponse) {
if (!identity) {
return;
}
if (!(identity.token || identity.idToken)) {
if (!(identity.token || identity.token)) {
identity.token = await this.options.tokenIssuer.issueToken({
claims: { sub: identity.id },
});
} else if (!identity.token && identity.idToken) {
identity.token = identity.idToken;
} else if (!identity.token && identity.token) {
identity.token = identity.token;
}
}
@@ -151,7 +151,7 @@ export class Auth0AuthProvider implements OAuthHandlers {
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
return { ...response, backstageIdentity: { id, token: '' } };
}
}
+1 -1
View File
@@ -38,4 +38,4 @@ export type {
// These types are needed for a postMessage from the login pop-up
// to the frontend
export type { AuthResponse, BackstageIdentity, ProfileInfo } from './types';
export type { AuthResponse, BackstageUserIdentity, ProfileInfo } from './types';
@@ -205,7 +205,6 @@ export class OidcAuthProvider implements OAuthHandlers {
},
);
}
return response;
}
}
@@ -148,7 +148,7 @@ export class OneLoginProvider implements OAuthHandlers {
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
return { ...response, backstageIdentity: { id, token: '' } };
}
}
+25 -14
View File
@@ -140,6 +140,30 @@ export type AuthResponse<ProviderInfo> = {
backstageIdentity?: BackstageIdentityResponse;
};
/**
* User identity information within Backstage.
*
* @public
*/
export type BackstageUserIdentity = {
/**
* The type of identity that this structure represents. In the frontend app
* this will currently always be 'user'.
*/
type: 'user';
/**
* The entityRef of the user in the catalog.
* For example User:default/sandra
*/
userEntityRef: string;
/**
* The user and group entities that the user claims ownership through
*/
ownershipEntityRefs: string[];
};
/**
* @public
*/
@@ -171,20 +195,7 @@ export type BackstageIdentityResponse = {
/**
* A plaintext description of the identity that is encapsulated within the token.
*/
identity?: {
type: 'user';
/**
* The entityRef of the user in the catalog.
* For example User:default/sandra
*/
userEntityRef: string;
/**
* The user and group entities that the user claims ownership through
*/
ownershipEntityRefs: string[];
};
identity?: BackstageUserIdentity;
};
/**
@@ -79,6 +79,9 @@ describe('CatalogImportClient', () => {
signOut: () => {
return Promise.resolve();
},
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
};
const scmIntegrationsApi = ScmIntegrations.fromConfig(
@@ -37,6 +37,9 @@ describe('<DefaultImportPage />', () => {
signOut: () => {
return Promise.resolve();
},
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
};
let apis: TestApiRegistry;
@@ -43,6 +43,9 @@ describe('<ImportPage />', () => {
signOut: () => {
return Promise.resolve();
},
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
};
let apis: TestApiRegistry;
@@ -40,6 +40,9 @@ const identityApi: IdentityApi = {
async signOut() {
return Promise.resolve();
},
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
};
const guestIdentityApi: IdentityApi = {
getUserId() {
@@ -54,6 +57,9 @@ const guestIdentityApi: IdentityApi = {
async signOut() {
return Promise.resolve();
},
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
};
describe('CatalogClientWrapper', () => {
@@ -187,6 +187,9 @@ export const MockCostInsightsApiProvider = ({
getIdToken: jest.fn(),
getUserId: jest.fn(),
signOut: jest.fn(),
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
};
const defaultCostInsightsApi: CostInsightsApi = {
+2 -11
View File
@@ -24,20 +24,11 @@ import { UrlPatternDiscovery } from '@backstage/core-app-api';
const server = setupServer();
const identityApi: IdentityApi = {
getUserId() {
return 'jane-fonda';
},
getProfile() {
return { email: 'jane-fonda@spotify.com' };
},
const identityApi = {
async getIdToken() {
return Promise.resolve('fake-id-token');
},
async signOut() {
return Promise.resolve();
},
};
} as IdentityApi;
describe('FossaClient', () => {
setupRequestMockHandlers(server);
+3
View File
@@ -34,6 +34,9 @@ describe('apis', () => {
getUserId: jest.fn(),
getProfile: jest.fn(),
signOut: jest.fn(),
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
});
const client = new SearchClient({
@@ -37,6 +37,9 @@ const identityApiAuthenticated: IdentityApi = {
async signOut() {
return Promise.resolve();
},
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
};
const identityApiGuest: IdentityApi = {
getUserId() {
@@ -51,6 +54,9 @@ const identityApiGuest: IdentityApi = {
async signOut() {
return Promise.resolve();
},
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
};
describe('SonarQubeClient', () => {
+3
View File
@@ -44,6 +44,9 @@ describe('TechDocsStorageClient', () => {
getProfile: jest.fn(),
getUserId: jest.fn(),
signOut: jest.fn(),
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
};
beforeEach(() => {