diff --git a/packages/backend-app-api/config.d.ts b/packages/backend-app-api/config.d.ts index 86b63b527a..137615a152 100644 --- a/packages/backend-app-api/config.d.ts +++ b/packages/backend-app-api/config.d.ts @@ -15,6 +15,26 @@ */ export interface Config { + backend?: { + auth?: { + /** + * This disables the otherwise default auth policy, which requires all + * requests to be authenticated with either user or service credentials. + * + * Disabling this check means that the backend will no longer block + * unauthenticated requests, but instead allow them to pass through to + * plugins. + * + * If permissions are enabled, unauthenticated requests will be treated + * exactly as such, leaving it to the permission policy to determine what + * permissions should be allowed for an unauthenticated identity. Note + * that this will also apply to service-to-service calls between plugins + * unless you configure credentials for service calls. + */ + dangerouslyDisableDefaultAuthPolicy?: boolean; + }; + }; + /** Discovery options. */ discovery?: { /** 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 86aeeea74d..72d5635f61 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -111,6 +111,7 @@ class DefaultAuthService implements AuthService { private readonly tokenManager: TokenManager, private readonly identity: IdentityService, private readonly pluginId: string, + private readonly disableDefaultAuthPolicy: boolean, ) {} async authenticate(token: string): Promise { @@ -171,6 +172,15 @@ class DefaultAuthService implements AuthService { 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: '' }; + } + switch (type) { // TODO: Check whether the principal is ourselves case 'service': @@ -200,8 +210,18 @@ export const authServiceFactory = createServiceFactory({ createRootContext({ config, logger }) { return ServerTokenManager.fromConfig(config, { logger }); }, - async factory({ discovery, plugin }, tokenManager) { + async factory({ discovery, config, plugin }, tokenManager) { const identity = DefaultIdentityClient.create({ discovery }); - return new DefaultAuthService(tokenManager, identity, plugin.getId()); + const disableDefaultAuthPolicy = Boolean( + config.getOptionalBoolean( + 'backend.auth.dangerouslyDisableDefaultAuthPolicy', + ), + ); + return new DefaultAuthService( + tokenManager, + identity, + plugin.getId(), + disableDefaultAuthPolicy, + ); }, }); diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts index d786df1ecc..801c87d430 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts @@ -17,6 +17,7 @@ import { HttpAuthService, HttpRouterServiceAuthPolicy, + RootConfigService, } from '@backstage/backend-plugin-api'; import { RequestHandler } from 'express'; import { pathToRegexp } from 'path-to-regexp'; @@ -37,11 +38,23 @@ export function createPathPolicyPredicate(policyPath: string) { export function createCredentialsBarrier(options: { httpAuth: HttpAuthService; + config: RootConfigService; }): { middleware: RequestHandler; addAuthPolicy: (policy: HttpRouterServiceAuthPolicy) => void; } { - const { httpAuth } = options; + const { httpAuth, config } = options; + + const disableDefaultAuthPolicy = config.getOptionalBoolean( + 'backend.auth.dangerouslyDisableDefaultAuthPolicy', + ); + + if (disableDefaultAuthPolicy) { + return { + middleware: (_req, _res, next) => next(), + addAuthPolicy: () => {}, + }; + } const unauthenticatedPredicates = new Array<(path: string) => boolean>(); const cookiePredicates = new Array<(path: string) => boolean>(); diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts index a6c586d806..58c074f985 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts @@ -16,9 +16,17 @@ import { ServiceFactoryTester, + mockCredentials, mockServices, + startTestBackend, } from '@backstage/backend-test-utils'; import { httpRouterServiceFactory } from './httpRouterServiceFactory'; +import request from 'supertest'; +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import Router from 'express-promise-router'; describe('httpRouterFactory', () => { it('should register plugin paths', async () => { @@ -69,4 +77,190 @@ describe('httpRouterFactory', () => { expect.any(Function), ); }); + + describe('auth services', () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'test', + register(reg) { + reg.registerInit({ + deps: { + httpRouter: coreServices.httpRouter, + auth: coreServices.auth, + httpAuth: coreServices.httpAuth, + }, + async init({ httpRouter, auth, httpAuth }) { + const router = Router(); + httpRouter.use(router); + httpRouter.addAuthPolicy({ + path: '/public', + allow: 'unauthenticated', + }); + httpRouter.addAuthPolicy({ + path: '/cookie', + allow: 'user-cookie', + }); + + router.get('/public', (_req, res) => { + res.json({ ok: true }); + }); + router.get('/cookie', (_req, res) => { + res.json({ ok: true }); + }); + router.get('/protected/no-checks', (_req, res) => { + res.json({ ok: true }); + }); + router.get('/protected/only-users', async (req, res) => { + await httpAuth.credentials(req, { allow: ['user'] }); + res.json({ ok: true }); + }); + router.get('/protected/only-services', async (req, res) => { + await httpAuth.credentials(req, { allow: ['service'] }); + res.json({ ok: true }); + }); + router.get('/protected/credentials', async (req, res) => { + res.json({ + credentials: await httpAuth.credentials(req), + }); + }); + router.get('/protected/service-token', async (req, res) => { + res.json( + await auth.getPluginRequestToken({ + onBehalfOf: await httpAuth.credentials(req), + targetPluginId: 'test', + }), + ); + }); + }, + }); + }, + }); + + const defaultServices = [ + httpRouterServiceFactory(), + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.none(), + }), + ]; + + it('should block unauthenticated requests by default', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + await expect( + request(server).get('/api/test/public'), + ).resolves.toMatchObject({ + status: 200, + }); + + // TODO: cookie + + await expect( + request(server).get('/api/test/protected/no-checks'), + ).resolves.toMatchObject({ + status: 401, + }); + }); + + it('should not block unauthenticated requests if default policy is disabled', async () => { + const { server } = await startTestBackend({ + features: [ + pluginSubject, + ...defaultServices, + mockServices.rootConfig.factory({ + data: { + backend: { auth: { dangerouslyDisableDefaultAuthPolicy: true } }, + }, + }), + ], + }); + + await expect( + request(server).get('/api/test/public'), + ).resolves.toMatchObject({ + status: 200, + }); + + // TODO: cookie + + await expect( + request(server).get('/api/test/protected/no-checks'), + ).resolves.toMatchObject({ + status: 200, + body: { ok: true }, + }); + + await expect( + request(server).get('/api/test/protected/only-users'), + ).resolves.toMatchObject({ + status: 401, + }); + + await expect( + request(server).get('/api/test/protected/only-services'), + ).resolves.toMatchObject({ + status: 401, + }); + + await expect( + request(server).get('/api/test/protected/credentials'), + ).resolves.toMatchObject({ + status: 200, + body: { credentials: mockCredentials.none() }, + }); + + await expect( + request(server) + .get('/api/test/protected/credentials') + .set('authorization', mockCredentials.user.header()), + ).resolves.toMatchObject({ + status: 200, + body: { credentials: mockCredentials.user() }, + }); + + await expect( + request(server) + .get('/api/test/protected/credentials') + .set('authorization', mockCredentials.service.header()), + ).resolves.toMatchObject({ + status: 200, + body: { credentials: mockCredentials.service() }, + }); + + await expect( + request(server).get('/api/test/protected/service-token'), + ).resolves.toMatchObject({ + status: 200, + body: { token: '' }, + }); + + await expect( + request(server) + .get('/api/test/protected/service-token') + .set('authorization', mockCredentials.user.header()), + ).resolves.toMatchObject({ + status: 200, + body: { + token: mockCredentials.service.token({ + onBehalfOf: mockCredentials.user(), + targetPluginId: 'test', + }), + }, + }); + + await expect( + request(server) + .get('/api/test/protected/service-token') + .set('authorization', mockCredentials.service.header()), + ).resolves.toMatchObject({ + status: 200, + body: { + token: mockCredentials.service.token({ + onBehalfOf: mockCredentials.service(), + targetPluginId: 'test', + }), + }, + }); + }); + }); }); diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts index 6c89834f2b..e72d091b96 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -40,18 +40,19 @@ export const httpRouterServiceFactory = createServiceFactory( service: coreServices.httpRouter, deps: { plugin: coreServices.pluginMetadata, + config: coreServices.rootConfig, lifecycle: coreServices.lifecycle, rootHttpRouter: coreServices.rootHttpRouter, httpAuth: coreServices.httpAuth, }, - async factory({ httpAuth, plugin, rootHttpRouter, lifecycle }) { + async factory({ httpAuth, config, plugin, rootHttpRouter, lifecycle }) { const getPath = options?.getPath ?? (id => `/api/${id}`); const path = getPath(plugin.getId()); const router = PromiseRouter(); rootHttpRouter.use(path, router); - const credentialsBarrier = createCredentialsBarrier({ httpAuth }); + const credentialsBarrier = createCredentialsBarrier({ httpAuth, config }); router.use(createLifecycleMiddleware({ lifecycle })); router.use(credentialsBarrier.middleware); diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 949caddcfa..a9ebb402c4 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -128,7 +128,10 @@ export interface MockDirectoryOptions { // @public (undocumented) export namespace mockServices { // (undocumented) - export function auth(options?: { pluginId?: string }): AuthService; + export function auth(options?: { + pluginId?: string; + disableDefaultAuthPolicy?: boolean; + }): AuthService; // (undocumented) export namespace auth { const // (undocumented) diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts index 261bc90f77..dfb54b9762 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts @@ -24,7 +24,10 @@ import { } from './mockCredentials'; describe('MockAuthService', () => { - const auth = new MockAuthService('test'); + const auth = new MockAuthService({ + pluginId: 'test', + disableDefaultAuthPolicy: false, + }); it('should reject invalid tokens', async () => { await expect(auth.authenticate('')).rejects.toThrow('Token is empty'); diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 5c58240b42..5735755f85 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -37,7 +37,16 @@ import { /** @internal */ export class MockAuthService implements AuthService { - constructor(private readonly pluginId: string) {} + readonly pluginId: string; + readonly disableDefaultAuthPolicy: boolean; + + constructor(options: { + pluginId: string; + disableDefaultAuthPolicy: boolean; + }) { + this.pluginId = options.pluginId; + this.disableDefaultAuthPolicy = options.disableDefaultAuthPolicy; + } async authenticate(token: string): Promise { switch (token) { @@ -117,6 +126,10 @@ export class MockAuthService implements AuthService { | BackstageServicePrincipal | BackstageNonePrincipal; + if (principal.type === 'none' && this.disableDefaultAuthPolicy) { + return { token: '' }; + } + if (principal.type !== 'user' && principal.type !== 'service') { throw new AuthenticationError( `Refused to issue service token for credential type '${principal.type}'`, diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts index 2f067e9dcb..9b133f996b 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts @@ -35,7 +35,10 @@ export class MockHttpAuthService implements HttpAuthService { #defaultCredentials: BackstageCredentials; constructor(pluginId: string, defaultCredentials: BackstageCredentials) { - this.#auth = new MockAuthService(pluginId); + this.#auth = new MockAuthService({ + pluginId, + disableDefaultAuthPolicy: false, + }); this.#defaultCredentials = defaultCredentials; } diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index e87319c669..f72f229434 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -169,14 +169,33 @@ export namespace mockServices { })); } - export function auth(options?: { pluginId?: string }): AuthService { - return new MockAuthService(options?.pluginId ?? 'test'); + export function auth(options?: { + pluginId?: string; + disableDefaultAuthPolicy?: boolean; + }): AuthService { + return new MockAuthService({ + pluginId: options?.pluginId ?? 'test', + disableDefaultAuthPolicy: Boolean(options?.disableDefaultAuthPolicy), + }); } export namespace auth { export const factory = createServiceFactory({ service: coreServices.auth, - deps: { plugin: coreServices.pluginMetadata }, - factory: ({ plugin }) => new MockAuthService(plugin.getId()), + deps: { + plugin: coreServices.pluginMetadata, + config: coreServices.rootConfig, + }, + factory({ plugin, config }) { + const disableDefaultAuthPolicy = Boolean( + config.getOptionalBoolean( + 'backend.auth.dangerouslyDisableDefaultAuthPolicy', + ), + ); + return new MockAuthService({ + pluginId: plugin.getId(), + disableDefaultAuthPolicy, + }); + }, }); export const mock = simpleMock(coreServices.auth, () => ({ authenticate: jest.fn(),