Merge pull request #8283 from backstage/mob/identity-api

core-plugin-api: stabilize IdentityApi
This commit is contained in:
Johan Haals
2021-12-08 14:29:03 +01:00
committed by GitHub
56 changed files with 1163 additions and 327 deletions
+36 -18
View File
@@ -103,7 +103,7 @@ export interface AuthProviderRouteHandlers {
export type AuthResponse<ProviderInfo> = {
providerInfo: ProviderInfo;
profile: ProfileInfo;
backstageIdentity?: BackstageIdentity;
backstageIdentity?: BackstageIdentityResponse;
};
// Warning: (ae-missing-release-tag) "AwsAlbProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -116,14 +116,28 @@ export type AwsAlbProviderOptions = {
};
};
// Warning: (ae-missing-release-tag) "BackstageIdentity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type BackstageIdentity = {
id: string;
idToken?: string;
token?: string;
// @public @deprecated
export type BackstageIdentity = BackstageSignInResult;
// @public
export interface BackstageIdentityResponse extends BackstageSignInResult {
identity: BackstageUserIdentity;
}
// @public
export interface BackstageSignInResult {
// @deprecated
entity?: Entity;
// @deprecated
id: string;
token: string;
}
// @public
export type BackstageUserIdentity = {
type: 'user';
userEntityRef: string;
ownershipEntityRefs: string[];
};
// Warning: (ae-missing-release-tag) "BitbucketOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -352,7 +366,7 @@ export type GoogleProviderOptions = {
// @public
export class IdentityClient {
constructor(options: { discovery: PluginEndpointDiscovery; issuer: string });
authenticate(token: string | undefined): Promise<BackstageIdentity>;
authenticate(token: string | undefined): Promise<BackstageIdentityResponse>;
static getBearerToken(
authorizationHeader: string | undefined,
): string | undefined;
@@ -439,7 +453,7 @@ export interface OAuthHandlers {
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
handler(req: express.Request): Promise<{
response: AuthResponse<OAuthProviderInfo>;
response: OAuthResponse;
refreshToken?: string;
}>;
logout?(): Promise<void>;
@@ -447,7 +461,7 @@ export interface OAuthHandlers {
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
refresh?(req: OAuthRefreshRequest): Promise<AuthResponse<OAuthProviderInfo>>;
refresh?(req: OAuthRefreshRequest): Promise<OAuthResponse>;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
@@ -482,10 +496,12 @@ export type OAuthRefreshRequest = express.Request<{}> & {
refreshToken: string;
};
// Warning: (ae-missing-release-tag) "OAuthResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type OAuthResponse = AuthResponse<OAuthProviderInfo>;
// @public
export type OAuthResponse = {
profile: ProfileInfo;
providerInfo: OAuthProviderInfo;
backstageIdentity?: BackstageSignInResult;
};
// Warning: (ae-missing-release-tag) "OAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -542,8 +558,11 @@ export const postMessageResponse: (
response: WebMessageResponse,
) => void;
// Warning: (ae-missing-release-tag) "ProfileInfo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export function prepareBackstageIdentityResponse(
result: BackstageSignInResult,
): BackstageIdentityResponse;
// @public
export type ProfileInfo = {
email?: string;
@@ -628,5 +647,4 @@ export type WebMessageResponse =
// src/providers/github/provider.d.ts:71:68 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@"
// src/providers/github/provider.d.ts:78:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:100:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:122:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative
```
@@ -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;
}
+1 -1
View File
@@ -29,7 +29,7 @@ export * from './providers';
// ensuresXRequestedWith and postMessageResponse to safely handle CORS requests for login. The WebMessageResponse type in flow is used to type the response from the login-popup
export * from './lib/flow';
// OAuth wrapper over a passport or a custom `startegy`.
// OAuth wrapper over a passport or a custom `strategy`.
export * from './lib/oauth';
export * from './lib/catalog';
@@ -51,7 +51,12 @@ describe('oauth helpers', () => {
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
token: 'a.b.c',
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: 'a',
},
},
},
};
@@ -106,7 +111,12 @@ describe('oauth helpers', () => {
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
token: 'a.b.c',
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: 'a',
},
},
},
};
@@ -148,7 +158,12 @@ describe('oauth helpers', () => {
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
token: 'a.b.c',
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: 'a',
},
},
},
};
@@ -31,6 +31,8 @@ const mockResponseData = {
},
backstageIdentity: {
id: 'foo',
token:
'eyblob.eyJzdWIiOiJqaW1teW1hcmt1bSIsImVudCI6WyJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iXX0=.eyblob',
},
};
@@ -217,7 +219,13 @@ describe('OAuthAdapter', () => {
...mockResponseData,
backstageIdentity: {
id: mockResponseData.backstageIdentity.id,
token: 'my-id-token',
token: mockResponseData.backstageIdentity.token,
idToken: mockResponseData.backstageIdentity.token,
identity: {
ownershipEntityRefs: ['user:default/jimmymarkum'],
type: 'user',
userEntityRef: 'jimmymarkum',
},
},
});
});
@@ -19,8 +19,9 @@ import crypto from 'crypto';
import { URL } from 'url';
import {
AuthProviderRouteHandlers,
BackstageIdentity,
AuthProviderConfig,
BackstageIdentityResponse,
BackstageSignInResult,
} from '../../providers/types';
import {
AuthenticationError,
@@ -37,6 +38,7 @@ import {
OAuthRefreshRequest,
OAuthState,
} from './types';
import { prepareBackstageIdentityResponse } from '../../providers/prepareBackstageIdentityResponse';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
@@ -150,12 +152,12 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
this.setRefreshTokenCookie(res, refreshToken);
}
await this.populateIdentity(response.backstageIdentity);
const identity = await this.populateIdentity(response.backstageIdentity);
// post message back to popup if successful
return postMessageResponse(res, appOrigin, {
type: 'authorization_response',
response,
response: { ...response, backstageIdentity: identity },
});
} catch (error) {
const { name, message } = isError(error)
@@ -209,7 +211,9 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
forwardReq as OAuthRefreshRequest,
);
await this.populateIdentity(response.backstageIdentity);
const backstageIdentity = await this.populateIdentity(
response.backstageIdentity,
);
if (
response.providerInfo.refreshToken &&
@@ -218,7 +222,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
this.setRefreshTokenCookie(res, response.providerInfo.refreshToken);
}
res.status(200).json(response);
res.status(200).json({ ...response, backstageIdentity });
} catch (error) {
throw new AuthenticationError('Refresh failed', error);
}
@@ -228,18 +232,22 @@ 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?: BackstageSignInResult,
): Promise<BackstageIdentityResponse | undefined> {
if (!identity) {
return;
return undefined;
}
if (!(identity.token || identity.idToken)) {
identity.token = await this.options.tokenIssuer.issueToken({
claims: { sub: identity.id },
});
} else if (!identity.token && identity.idToken) {
identity.token = identity.idToken;
if (identity.token) {
return prepareBackstageIdentityResponse(identity);
}
const token = await this.options.tokenIssuer.issueToken({
claims: { sub: identity.id },
});
return prepareBackstageIdentityResponse({ ...identity, token });
}
private setNonceCookie = (res: express.Response, nonce: string) => {
+17 -4
View File
@@ -16,7 +16,11 @@
import express from 'express';
import { Profile as PassportProfile } from 'passport';
import { AuthResponse, RedirectInfo } from '../../providers/types';
import {
RedirectInfo,
BackstageSignInResult,
ProfileInfo,
} from '../../providers/types';
/**
* Common options for passport.js-based OAuth providers
@@ -47,7 +51,16 @@ export type OAuthResult = {
refreshToken?: string;
};
export type OAuthResponse = AuthResponse<OAuthProviderInfo>;
/**
* The expected response from an OAuth flow.
*
* @public
*/
export type OAuthResponse = {
profile: ProfileInfo;
providerInfo: OAuthProviderInfo;
backstageIdentity?: BackstageSignInResult;
};
export type OAuthProviderInfo = {
/**
@@ -108,7 +121,7 @@ export interface OAuthHandlers {
* @param {express.Request} req
*/
handler(req: express.Request): Promise<{
response: AuthResponse<OAuthProviderInfo>;
response: OAuthResponse;
refreshToken?: string;
}>;
@@ -117,7 +130,7 @@ export interface OAuthHandlers {
* @param {string} refreshToken
* @param {string} scope
*/
refresh?(req: OAuthRefreshRequest): Promise<AuthResponse<OAuthProviderInfo>>;
refresh?(req: OAuthRefreshRequest): Promise<OAuthResponse>;
/**
* (Optional) Sign out of the auth provider.
@@ -151,7 +151,7 @@ export class Auth0AuthProvider implements OAuthHandlers {
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
return { ...response, backstageIdentity: { id, token: '' } };
}
}
@@ -122,7 +122,11 @@ describe('AwsAlbAuthProvider', () => {
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
return {
id: 'user.name',
token:
'eyblob.eyJzdWIiOiJqaW1teW1hcmt1bSIsImVudCI6WyJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iXX0=.eyblob',
};
},
});
@@ -133,7 +137,15 @@ describe('AwsAlbAuthProvider', () => {
expect(mockResponse.json).toHaveBeenCalledWith({
backstageIdentity: {
id: 'user.name',
token: 'TOKEN',
token:
'eyblob.eyJzdWIiOiJqaW1teW1hcmt1bSIsImVudCI6WyJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iXX0=.eyblob',
idToken:
'eyblob.eyJzdWIiOiJqaW1teW1hcmt1bSIsImVudCI6WyJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iXX0=.eyblob',
identity: {
ownershipEntityRefs: ['user:default/jimmymarkum'],
type: 'user',
userEntityRef: 'jimmymarkum',
},
},
profile: {
displayName: 'User Name',
@@ -32,6 +32,7 @@ import { CatalogIdentityClient } from '../../lib/catalog';
import { Profile as PassportProfile } from 'passport';
import { makeProfileInfo } from '../../lib/passport';
import { AuthenticationError } from '@backstage/errors';
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
export const ALB_JWT_HEADER = 'x-amzn-oidc-data';
export const ALB_ACCESSTOKEN_HEADER = 'x-amzn-oidc-accesstoken';
@@ -198,7 +199,7 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
accessToken: result.accessToken,
expiresInSeconds: result.expiresInSeconds,
},
backstageIdentity,
backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity),
profile,
};
}
+10 -1
View File
@@ -38,4 +38,13 @@ 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,
BackstageIdentity,
BackstageUserIdentity,
BackstageIdentityResponse,
BackstageSignInResult,
ProfileInfo,
} from './types';
export { prepareBackstageIdentityResponse } from './prepareBackstageIdentityResponse';
@@ -205,6 +205,7 @@ 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: '' } };
}
}
@@ -0,0 +1,45 @@
/*
* 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 { BackstageIdentityResponse, BackstageSignInResult } from './types';
function parseJwtPayload(token: string) {
const [_header, payload, _signature] = token.split('.');
return JSON.parse(Buffer.from(payload, 'base64').toString());
}
/**
* Parses token and decorates the BackstageIdentityResponse with identity information sourced from the token
*
* @public
*/
export function prepareBackstageIdentityResponse(
result: BackstageSignInResult,
): BackstageIdentityResponse {
const { sub, ent } = parseJwtPayload(result.token);
return {
...{
// TODO: idToken is for backwards compatibility and can be removed in the future
idToken: result.token,
...result,
},
identity: {
type: 'user',
userEntityRef: sub,
ownershipEntityRefs: ent ?? [],
},
};
}
@@ -38,6 +38,7 @@ import { TokenIssuer } from '../../identity/types';
import { isError } from '@backstage/errors';
import { CatalogIdentityClient } from '../../lib/catalog';
import { Logger } from 'winston';
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
/** @public */
export type SamlAuthResult = {
@@ -105,7 +106,7 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
const signInResponse = await this.signInResolver(
{
result,
profile,
@@ -116,6 +117,9 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
logger: this.logger,
},
);
response.backstageIdentity =
prepareBackstageIdentityResponse(signInResponse);
}
return postMessageResponse(res, this.appUrl, {
+65 -15
View File
@@ -137,42 +137,92 @@ export type AuthProviderFactory = (
export type AuthResponse<ProviderInfo> = {
providerInfo: ProviderInfo;
profile: ProfileInfo;
backstageIdentity?: BackstageIdentity;
backstageIdentity?: BackstageIdentityResponse;
};
export type BackstageIdentity = {
/**
* 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[];
};
/**
* A representation of a successful Backstage sign-in.
*
* Compared to the {@link BackstageIdentityResponse} this type omits
* the decoded identity information embedded in the token.
*
* @public
*/
export interface BackstageSignInResult {
/**
* An opaque ID that uniquely identifies the user within Backstage.
*
* This is typically the same as the user entity `metadata.name`.
*
* @deprecated Use the `identity` field instead
*/
id: string;
/**
* This is deprecated, use `token` instead.
* @deprecated
*/
idToken?: string;
/**
* The token used to authenticate the user within Backstage.
*/
token?: string;
/**
* The entity that the user is represented by within Backstage.
*
* This entity may or may not exist within the Catalog, and it can be used
* to read and store additional metadata about the user.
*
* @deprecated Use the `identity` field instead.
*/
entity?: Entity;
};
/**
* The token used to authenticate the user within Backstage.
*/
token: string;
}
/**
* The old exported symbol for {@link BackstageSignInResult}.
* @public
* @deprecated Use the `BackstageSignInResult` type instead.
*/
export type BackstageIdentity = BackstageSignInResult;
/**
* Response object containing the {@link BackstageUserIdentity} and the token from the authentication provider.
* @public
*/
export interface BackstageIdentityResponse extends BackstageSignInResult {
/**
* A plaintext description of the identity that is encapsulated within the token.
*/
identity: BackstageUserIdentity;
}
/**
* Used to display login information to user, i.e. sidebar popup.
*
* It is also temporarily used as the profile of the signed-in user's Backstage
* identity, but we want to replace that with data from identity and/org catalog service
*
* @public
*/
export type ProfileInfo = {
/**
@@ -209,7 +259,7 @@ export type SignInResolver<AuthResult> = (
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
},
) => Promise<BackstageIdentity>;
) => Promise<BackstageSignInResult>;
export type AuthHandlerResult = { profile: ProfileInfo };