auth-backend: add helper methods in AuthResolverContext + deprecations

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2022-03-18 14:16:15 +01:00
parent 52039f0563
commit f181c8157d
9 changed files with 309 additions and 34 deletions
+38 -4
View File
@@ -9,7 +9,9 @@ import { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
import { BackstageSignInResult } from '@backstage/plugin-auth-node';
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
import express from 'express';
import { GetEntitiesRequest } from '@backstage/catalog-client';
import { JsonValue } from '@backstage/types';
import { Logger } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
@@ -110,11 +112,40 @@ export interface AuthProviderRouteHandlers {
start(req: express.Request, res: express.Response): Promise<void>;
}
// Warning: (ae-missing-release-tag) "AuthResolverCatalogUserQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type AuthResolverCatalogUserQuery =
| {
entityRef:
| string
| {
kind?: string;
namespace?: string;
name: string;
};
}
| {
annotations: Record<string, string>;
}
| {
filter: Exclude<GetEntitiesRequest['filter'], undefined>;
};
// @public
export type AuthResolverContext = {
logger: Logger;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
issueToken(params: TokenParams): Promise<{
token: string;
}>;
findCatalogUser(query: AuthResolverCatalogUserQuery): Promise<{
entity: Entity;
}>;
signInWithCatalogUser(
query: AuthResolverCatalogUserQuery,
): Promise<BackstageSignInResult>;
};
// Warning: (ae-missing-release-tag) "AuthResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -360,10 +391,12 @@ export type GcpIapTokenInfo = {
[key: string]: JsonValue;
};
// Warning: (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts
// @public
export function getDefaultOwnershipEntityRefs(entity: Entity): string[];
// Warning: (ae-missing-release-tag) "getEntityClaims" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public @deprecated (undocumented)
export function getEntityClaims(entity: UserEntity): TokenParams['claims'];
// Warning: (ae-missing-release-tag) "GithubOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -760,5 +793,6 @@ export type WebMessageResponse =
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts
// src/providers/github/provider.d.ts:97:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:131:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:50:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:180:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
```
+2
View File
@@ -32,3 +32,5 @@ export * from './lib/flow';
export * from './lib/oauth';
export * from './lib/catalog';
export { getDefaultOwnershipEntityRefs } from './lib/resolvers';
@@ -21,6 +21,9 @@ import {
} from '@backstage/catalog-model';
import { TokenParams } from '../../identity';
/**
* @deprecated use {@link getDefaultOwnershipEntityRefs} instead
*/
export function getEntityClaims(entity: UserEntity): TokenParams['claims'] {
const userRef = stringifyEntityRef(entity);
@@ -0,0 +1,176 @@
/*
* Copyright 2022 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 { TokenManager } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import {
DEFAULT_NAMESPACE,
Entity,
parseEntityRef,
RELATION_MEMBER_OF,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { ConflictError, InputError, NotFoundError } from '@backstage/errors';
import { Logger } from 'winston';
import { TokenIssuer } from '../..';
import { TokenParams } from '../../identity';
import { AuthResolverContext } from '../../providers';
import { AuthResolverCatalogUserQuery } from '../../providers/types';
import { CatalogIdentityClient } from '../catalog';
/**
* Uses the default ownership resolution logic to return an array
* of entity refs that the provided entity claims ownership through.
*
* A reference to the entity itself will also be included in the returned array.
*
* @public
*/
export function getDefaultOwnershipEntityRefs(entity: Entity) {
const membershipRefs =
entity.relations
?.filter(r => r.type === RELATION_MEMBER_OF)
.map(r => r.targetRef) ?? [];
return Array.from(new Set([stringifyEntityRef(entity), ...membershipRefs]));
}
/**
* @internal
*/
export class CatalogAuthResolverContext implements AuthResolverContext {
static create(options: {
logger: Logger;
catalogApi: CatalogApi;
tokenIssuer: TokenIssuer;
tokenManager: TokenManager;
}): CatalogAuthResolverContext {
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi: options.catalogApi,
tokenManager: options.tokenManager,
});
return new CatalogAuthResolverContext(
options.logger,
options.tokenIssuer,
catalogIdentityClient,
options.catalogApi,
options.tokenManager,
);
}
private constructor(
public readonly logger: Logger,
public readonly tokenIssuer: TokenIssuer,
public readonly catalogIdentityClient: CatalogIdentityClient,
private readonly catalogApi: CatalogApi,
private readonly tokenManager: TokenManager,
) {}
async issueToken(params: TokenParams) {
const token = await this.tokenIssuer.issueToken(params);
return { token };
}
async findCatalogUser(query: AuthResolverCatalogUserQuery) {
let result: Entity[] | Entity | undefined = undefined;
const { token } = await this.tokenManager.getToken();
if ('entityRef' in query) {
const entityRef = parseEntityRef(query.entityRef, {
defaultKind: 'user',
defaultNamespace: DEFAULT_NAMESPACE,
});
result = await this.catalogApi.getEntityByRef(entityRef, { token });
} else if ('annotations' in query) {
const filter: Record<string, string> = {
kind: 'user',
};
for (const [key, value] of Object.entries(query.annotations)) {
filter[`metadata.annotations.${key}`] = value;
}
const res = await this.catalogApi.getEntities({ filter }, { token });
result = res.items;
} else if ('filter' in query) {
const res = await this.catalogApi.getEntities(
{ filter: query.filter },
{ token },
);
result = res.items;
} else {
throw new InputError('Invalid user lookup query');
}
if (Array.isArray(result)) {
if (result.length > 1) {
throw new ConflictError('User lookup resulted in multiple matches');
}
result = result[0];
}
if (!result) {
throw new NotFoundError('User not found');
}
return { entity: result };
}
async signInWithCatalogUser(query: AuthResolverCatalogUserQuery) {
const { entity } = await this.findCatalogUser(query);
const ownershipRefs = getDefaultOwnershipEntityRefs(entity);
const token = await this.tokenIssuer.issueToken({
claims: {
sub: stringifyEntityRef(entity),
ent: ownershipRefs,
},
});
return { token };
}
/*
async expandCatalogOwnership(query: { entityRefs: string[] }) {
const { entityRefs } = query;
const compoundRefs = entityRefs.map(ref =>
parseEntityRef(ref.toLocaleLowerCase('en-US'), {
defaultKind: 'user',
defaultNamespace: DEFAULT_NAMESPACE,
}),
);
const stringRefs = compoundRefs.map(e => stringifyEntityRef(e));
const { token } = await this.tokenManager.getToken();
const { items: entities } = await this.catalogApi.getEntities(
{
filter: compoundRefs.map(ref => ({
kind: ref.kind,
'metadata.namespace': ref.namespace,
'metadata.name': ref.name,
})),
},
{ token },
);
if (compoundRefs.length !== entities.length) {
const found = entities.map(e => stringifyEntityRef(e));
const missing = stringRefs.filter(ref => !found.includes(ref));
throw new NotFoundError(`Entities not found for refs ${missing.join()}`);
}
const memberOf = entities.flatMap(e => getDefaultOwnershipEntityRefs(e));
return Array.from(new Set(memberOf));
}
*/
}
@@ -0,0 +1,20 @@
/*
* Copyright 2022 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.
*/
export {
CatalogAuthResolverContext,
getDefaultOwnershipEntityRefs,
} from './CatalogAuthResolverContext';
@@ -17,9 +17,7 @@
import { GoogleAuthProvider } 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';
import { AuthResolverContext } from '../types';
const mockFrameHandler = jest.spyOn(
helpers,
@@ -30,21 +28,8 @@ const mockFrameHandler = jest.spyOn(
describe('createGoogleProvider', () => {
it('should auth', async () => {
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new GoogleAuthProvider({
resolverContext: {
logger: getVoidLogger(),
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
},
resolverContext: {} as AuthResolverContext,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
@@ -41,6 +41,7 @@ export type {
AuthProviderFactoryOptions,
AuthProviderFactory,
AuthHandler,
AuthResolverCatalogUserQuery,
AuthResolverContext,
AuthHandlerResult,
SignInResolver,
+62 -4
View File
@@ -18,7 +18,7 @@ import {
PluginEndpointDiscovery,
TokenManager,
} from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import { CatalogApi, GetEntitiesRequest } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import {
BackstageIdentityResponse,
@@ -26,9 +26,40 @@ import {
} from '@backstage/plugin-auth-node';
import express from 'express';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity/types';
import { TokenIssuer, TokenParams } from '../identity/types';
import { OAuthStartRequest } from '../lib/oauth/types';
import { CatalogIdentityClient } from '../lib/catalog';
import { Entity } from '@backstage/catalog-model';
/**
* A query for a single user in the catalog.
*
* If `entityRef` is used, the default kind is `'User'`.
*
* If `annotations` are used, all annotations must be present and
* match the provided value exactly. Only entities of kind `'User'` will be considered.
*
* If `filter` are used they are passed on as they are to the `CatalogApi`.
*
* Regardless of the query method, the query must match exactly one entity
* in the catalog, or an error will be thrown.
*/
export type AuthResolverCatalogUserQuery =
| {
entityRef:
| string
| {
kind?: string;
namespace?: string;
name: string;
};
}
| {
annotations: Record<string, string>;
}
| {
filter: Exclude<GetEntitiesRequest['filter'], undefined>;
};
/**
* The context that is used for auth processing.
@@ -36,9 +67,36 @@ import { CatalogIdentityClient } from '../lib/catalog';
* @public
*/
export type AuthResolverContext = {
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
/** @deprecated Will be removed from the context, access it via a closure instead if needed */
logger: Logger;
/** @deprecated Use the `issueToken` method instead */
tokenIssuer: TokenIssuer;
/** @deprecated Use the `findCatalogUser` and `signInWithCatalogUser` methods instead, and the `getDefaultOwnershipEntityRefs` helper */
catalogIdentityClient: CatalogIdentityClient;
/**
* Issues a Backstage token using the provided parameters.
*/
issueToken(params: TokenParams): Promise<{ token: string }>;
/**
* Finds a single user in the catalog using the provided query.
*
* See {@link AuthResolverCatalogUserQuery} for details.
*/
findCatalogUser(
query: AuthResolverCatalogUserQuery,
): Promise<{ entity: Entity }>;
/**
* Finds a single user in the catalog using the provided query, and then
* issues an identity for that user using default ownership resolution.
*
* See {@link AuthResolverCatalogUserQuery} for details.
*/
signInWithCatalogUser(
query: AuthResolverCatalogUserQuery,
): Promise<BackstageSignInResult>;
};
/**
+5 -9
View File
@@ -34,7 +34,7 @@ import { createOidcRouter, TokenFactory, KeyStores } from '../identity';
import session from 'express-session';
import passport from 'passport';
import { Minimatch } from 'minimatch';
import { CatalogIdentityClient } from '../lib/catalog';
import { CatalogAuthResolverContext } from '../lib/resolvers';
type ProviderFactories = { [s: string]: AuthProviderFactory };
@@ -104,11 +104,6 @@ export async function createRouter(
const isOriginAllowed = createOriginFilter(config);
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenManager,
});
for (const [providerId, providerFactory] of Object.entries(
allProviderFactories,
)) {
@@ -128,11 +123,12 @@ export async function createRouter(
tokenIssuer,
discovery,
catalogApi,
resolverContext: {
resolverContext: CatalogAuthResolverContext.create({
logger,
catalogApi,
tokenIssuer,
catalogIdentityClient,
},
tokenManager,
}),
});
const r = Router();