backend-common: add createLegacyAuthAdapters

Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Co-authored-by: Carl-Erik Bergström <cbergstrom@spotify.com>
Co-authored-by: blam <ben@blam.sh>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-02-12 16:33:53 +01:00
parent 6b19a73abc
commit 3444a2c7c9
5 changed files with 282 additions and 0 deletions
+14
View File
@@ -8,6 +8,7 @@
import { AppConfig } from '@backstage/config';
import { AuthCallback } from 'isomorphic-git';
import { AuthService } from '@backstage/backend-plugin-api';
import { AwsCredentialsManager } from '@backstage/integration-aws-node';
import { AwsS3Integration } from '@backstage/integration';
import { AzureDevOpsCredentialsProvider } from '@backstage/integration';
@@ -30,6 +31,7 @@ import { GithubCredentialsProvider } from '@backstage/integration';
import { GithubIntegration } from '@backstage/integration';
import { GitLabIntegration } from '@backstage/integration';
import { HostDiscovery as HostDiscovery_2 } from '@backstage/backend-app-api';
import { HttpAuthService } from '@backstage/backend-plugin-api';
import { IdentityService } from '@backstage/backend-plugin-api';
import { isChildPath } from '@backstage/cli-common';
import { Knex } from 'knex';
@@ -232,6 +234,18 @@ export function createDatabaseClient(
},
): knexFactory.Knex<any, any[]>;
// @public (undocumented)
export function createLegacyAuthAdapters(options: {
auth?: AuthService;
httpAuth?: HttpAuthService;
identity?: IdentityService;
tokenManager?: TokenManager;
discovery: PluginEndpointDiscovery;
}): {
auth: AuthService;
httpAuth: HttpAuthService;
};
// @public
export function createRootLogger(
options?: winston.LoggerOptions,
+1
View File
@@ -63,6 +63,7 @@
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/integration-aws-node": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/types": "workspace:^",
"@google-cloud/storage": "^7.0.0",
"@keyv/memcache": "^1.3.5",
@@ -0,0 +1,249 @@
/*
* Copyright 2024 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 {
AuthService,
BackstageCredentials,
BackstagePrincipalTypes,
BackstageServicePrincipal,
BackstageUserPrincipal,
HttpAuthService,
IdentityService,
TokenManagerService,
} from '@backstage/backend-plugin-api';
import { ServerTokenManager, TokenManager } from '../tokens';
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
import type { Request, Response } from 'express';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
createCredentialsWithServicePrincipal,
createCredentialsWithUserPrincipal,
createCredentialsWithNonePrincipal,
toInternalBackstageCredentials,
} from '../../../backend-app-api/src/services/implementations/auth/authServiceFactory';
// TODO is this circular thingy a problem? Test in e2e
import {
type IdentityApiGetIdentityRequest,
DefaultIdentityClient,
} from '@backstage/plugin-auth-node';
import { decodeJwt } from 'jose';
import { PluginEndpointDiscovery } from '../discovery';
class AuthCompat implements AuthService {
constructor(
private readonly identity: IdentityService,
private readonly tokenManager: TokenManagerService,
) {}
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
credentials: BackstageCredentials,
type: TType,
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]> {
const principal = credentials.principal as
| BackstageUserPrincipal
| BackstageServicePrincipal;
if (principal.type !== type) {
return false;
}
return true;
}
async getOwnCredentials(): Promise<
BackstageCredentials<BackstageServicePrincipal>
> {
return createCredentialsWithServicePrincipal('external:backstage-plugin');
}
async authenticate(token: string): Promise<BackstageCredentials> {
const { sub, aud } = decodeJwt(token);
// Legacy service-to-service token
if (sub === 'backstage-server' && !aud) {
await this.tokenManager.authenticate(token);
return createCredentialsWithServicePrincipal('external:backstage-plugin');
}
// User Backstage token
const identity = await this.identity.getIdentity({
request: {
headers: { authorization: `Bearer ${token}` },
},
} as IdentityApiGetIdentityRequest);
if (!identity) {
throw new AuthenticationError('Invalid user token');
}
return createCredentialsWithUserPrincipal(
identity.identity.userEntityRef,
token,
);
}
async issueServiceToken(options: {
forward: BackstageCredentials;
}): Promise<{ token: string }> {
const internalForward = toInternalBackstageCredentials(options.forward);
const { type } = internalForward.principal;
switch (type) {
// TODO: Check whether the principal is ourselves
case 'service':
return this.tokenManager.getToken();
case 'user':
if (!internalForward.token) {
throw new Error('User credentials is unexpectedly missing token');
}
return { token: internalForward.token };
// NOTE: this is not the behavior of this service in the new backend system, it only applies
// here since we'll need to accept and forward requests without authentication.
case 'none':
return { token: '' };
default:
throw new AuthenticationError(
`Refused to issue service token for credential type '${type}'`,
);
}
}
}
function getTokenFromRequest(req: Request) {
// TODO: support multiple auth headers (iterate rawHeaders)
const authHeader = req.headers.authorization;
if (typeof authHeader === 'string') {
const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i);
const token = matches?.[1];
if (token) {
return token;
}
}
return undefined;
}
const credentialsSymbol = Symbol('backstage-credentials');
type RequestWithCredentials = Request & {
[credentialsSymbol]?: Promise<BackstageCredentials>;
};
class HttpAuthCompat implements HttpAuthService {
constructor(private readonly auth: AuthService) {}
async #extractCredentialsFromRequest(req: Request) {
const token = getTokenFromRequest(req);
if (!token) {
return createCredentialsWithNonePrincipal();
}
const credentials = toInternalBackstageCredentials(
await this.auth.authenticate(token),
);
return credentials;
}
async #getCredentials(req: /* */ RequestWithCredentials) {
return (req[credentialsSymbol] ??=
this.#extractCredentialsFromRequest(req));
}
async credentials<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
req: Request,
options?: {
allow?: Array<TAllowed>;
allowedAuthMethods?: Array<'token' | 'cookie'>;
},
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
const credentials = toInternalBackstageCredentials(
await this.#getCredentials(req),
);
const allowedPrincipalTypes = options?.allow;
const allowedAuthMethods: Array<'token' | 'cookie' | 'none'> =
options?.allowedAuthMethods ?? ['token'];
if (
credentials.authMethod !== 'none' &&
!allowedAuthMethods.includes(credentials.authMethod)
) {
throw new NotAllowedError(
`This endpoint does not allow the '${credentials.authMethod}' auth method`,
);
}
if (
allowedPrincipalTypes &&
!allowedPrincipalTypes.includes(credentials.principal.type as TAllowed)
) {
throw new NotAllowedError(
`This endpoint does not allow '${credentials.principal.type}' credentials`,
);
}
return credentials as any;
}
async requestHeaders(options: {
forward: BackstageCredentials;
}): Promise<Record<string, string>> {
return {
Authorization: `Bearer ${await this.auth.issueServiceToken(options)}`,
};
}
async issueUserCookie(_res: Response): Promise<void> {}
}
/** @public */
export function createLegacyAuthAdapters(options: {
auth?: AuthService;
httpAuth?: HttpAuthService;
identity?: IdentityService;
tokenManager?: TokenManager;
discovery: PluginEndpointDiscovery;
}): {
auth: AuthService;
httpAuth: HttpAuthService;
} {
const { auth, httpAuth, discovery } = options;
if (auth || httpAuth) {
// TODO: Is this sensible? Could be that a lot of plugins only want one of them and in
// that case overloads might be better, so that it's possible to only provide one of them.
if (!(auth && httpAuth)) {
throw new Error('Both auth and httpAuth must be provided');
}
return {
auth,
httpAuth,
};
}
const identity =
options.identity ?? DefaultIdentityClient.create({ discovery });
const tokenManager = options.tokenManager ?? ServerTokenManager.noop();
const authImpl = new AuthCompat(identity, tokenManager);
const httpAuthImpl = new HttpAuthCompat(authImpl);
return {
auth: authImpl,
httpAuth: httpAuthImpl,
};
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2024 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 { createLegacyAuthAdapters } from './createLegacyAuthAdapters';
+1
View File
@@ -22,6 +22,7 @@
export { legacyPlugin, makeLegacyPlugin } from './legacy';
export type { LegacyCreateRouter } from './legacy';
export * from './auth';
export * from './cache';
export { loadBackendConfig } from './config';
export * from './database';