diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 6c9b63ae11..8740910449 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -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; +// @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, diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index d202b19040..bfe8fd09fc 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -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", diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts new file mode 100644 index 0000000000..f0c259ad08 --- /dev/null +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -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( + credentials: BackstageCredentials, + type: TType, + ): credentials is BackstageCredentials { + const principal = credentials.principal as + | BackstageUserPrincipal + | BackstageServicePrincipal; + + if (principal.type !== type) { + return false; + } + + return true; + } + + async getOwnCredentials(): Promise< + BackstageCredentials + > { + return createCredentialsWithServicePrincipal('external:backstage-plugin'); + } + + async authenticate(token: string): Promise { + 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; +}; + +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( + req: Request, + options?: { + allow?: Array; + allowedAuthMethods?: Array<'token' | 'cookie'>; + }, + ): Promise> { + 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> { + return { + Authorization: `Bearer ${await this.auth.issueServiceToken(options)}`, + }; + } + + async issueUserCookie(_res: Response): Promise {} +} + +/** @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, + }; +} diff --git a/packages/backend-common/src/auth/index.ts b/packages/backend-common/src/auth/index.ts new file mode 100644 index 0000000000..c92ecdce25 --- /dev/null +++ b/packages/backend-common/src/auth/index.ts @@ -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'; diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index afb12115b2..4b1314b7d2 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -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';