diff --git a/.changeset/sour-cooks-cough.md b/.changeset/sour-cooks-cough.md new file mode 100644 index 0000000000..be854618a8 --- /dev/null +++ b/.changeset/sour-cooks-cough.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-app-api': patch +'@backstage/backend-common': patch +--- + +Minor internal restructuring diff --git a/packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts b/packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts new file mode 100644 index 0000000000..8db3f033e2 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts @@ -0,0 +1,213 @@ +/* + * 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 { TokenManager } from '@backstage/backend-common'; +import { + AuthService, + BackstageCredentials, + BackstageNonePrincipal, + BackstagePrincipalTypes, + BackstageServicePrincipal, + BackstageUserPrincipal, +} from '@backstage/backend-plugin-api'; +import { AuthenticationError } from '@backstage/errors'; +import { JsonObject } from '@backstage/types'; +import { decodeJwt } from 'jose'; +import { PluginTokenHandler } from './PluginTokenHandler'; +import { UserTokenHandler } from './UserTokenHandler'; +import { + createCredentialsWithNonePrincipal, + createCredentialsWithServicePrincipal, + createCredentialsWithUserPrincipal, + toInternalBackstageCredentials, +} from './helpers'; +import { KeyStore } from './types'; + +/** @internal */ +export class DefaultAuthService implements AuthService { + constructor( + private readonly tokenManager: TokenManager, + private readonly userTokenHandler: UserTokenHandler, + private readonly pluginId: string, + private readonly disableDefaultAuthPolicy: boolean, + private readonly publicKeyStore: KeyStore, + private readonly pluginTokenHandler: PluginTokenHandler, + ) {} + + // allowLimitedAccess is currently ignored, since we currently always use the full user tokens + async authenticate(token: string): Promise { + const pluginResult = await this.pluginTokenHandler.verifyToken(token); + if (pluginResult) { + if (pluginResult.limitedUserToken) { + const userResult = await this.userTokenHandler.verifyToken( + pluginResult.limitedUserToken, + ); + if (!userResult) { + throw new AuthenticationError( + 'Invalid user token in plugin token obo claim', + ); + } + return createCredentialsWithUserPrincipal( + userResult.userEntityRef, + pluginResult.limitedUserToken, + this.#getJwtExpiration(pluginResult.limitedUserToken), + ); + } + return createCredentialsWithServicePrincipal(pluginResult.subject); + } + + const userResult = await this.userTokenHandler.verifyToken(token); + if (userResult) { + return createCredentialsWithUserPrincipal( + userResult.userEntityRef, + token, + this.#getJwtExpiration(token), + ); + } + + // Legacy service-to-service token + const { sub, aud } = decodeJwt(token); + if (sub === 'backstage-server' && !aud) { + await this.tokenManager.authenticate(token); + return createCredentialsWithServicePrincipal('external:backstage-plugin'); + } + + throw new AuthenticationError('Unknown token'); + } + + isPrincipal( + credentials: BackstageCredentials, + type: TType, + ): credentials is BackstageCredentials { + const principal = credentials.principal as + | BackstageUserPrincipal + | BackstageServicePrincipal; + + if (type === 'unknown') { + return true; + } + + if (principal.type !== type) { + return false; + } + + return true; + } + + async getNoneCredentials(): Promise< + BackstageCredentials + > { + return createCredentialsWithNonePrincipal(); + } + + async getOwnServiceCredentials(): Promise< + BackstageCredentials + > { + return createCredentialsWithServicePrincipal(`plugin:${this.pluginId}`); + } + + async getPluginRequestToken(options: { + onBehalfOf: BackstageCredentials; + targetPluginId: string; + }): Promise<{ token: string }> { + const { targetPluginId } = options; + const internalForward = toInternalBackstageCredentials(options.onBehalfOf); + const { type } = internalForward.principal; + + // Since disabling the default policy means we'll be allowing + // unauthenticated requests through, we might have unauthenticated + // credentials from service calls that reach this point. If that's the case, + // we'll want to keep "forwarding" the unauthenticated credentials, which we + // do by returning an empty token. + if (type === 'none' && this.disableDefaultAuthPolicy) { + return { token: '' }; + } + + const targetSupportsNewAuth = + await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId); + + // check whether a plugin support the new auth system + // by checking the public keys endpoint existance. + switch (type) { + // TODO: Check whether the principal is ourselves + case 'service': + if (targetSupportsNewAuth) { + return this.pluginTokenHandler.issueToken({ + pluginId: this.pluginId, + targetPluginId, + }); + } + // If the target plugin does not support the new auth service, fall back to using old token format + return this.tokenManager.getToken(); + case 'user': { + const { token } = internalForward; + if (!token) { + throw new Error('User credentials is unexpectedly missing token'); + } + // If the target plugin supports the new auth service we issue a service + // on-behalf-of token rather than forwarding the user token + if (targetSupportsNewAuth) { + const onBehalfOf = await this.userTokenHandler.createLimitedUserToken( + token, + ); + return this.pluginTokenHandler.issueToken({ + pluginId: this.pluginId, + targetPluginId, + onBehalfOf, + }); + } + + if (this.userTokenHandler.isLimitedUserToken(token)) { + throw new AuthenticationError( + `Unable to call '${targetPluginId}' plugin on behalf of user, because the target plugin does not support on-behalf-of tokens or the plugin doesn't exist`, + ); + } + return { token }; + } + default: + throw new AuthenticationError( + `Refused to issue service token for credential type '${type}'`, + ); + } + } + + async getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ token: string; expiresAt: Date }> { + const { token: backstageToken } = + toInternalBackstageCredentials(credentials); + if (!backstageToken) { + throw new AuthenticationError( + 'User credentials is unexpectedly missing token', + ); + } + + return this.userTokenHandler.createLimitedUserToken(backstageToken); + } + + async listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> { + const { keys } = await this.publicKeyStore.listKeys(); + return { keys: keys.map(({ key }) => key) }; + } + + #getJwtExpiration(token: string) { + const { exp } = decodeJwt(token); + if (!exp) { + throw new AuthenticationError('User token is missing expiration'); + } + return new Date(exp * 1000); + } +} diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts index 812657ad32..96d4ce4dc9 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts @@ -19,11 +19,7 @@ import { mockServices, setupRequestMockHandlers, } from '@backstage/backend-test-utils'; -import { - InternalBackstageCredentials, - authServiceFactory, - toInternalBackstageCredentials, -} from './authServiceFactory'; +import { authServiceFactory } from './authServiceFactory'; import { base64url, decodeJwt } from 'jose'; import { discoveryServiceFactory } from '../discovery'; import { @@ -33,6 +29,8 @@ import { import { tokenManagerServiceFactory } from '../tokenManager'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { InternalBackstageCredentials } from './types'; +import { toInternalBackstageCredentials } from './helpers'; const server = setupServer(); diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index 44eacbfc5d..226e9a6917 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -14,271 +14,14 @@ * limitations under the License. */ -import { TokenManager } from '@backstage/backend-common'; import { - AuthService, - BackstageCredentials, - BackstagePrincipalTypes, - BackstageServicePrincipal, - BackstageNonePrincipal, - BackstageUserPrincipal, coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; -import { AuthenticationError } from '@backstage/errors'; -import { decodeJwt } from 'jose'; -import { UserTokenHandler } from './UserTokenHandler'; -import { PluginTokenHandler } from './PluginTokenHandler'; -import { JsonObject } from '@backstage/types'; import { DatabaseKeyStore } from './DatabaseKeyStore'; -import { KeyStore } from './types'; - -/** @internal */ -export type InternalBackstageCredentials = - BackstageCredentials & { - version: string; - token?: string; - }; - -export function createCredentialsWithServicePrincipal( - sub: string, - token?: string, -): InternalBackstageCredentials { - return { - $$type: '@backstage/BackstageCredentials', - version: 'v1', - token, - principal: { - type: 'service', - subject: sub, - }, - }; -} - -export function createCredentialsWithUserPrincipal( - sub: string, - token: string, - expiresAt?: Date, -): InternalBackstageCredentials { - return { - $$type: '@backstage/BackstageCredentials', - version: 'v1', - token, - expiresAt, - principal: { - type: 'user', - userEntityRef: sub, - }, - }; -} - -export function createCredentialsWithNonePrincipal(): InternalBackstageCredentials { - return { - $$type: '@backstage/BackstageCredentials', - version: 'v1', - principal: { - type: 'none', - }, - }; -} - -export function toInternalBackstageCredentials( - credentials: BackstageCredentials, -): InternalBackstageCredentials< - BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal -> { - if (credentials.$$type !== '@backstage/BackstageCredentials') { - throw new Error('Invalid credential type'); - } - - const internalCredentials = credentials as InternalBackstageCredentials< - BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal - >; - - if (internalCredentials.version !== 'v1') { - throw new Error( - `Invalid credential version ${internalCredentials.version}`, - ); - } - - return internalCredentials; -} - -/** @internal */ -class DefaultAuthService implements AuthService { - constructor( - private readonly tokenManager: TokenManager, - private readonly userTokenHandler: UserTokenHandler, - private readonly pluginId: string, - private readonly disableDefaultAuthPolicy: boolean, - private readonly publicKeyStore: KeyStore, - private readonly pluginTokenHandler: PluginTokenHandler, - ) {} - - // allowLimitedAccess is currently ignored, since we currently always use the full user tokens - async authenticate(token: string): Promise { - const pluginResult = await this.pluginTokenHandler.verifyToken(token); - if (pluginResult) { - if (pluginResult.limitedUserToken) { - const userResult = await this.userTokenHandler.verifyToken( - pluginResult.limitedUserToken, - ); - if (!userResult) { - throw new AuthenticationError( - 'Invalid user token in plugin token obo claim', - ); - } - return createCredentialsWithUserPrincipal( - userResult.userEntityRef, - pluginResult.limitedUserToken, - this.#getJwtExpiration(pluginResult.limitedUserToken), - ); - } - return createCredentialsWithServicePrincipal(pluginResult.subject); - } - - const userResult = await this.userTokenHandler.verifyToken(token); - if (userResult) { - return createCredentialsWithUserPrincipal( - userResult.userEntityRef, - token, - this.#getJwtExpiration(token), - ); - } - - // Legacy service-to-service token - const { sub, aud } = decodeJwt(token); - if (sub === 'backstage-server' && !aud) { - await this.tokenManager.authenticate(token); - return createCredentialsWithServicePrincipal('external:backstage-plugin'); - } - - throw new AuthenticationError('Unknown token'); - } - - isPrincipal( - credentials: BackstageCredentials, - type: TType, - ): credentials is BackstageCredentials { - const principal = credentials.principal as - | BackstageUserPrincipal - | BackstageServicePrincipal; - - if (type === 'unknown') { - return true; - } - - if (principal.type !== type) { - return false; - } - - return true; - } - - async getNoneCredentials(): Promise< - BackstageCredentials - > { - return createCredentialsWithNonePrincipal(); - } - - async getOwnServiceCredentials(): Promise< - BackstageCredentials - > { - return createCredentialsWithServicePrincipal(`plugin:${this.pluginId}`); - } - - async getPluginRequestToken(options: { - onBehalfOf: BackstageCredentials; - targetPluginId: string; - }): Promise<{ token: string }> { - const { targetPluginId } = options; - const internalForward = toInternalBackstageCredentials(options.onBehalfOf); - const { type } = internalForward.principal; - - // Since disabling the default policy means we'll be allowing - // unauthenticated requests through, we might have unauthenticated - // credentials from service calls that reach this point. If that's the case, - // we'll want to keep "forwarding" the unauthenticated credentials, which we - // do by returning an empty token. - if (type === 'none' && this.disableDefaultAuthPolicy) { - return { token: '' }; - } - - const targetSupportsNewAuth = - await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId); - - // check whether a plugin support the new auth system - // by checking the public keys endpoint existance. - switch (type) { - // TODO: Check whether the principal is ourselves - case 'service': - if (targetSupportsNewAuth) { - return this.pluginTokenHandler.issueToken({ - pluginId: this.pluginId, - targetPluginId, - }); - } - // If the target plugin does not support the new auth service, fall back to using old token format - return this.tokenManager.getToken(); - case 'user': { - const { token } = internalForward; - if (!token) { - throw new Error('User credentials is unexpectedly missing token'); - } - // If the target plugin supports the new auth service we issue a service - // on-behalf-of token rather than forwarding the user token - if (targetSupportsNewAuth) { - const onBehalfOf = await this.userTokenHandler.createLimitedUserToken( - token, - ); - return this.pluginTokenHandler.issueToken({ - pluginId: this.pluginId, - targetPluginId, - onBehalfOf, - }); - } - - if (this.userTokenHandler.isLimitedUserToken(token)) { - throw new AuthenticationError( - `Unable to call '${targetPluginId}' plugin on behalf of user, because the target plugin does not support on-behalf-of tokens or the plugin doesn't exist`, - ); - } - return { token }; - } - default: - throw new AuthenticationError( - `Refused to issue service token for credential type '${type}'`, - ); - } - } - - async getLimitedUserToken( - credentials: BackstageCredentials, - ): Promise<{ token: string; expiresAt: Date }> { - const { token: backstageToken } = - toInternalBackstageCredentials(credentials); - if (!backstageToken) { - throw new AuthenticationError( - 'User credentials is unexpectedly missing token', - ); - } - - return this.userTokenHandler.createLimitedUserToken(backstageToken); - } - - async listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> { - const { keys } = await this.publicKeyStore.listKeys(); - return { keys: keys.map(({ key }) => key) }; - } - - #getJwtExpiration(token: string) { - const { exp } = decodeJwt(token); - if (!exp) { - throw new AuthenticationError('User token is missing expiration'); - } - return new Date(exp * 1000); - } -} +import { DefaultAuthService } from './DefaultAuthService'; +import { PluginTokenHandler } from './PluginTokenHandler'; +import { UserTokenHandler } from './UserTokenHandler'; /** @public */ export const authServiceFactory = createServiceFactory({ diff --git a/packages/backend-app-api/src/services/implementations/auth/helpers.ts b/packages/backend-app-api/src/services/implementations/auth/helpers.ts new file mode 100644 index 0000000000..01fb9537a1 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/helpers.ts @@ -0,0 +1,87 @@ +/* + * 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 { + BackstageCredentials, + BackstageNonePrincipal, + BackstageServicePrincipal, + BackstageUserPrincipal, +} from '@backstage/backend-plugin-api'; +import { InternalBackstageCredentials } from './types'; + +export function createCredentialsWithServicePrincipal( + sub: string, + token?: string, +): InternalBackstageCredentials { + return { + $$type: '@backstage/BackstageCredentials', + version: 'v1', + token, + principal: { + type: 'service', + subject: sub, + }, + }; +} + +export function createCredentialsWithUserPrincipal( + sub: string, + token: string, + expiresAt?: Date, +): InternalBackstageCredentials { + return { + $$type: '@backstage/BackstageCredentials', + version: 'v1', + token, + expiresAt, + principal: { + type: 'user', + userEntityRef: sub, + }, + }; +} + +export function createCredentialsWithNonePrincipal(): InternalBackstageCredentials { + return { + $$type: '@backstage/BackstageCredentials', + version: 'v1', + principal: { + type: 'none', + }, + }; +} + +export function toInternalBackstageCredentials( + credentials: BackstageCredentials, +): InternalBackstageCredentials< + BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal +> { + if (credentials.$$type !== '@backstage/BackstageCredentials') { + throw new Error('Invalid credential type'); + } + + const internalCredentials = credentials as InternalBackstageCredentials< + BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal + >; + + if (internalCredentials.version !== 'v1') { + throw new Error( + `Invalid credential version ${internalCredentials.version}`, + ); + } + + return internalCredentials; +} diff --git a/packages/backend-app-api/src/services/implementations/auth/types.ts b/packages/backend-app-api/src/services/implementations/auth/types.ts index b47ff2588d..11a8fcdfcf 100644 --- a/packages/backend-app-api/src/services/implementations/auth/types.ts +++ b/packages/backend-app-api/src/services/implementations/auth/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { JsonObject } from '@backstage/types'; export type KeyStore = { @@ -28,3 +29,10 @@ export type KeyPayload = { }; export type InternalKey = JsonObject & { kid: string }; + +/** @internal */ +export type InternalBackstageCredentials = + BackstageCredentials & { + version: string; + token?: string; + }; diff --git a/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts b/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts index 7d3a2af7b5..e88acaf4c2 100644 --- a/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts @@ -21,8 +21,8 @@ import { createServiceFactory, BackstageCredentials, } from '@backstage/backend-plugin-api'; -import { toInternalBackstageCredentials } from '../auth/authServiceFactory'; import { decodeJwt } from 'jose'; +import { toInternalBackstageCredentials } from '../auth/helpers'; // TODO: The intention is for this to eventually be replaced by a call to the auth-backend export class DefaultUserInfoService implements UserInfoService { diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts index 0ba23661ef..97d8cd6240 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -36,7 +36,7 @@ import { createCredentialsWithUserPrincipal, createCredentialsWithNonePrincipal, toInternalBackstageCredentials, -} from '../../../backend-app-api/src/services/implementations/auth/authServiceFactory'; +} from '../../../backend-app-api/src/services/implementations/auth/helpers'; // TODO is this circular thingy a problem? Test in e2e import { type IdentityApiGetIdentityRequest,