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 19dfbe1299..78ff97a139 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -35,7 +35,6 @@ export type InternalBackstageCredentials = BackstageCredentials & { version: string; token?: string; - authMethod: 'token' | 'cookie' | 'none'; }; export function createCredentialsWithServicePrincipal( @@ -48,24 +47,23 @@ export function createCredentialsWithServicePrincipal( type: 'service', subject: sub, }, - authMethod: 'token', }; } export function createCredentialsWithUserPrincipal( sub: string, token: string, - authMethod: 'token' | 'cookie' = 'token', + expiresAt?: Date, ): InternalBackstageCredentials { return { $$type: '@backstage/BackstageCredentials', version: 'v1', token, + expiresAt, principal: { type: 'user', userEntityRef: sub, }, - authMethod, }; } @@ -76,7 +74,6 @@ export function createCredentialsWithNonePrincipal(): InternalBackstageCredentia principal: { type: 'none', }, - authMethod: 'none', }; } @@ -111,6 +108,7 @@ class DefaultAuthService implements AuthService { private readonly disableDefaultAuthPolicy: boolean, ) {} + // allowLimitedAccess is currently ignored, since we currently always use the full user tokens async authenticate(token: string): Promise { const { sub, aud } = decodeJwt(token); @@ -134,6 +132,7 @@ class DefaultAuthService implements AuthService { return createCredentialsWithUserPrincipal( identity.identity.userEntityRef, token, + this.#getJwtExpiration(token), ); } @@ -156,6 +155,12 @@ class DefaultAuthService implements AuthService { return true; } + async getNoneCredentials(): Promise< + BackstageCredentials + > { + return createCredentialsWithNonePrincipal(); + } + async getOwnServiceCredentials(): Promise< BackstageCredentials > { @@ -193,6 +198,30 @@ class DefaultAuthService implements AuthService { ); } } + + async getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ token: string; expiresAt: Date }> { + const internalCredentials = toInternalBackstageCredentials(credentials); + + const { token } = internalCredentials; + + if (!token) { + throw new AuthenticationError( + 'User credentials is unexpectedly missing token', + ); + } + + return { token, expiresAt: this.#getJwtExpiration(token) }; + } + + #getJwtExpiration(token: string) { + const { exp } = decodeJwt(token); + if (!exp) { + throw new AuthenticationError('User token is missing expiration'); + } + return new Date(exp * 1000); + } } /** @public */ diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index 1bd9d3cf2a..db36e5bf2a 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -18,6 +18,7 @@ import { AuthService, BackstageCredentials, BackstagePrincipalTypes, + BackstageUserPrincipal, DiscoveryService, HttpAuthService, coreServices, @@ -26,11 +27,8 @@ import { import { AuthenticationError, NotAllowedError } from '@backstage/errors'; import { parse as parseCookie } from 'cookie'; import { Request, Response } from 'express'; -import { decodeJwt } from 'jose'; -import { - createCredentialsWithNonePrincipal, - toInternalBackstageCredentials, -} from '../auth/authServiceFactory'; + +const FIVE_MINUTES_MS = 5 * 60 * 1000; const BACKSTAGE_AUTH_COOKIE = 'backstage-auth'; @@ -41,54 +39,78 @@ function getTokenFromRequest(req: Request) { const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i); const token = matches?.[1]; if (token) { - return { token, isCookie: false }; + return token; } } + return undefined; +} + +function getCookieFromRequest(req: Request) { const cookieHeader = req.headers.cookie; if (cookieHeader) { const cookies = parseCookie(cookieHeader); const token = cookies[BACKSTAGE_AUTH_COOKIE]; if (token) { - return { token, isCookie: true }; + return token; } } - return { token: undefined, isCookie: false }; + return undefined; +} + +function willExpireSoon(expiresAt: Date) { + return Date.now() + FIVE_MINUTES_MS > expiresAt.getTime(); } const credentialsSymbol = Symbol('backstage-credentials'); +const limitedCredentialsSymbol = Symbol('backstage-limited-credentials'); type RequestWithCredentials = Request & { [credentialsSymbol]?: Promise; + [limitedCredentialsSymbol]?: Promise; }; class DefaultHttpAuthService implements HttpAuthService { + readonly #auth: AuthService; + readonly #discovery: DiscoveryService; + readonly #pluginId: string; + constructor( - private readonly auth: AuthService, - private readonly discovery: DiscoveryService, - private readonly pluginId: string, - ) {} + auth: AuthService, + discovery: DiscoveryService, + pluginId: string, + ) { + this.#auth = auth; + this.#discovery = discovery; + this.#pluginId = pluginId; + } async #extractCredentialsFromRequest(req: Request) { - const { token, isCookie } = getTokenFromRequest(req); + const token = getTokenFromRequest(req); if (!token) { - return createCredentialsWithNonePrincipal(); + return await this.#auth.getNoneCredentials(); } - const credentials = toInternalBackstageCredentials( - await this.auth.authenticate(token), - ); - if (isCookie) { - if (credentials.principal.type !== 'user') { - throw new AuthenticationError( - 'Refusing to authenticate non-user principal with cookie auth', - ); - } - credentials.authMethod = 'cookie'; + return await this.#auth.authenticate(token); + } + + async #extractLimitedCredentialsFromRequest(req: Request) { + const token = getTokenFromRequest(req); + if (token) { + return await this.#auth.authenticate(token, { + allowLimitedAccess: true, + }); } - return credentials; + const cookie = getCookieFromRequest(req); + if (cookie) { + return await this.#auth.authenticate(cookie, { + allowLimitedAccess: true, + }); + } + + return await this.#auth.getNoneCredentials(); } async #getCredentials(req: RequestWithCredentials) { @@ -96,73 +118,131 @@ class DefaultHttpAuthService implements HttpAuthService { this.#extractCredentialsFromRequest(req)); } + async #getLimitedCredentials(req: RequestWithCredentials) { + return (req[limitedCredentialsSymbol] ??= + this.#extractLimitedCredentialsFromRequest(req)); + } + async credentials( req: Request, options?: { allow?: Array; - allowedAuthMethods?: Array<'token' | 'cookie'>; + allowLimitedAccess?: boolean; }, ): Promise> { - const credentials = toInternalBackstageCredentials( - await this.#getCredentials(req), - ); + // Limited and full credentials are treated as two separate cases, this lets + // us avoid internal dependencies between the AuthService and + // HttpAuthService implementations + const credentials = options?.allowLimitedAccess + ? await this.#getLimitedCredentials(req) + : 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`, - ); + const allowed = options?.allow; + if (!allowed) { + return credentials as any; } - if ( - allowedPrincipalTypes && - !allowedPrincipalTypes.includes(credentials.principal.type as TAllowed) - ) { - if (credentials.authMethod === 'none') { - throw new AuthenticationError(); + if (this.#auth.isPrincipal(credentials, 'none')) { + if (allowed.includes('none' as TAllowed)) { + return credentials as any; } + + throw new AuthenticationError('Missing credentials'); + } else if (this.#auth.isPrincipal(credentials, 'user')) { + if (allowed.includes('user' as TAllowed)) { + return credentials as any; + } + throw new NotAllowedError( - `This endpoint does not allow '${credentials.principal.type}' credentials`, + `This endpoint does not allow 'user' credentials`, + ); + } else if (this.#auth.isPrincipal(credentials, 'service')) { + if (allowed.includes('service' as TAllowed)) { + return credentials as any; + } + + throw new NotAllowedError( + `This endpoint does not allow 'service' credentials`, ); } - return credentials as any; + throw new NotAllowedError( + 'Unknown principal type, this should never happen', + ); } - async issueUserCookie(res: Response): Promise { - const credentials = await this.credentials(res.req, { allow: ['user'] }); + async issueUserCookie( + res: Response, + options?: { credentials?: BackstageCredentials }, + ): Promise<{ expiresAt: Date }> { + if (res.headersSent) { + throw new Error('Failed to issue user cookie, headers were already sent'); + } + + let credentials: BackstageCredentials; + if (options?.credentials) { + if (!this.#auth.isPrincipal(options.credentials, 'user')) { + throw new AuthenticationError( + 'Refused to issue cookie for non-user principal', + ); + } + credentials = options.credentials; + } else { + credentials = await this.credentials(res.req, { allow: ['user'] }); + } + + const existingExpiresAt = await this.#existingCookieExpiration(res.req); + if (existingExpiresAt && !willExpireSoon(existingExpiresAt)) { + return { expiresAt: existingExpiresAt }; + } + + const originHeader = res.req.headers.origin; + const origin = + !originHeader || originHeader === 'null' ? undefined : originHeader; // https://backstage.example.com/api/catalog - const externalBaseUrlStr = await this.discovery.getExternalBaseUrl( - this.pluginId, + const externalBaseUrlStr = await this.#discovery.getExternalBaseUrl( + this.#pluginId, ); - const externalBaseUrl = new URL(externalBaseUrlStr); + const externalBaseUrl = new URL(origin ?? externalBaseUrlStr); - const { token } = toInternalBackstageCredentials(credentials); + const { token, expiresAt } = await this.#auth.getLimitedUserToken( + credentials, + ); if (!token) { throw new Error('User credentials is unexpectedly missing token'); } - // TODO: Proper refresh and expiration handling - const expires = decodeJwt(token).exp!; + const secure = + externalBaseUrl.protocol === 'https:' || + externalBaseUrl.hostname === 'localhost'; - // TODO: refresh this thing res.cookie(BACKSTAGE_AUTH_COOKIE, token, { domain: externalBaseUrl.hostname, httpOnly: true, - expires: new Date(expires * 1000), - path: externalBaseUrl.pathname, + expires: expiresAt, + secure, priority: 'high', - sameSite: 'lax', // TBD + sameSite: secure ? 'none' : 'lax', }); - throw new Error('Method not implemented.'); + return { expiresAt }; + } + + async #existingCookieExpiration(req: Request): Promise { + const existingCookie = getCookieFromRequest(req); + if (!existingCookie) { + return undefined; + } + + const existingCredentials = await this.#auth.authenticate(existingCookie, { + allowLimitedAccess: true, + }); + if (!this.#auth.isPrincipal(existingCredentials, 'user')) { + return undefined; + } + + return existingCredentials.expiresAt; } } diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.test.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.test.ts index b8430d398e..a5246c91b2 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.test.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.test.ts @@ -53,7 +53,10 @@ describe('createCredentialsBarrier', () => { .expect(401) .expect(res => expect(res.body).toMatchObject({ - error: { name: 'AuthenticationError', message: '' }, + error: { + name: 'AuthenticationError', + message: 'Missing credentials', + }, }), ); @@ -98,7 +101,7 @@ describe('createCredentialsBarrier', () => { .expect(200); }); - it('should allow exceptions to the default auth policy to be made', async () => { + it('should allow exceptions for unauthenticated access', async () => { const { app, barrier } = setup(); await request(app).get('/').send().expect(401); @@ -118,5 +121,55 @@ describe('createCredentialsBarrier', () => { await request(app).get('/other').send().expect(200); }); - // TODO: cookie auth + it('should allow exceptions for cookie access', async () => { + const { app, barrier } = setup(); + + await request(app).get('/').send().expect(401); + await request(app).get('/public').send().expect(401); + await request(app).get('/other').send().expect(401); + await request(app) + .get('/static') + .set('cookie', mockCredentials.limitedUser.cookie()) + .send() + .expect(401); + await request(app) + .get('/static') + .set('authorization', mockCredentials.user.header()) + .send() + .expect(200); + + barrier.addAuthPolicy({ allow: 'user-cookie', path: '/static' }); + + await request(app).get('/').send().expect(401); + await request(app).get('/static').send().expect(401); + await request(app) + .get('/static') + .set('cookie', mockCredentials.limitedUser.cookie()) + .send() + .expect(200); + await request(app) + .get('/static') + .set('authorization', mockCredentials.user.header()) + .send() + .expect(200); + + await request(app).get('/other').send().expect(401); + + // Unauthenticated access should take precedence + barrier.addAuthPolicy({ allow: 'unauthenticated', path: '/' }); + + await request(app).get('/').send().expect(200); + await request(app).get('/static').send().expect(200); + await request(app) + .get('/static') + .set('cookie', mockCredentials.limitedUser.cookie()) + .send() + .expect(200); + await request(app) + .get('/static') + .set('cookie', mockCredentials.limitedUser.invalidCookie()) + .send() + .expect(200); + await request(app).get('/other').send().expect(200); + }); }); 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 a512c5ac9e..a69fa5a804 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts @@ -76,7 +76,7 @@ export function createCredentialsBarrier(options: { httpAuth .credentials(req, { allow: ['user', 'service'], - allowedAuthMethods: allowsCookie ? ['token', 'cookie'] : ['token'], + allowLimitedAccess: allowsCookie, }) .then( () => next(), diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts index d12dd8b9fc..a46d553c5d 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -17,6 +17,7 @@ import { AuthService, BackstageCredentials, + BackstageNonePrincipal, BackstagePrincipalTypes, BackstageServicePrincipal, BackstageUserInfo, @@ -65,6 +66,12 @@ class AuthCompat implements AuthService { return true; } + async getNoneCredentials(): Promise< + BackstageCredentials + > { + return createCredentialsWithNonePrincipal(); + } + async getOwnServiceCredentials(): Promise< BackstageCredentials > { @@ -89,6 +96,7 @@ class AuthCompat implements AuthService { return createCredentialsWithUserPrincipal( identity.identity.userEntityRef, token, + this.#getJwtExpiration(token), ); } @@ -123,6 +131,30 @@ class AuthCompat implements AuthService { ); } } + + async getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ token: string; expiresAt: Date }> { + const internalCredentials = toInternalBackstageCredentials(credentials); + + const { token } = internalCredentials; + + if (!token) { + throw new AuthenticationError( + 'User credentials is unexpectedly missing token', + ); + } + + return { token, expiresAt: this.#getJwtExpiration(token) }; + } + + #getJwtExpiration(token: string) { + const { exp } = decodeJwt(token); + if (!exp) { + throw new AuthenticationError('User token is missing expiration'); + } + return new Date(exp * 1000); + } } function getTokenFromRequest(req: Request) { @@ -146,7 +178,11 @@ type RequestWithCredentials = Request & { }; class HttpAuthCompat implements HttpAuthService { - constructor(private readonly auth: AuthService) {} + #auth: AuthService; + + constructor(auth: AuthService) { + this.#auth = auth; + } async #extractCredentialsFromRequest(req: Request) { const token = getTokenFromRequest(req); @@ -155,7 +191,7 @@ class HttpAuthCompat implements HttpAuthService { } const credentials = toInternalBackstageCredentials( - await this.auth.authenticate(token), + await this.#auth.authenticate(token), ); return credentials; @@ -170,39 +206,50 @@ class HttpAuthCompat implements HttpAuthService { req: Request, options?: { allow?: Array; - allowedAuthMethods?: Array<'token' | 'cookie'>; + allowLimitedAccess?: boolean; }, ): Promise> { const credentials = toInternalBackstageCredentials( await this.#getCredentials(req), ); - const allowedPrincipalTypes = options?.allow; - const allowedAuthMethods: Array<'token' | 'cookie' | 'none'> = - options?.allowedAuthMethods ?? ['token']; + const allowed = options?.allow; + if (!allowed) { + return credentials as any; + } + + if (this.#auth.isPrincipal(credentials, 'none')) { + if (allowed.includes('none' as TAllowed)) { + return credentials as any; + } + + throw new AuthenticationError('Missing credentials'); + } else if (this.#auth.isPrincipal(credentials, 'user')) { + if (allowed.includes('user' as TAllowed)) { + return credentials as any; + } - if ( - credentials.authMethod !== 'none' && - !allowedAuthMethods.includes(credentials.authMethod) - ) { throw new NotAllowedError( - `This endpoint does not allow the '${credentials.authMethod}' auth method`, + `This endpoint does not allow 'user' credentials`, + ); + } else if (this.#auth.isPrincipal(credentials, 'service')) { + if (allowed.includes('service' as TAllowed)) { + return credentials as any; + } + + throw new NotAllowedError( + `This endpoint does not allow 'service' credentials`, ); } - 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; + throw new NotAllowedError( + 'Unknown principal type, this should never happen', + ); } - async issueUserCookie(_res: Response): Promise {} + async issueUserCookie(_res: Response): Promise<{ expiresAt: Date }> { + return { expiresAt: new Date(Date.now() + 3600_000) }; + } } export class UserInfoCompat implements UserInfoService { diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index cb44ba158d..73c1d15cd1 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -24,7 +24,21 @@ import { Response as Response_2 } from 'express'; // @public (undocumented) export interface AuthService { // (undocumented) - authenticate(token: string): Promise; + authenticate( + token: string, + options?: { + allowLimitedAccess?: boolean; + }, + ): Promise; + // (undocumented) + getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ + token: string; + expiresAt: Date; + }>; + // (undocumented) + getNoneCredentials(): Promise>; // (undocumented) getOwnServiceCredentials(): Promise< BackstageCredentials @@ -107,6 +121,7 @@ export interface BackendPluginRegistrationPoints { // @public (undocumented) export type BackstageCredentials = { $$type: '@backstage/BackstageCredentials'; + expiresAt?: Date; principal: TPrincipal; }; @@ -299,11 +314,18 @@ export interface HttpAuthService { req: Request_2, options?: { allow?: Array; - allowedAuthMethods?: Array<'token' | 'cookie'>; + allowLimitedAccess?: boolean; }, ): Promise>; // (undocumented) - issueUserCookie(res: Response_2): Promise; + issueUserCookie( + res: Response_2, + options?: { + credentials?: BackstageCredentials; + }, + ): Promise<{ + expiresAt: Date; + }>; } // @public (undocumented) diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts index eab0a7fac7..2bcdc975a0 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -46,6 +46,8 @@ export type BackstageServicePrincipal = { export type BackstageCredentials = { $$type: '@backstage/BackstageCredentials'; + expiresAt?: Date; + principal: TPrincipal; }; @@ -63,13 +65,20 @@ export type BackstagePrincipalTypes = { * @public */ export interface AuthService { - authenticate(token: string): Promise; + authenticate( + token: string, + options?: { + allowLimitedAccess?: boolean; + }, + ): Promise; isPrincipal( credentials: BackstageCredentials, type: TType, ): credentials is BackstageCredentials; + getNoneCredentials(): Promise>; + getOwnServiceCredentials(): Promise< BackstageCredentials >; @@ -78,4 +87,8 @@ export interface AuthService { onBehalfOf: BackstageCredentials; targetPluginId: string; }): Promise<{ token: string }>; + + getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ token: string; expiresAt: Date }>; } diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts index 44696bd777..637109d1f0 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -15,7 +15,11 @@ */ import { Request, Response } from 'express'; -import { BackstageCredentials, BackstagePrincipalTypes } from './AuthService'; +import { + BackstageCredentials, + BackstagePrincipalTypes, + BackstageUserPrincipal, +} from './AuthService'; /** @public */ export interface HttpAuthService { @@ -23,9 +27,14 @@ export interface HttpAuthService { req: Request, options?: { allow?: Array; - allowedAuthMethods?: Array<'token' | 'cookie'>; + allowLimitedAccess?: boolean; }, ): Promise>; - issueUserCookie(res: Response): Promise; + issueUserCookie( + res: Response, + options?: { + credentials?: BackstageCredentials; + }, + ): Promise<{ expiresAt: Date }>; } diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index d0e37a476d..ff3f50ef8c 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -50,6 +50,17 @@ export function isDockerDisabledForTests(): boolean; // @public (undocumented) export namespace mockCredentials { + export function limitedUser( + userEntityRef?: string, + ): BackstageCredentials; + export namespace limitedUser { + export function cookie(userEntityRef?: string): string; + // (undocumented) + export function invalidCookie(): string; + // (undocumented) + export function invalidToken(): string; + export function token(userEntityRef?: string): string; + } export function none(): BackstageCredentials; export namespace none { export function header(): string; diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index e2fc9dd3e6..801b7c125b 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -1,16 +1,30 @@ { "name": "@backstage/backend-test-utils", - "description": "Test helpers library for Backstage backends", "version": "0.3.0", - "main": "src/index.ts", - "types": "src/index.ts", + "description": "Test helpers library for Backstage backends", + "backstage": { + "role": "node-library" + }, "publishConfig": { "access": "public" }, + "keywords": [ + "backstage", + "test" + ], + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/backend-test-utils" + }, + "license": "Apache-2.0", "exports": { ".": "./src/index.ts", "./package.json": "./package.json" }, + "main": "src/index.ts", + "types": "src/index.ts", "typesVersions": { "*": { "package.json": [ @@ -18,28 +32,17 @@ ] } }, - "backstage": { - "role": "node-library" - }, - "homepage": "https://backstage.io", - "repository": { - "type": "git", - "url": "https://github.com/backstage/backstage", - "directory": "packages/backend-test-utils" - }, - "keywords": [ - "backstage", - "test" + "files": [ + "dist" ], - "license": "Apache-2.0", "scripts": { "build": "backstage-cli package build", + "clean": "backstage-cli package clean", "lint": "backstage-cli package lint", - "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack", - "clean": "backstage-cli package clean", - "start": "backstage-cli package start" + "start": "backstage-cli package start", + "test": "backstage-cli package test" }, "dependencies": { "@backstage/backend-app-api": "workspace:^", @@ -50,6 +53,7 @@ "@backstage/plugin-auth-node": "workspace:^", "@backstage/types": "workspace:^", "better-sqlite3": "^9.0.0", + "cookie": "^0.6.0", "express": "^4.17.1", "fs-extra": "^11.0.0", "knex": "^3.0.0", @@ -60,15 +64,12 @@ "textextensions": "^5.16.0", "uuid": "^9.0.0" }, - "peerDependencies": { - "@types/jest": "*" - }, "devDependencies": { "@backstage/cli": "workspace:^", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, - "files": [ - "dist" - ] + "peerDependencies": { + "@types/jest": "*" + } } 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 dfb54b9762..8741220a68 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts @@ -53,6 +53,12 @@ describe('MockAuthService', () => { auth.authenticate(mockCredentials.user.token()), ).resolves.toEqual(mockCredentials.user()); + await expect( + auth.authenticate(mockCredentials.user.token(), { + allowLimitedAccess: true, + }), + ).resolves.toEqual(mockCredentials.user()); + await expect( auth.authenticate(mockCredentials.user.token()), ).resolves.toEqual(mockCredentials.user(DEFAULT_MOCK_USER_ENTITY_REF)); @@ -66,6 +72,44 @@ describe('MockAuthService', () => { ).rejects.toThrow('User token is invalid'); }); + it('should authenticate mock limited user tokens', async () => { + await expect( + auth.authenticate(mockCredentials.limitedUser.token()), + ).rejects.toThrow('Limited user token is not allowed'); + await expect( + auth.authenticate(mockCredentials.limitedUser.token(), {}), + ).rejects.toThrow('Limited user token is not allowed'); + await expect( + auth.authenticate(mockCredentials.limitedUser.token(), { + allowLimitedAccess: false, + }), + ).rejects.toThrow('Limited user token is not allowed'); + await expect( + auth.authenticate(mockCredentials.limitedUser.token(), { + allowLimitedAccess: true, + }), + ).resolves.toEqual(mockCredentials.user()); + + await expect( + auth.authenticate(mockCredentials.limitedUser.token(), { + allowLimitedAccess: true, + }), + ).resolves.toEqual(mockCredentials.user(DEFAULT_MOCK_USER_ENTITY_REF)); + + await expect( + auth.authenticate( + mockCredentials.limitedUser.token('user:default/other'), + { + allowLimitedAccess: true, + }, + ), + ).resolves.toEqual(mockCredentials.user('user:default/other')); + + await expect( + auth.authenticate(mockCredentials.limitedUser.invalidToken()), + ).rejects.toThrow('Limited user token is invalid'); + }); + it('should authenticate mock service tokens', async () => { await expect( auth.authenticate(mockCredentials.service.token()), @@ -113,6 +157,12 @@ describe('MockAuthService', () => { ).rejects.toThrow('Service token is invalid'); }); + it('should return none credentials', async () => { + await expect(auth.getNoneCredentials()).resolves.toEqual( + mockCredentials.none(), + ); + }); + it('should return own service credentials', async () => { await expect(auth.getOwnServiceCredentials()).resolves.toEqual( mockCredentials.service('plugin:test'), @@ -211,4 +261,32 @@ describe('MockAuthService', () => { `Refused to issue service token for credential type 'none'`, ); }); + + it('should issue limited user tokens', async () => { + await expect( + auth.getLimitedUserToken(mockCredentials.user()), + ).resolves.toEqual({ + token: mockCredentials.limitedUser.token(), + expiresAt: expect.any(Date), + }); + + await expect( + auth.getLimitedUserToken(mockCredentials.user('user:default/other')), + ).resolves.toEqual({ + token: mockCredentials.limitedUser.token('user:default/other'), + expiresAt: expect.any(Date), + }); + + await expect( + auth.getLimitedUserToken(mockCredentials.none() as any), + ).rejects.toThrow( + "Refused to issue limited user token for credential type 'none'", + ); + + await expect( + auth.getLimitedUserToken(mockCredentials.service() as any), + ).rejects.toThrow( + "Refused to issue limited user token for credential type 'service'", + ); + }); }); diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 6a18711798..64dc92747a 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -27,9 +27,11 @@ import { mockCredentials, MOCK_USER_TOKEN, MOCK_USER_TOKEN_PREFIX, + MOCK_INVALID_USER_TOKEN, + MOCK_USER_LIMITED_TOKEN_PREFIX, + MOCK_INVALID_USER_LIMITED_TOKEN, MOCK_SERVICE_TOKEN, MOCK_SERVICE_TOKEN_PREFIX, - MOCK_INVALID_USER_TOKEN, MOCK_INVALID_SERVICE_TOKEN, UserTokenPayload, ServiceTokenPayload, @@ -48,7 +50,10 @@ export class MockAuthService implements AuthService { this.disableDefaultAuthPolicy = options.disableDefaultAuthPolicy; } - async authenticate(token: string): Promise { + async authenticate( + token: string, + options?: { allowLimitedAccess?: boolean }, + ): Promise { switch (token) { case MOCK_USER_TOKEN: return mockCredentials.user(); @@ -56,6 +61,8 @@ export class MockAuthService implements AuthService { return mockCredentials.service(); case MOCK_INVALID_USER_TOKEN: throw new AuthenticationError('User token is invalid'); + case MOCK_INVALID_USER_LIMITED_TOKEN: + throw new AuthenticationError('Limited user token is invalid'); case MOCK_INVALID_SERVICE_TOKEN: throw new AuthenticationError('Service token is invalid'); case '': @@ -72,6 +79,18 @@ export class MockAuthService implements AuthService { return mockCredentials.user(userEntityRef); } + if (token.startsWith(MOCK_USER_LIMITED_TOKEN_PREFIX)) { + if (!options?.allowLimitedAccess) { + throw new AuthenticationError('Limited user token is not allowed'); + } + + const { sub: userEntityRef }: UserTokenPayload = JSON.parse( + token.slice(MOCK_USER_LIMITED_TOKEN_PREFIX.length), + ); + + return mockCredentials.user(userEntityRef); + } + if (token.startsWith(MOCK_SERVICE_TOKEN_PREFIX)) { const { sub, target, obo }: ServiceTokenPayload = JSON.parse( token.slice(MOCK_SERVICE_TOKEN_PREFIX.length), @@ -92,6 +111,10 @@ export class MockAuthService implements AuthService { throw new AuthenticationError(`Unknown mock token '${token}'`); } + async getNoneCredentials() { + return mockCredentials.none(); + } + async getOwnServiceCredentials(): Promise< BackstageCredentials > { @@ -144,4 +167,21 @@ export class MockAuthService implements AuthService { }), }; } + + async getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ token: string; expiresAt: Date }> { + if (credentials.principal.type !== 'user') { + throw new AuthenticationError( + `Refused to issue limited user token for credential type '${credentials.principal.type}'`, + ); + } + + return { + token: mockCredentials.limitedUser.token( + credentials.principal.userEntityRef, + ), + expiresAt: new Date(Date.now() + 3600_000), + }; + } } diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts index 922b3daaf4..b43f448b44 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts @@ -22,8 +22,11 @@ import { AuthenticationError } from '@backstage/errors'; describe('MockHttpAuthService', () => { const httpAuth = new MockHttpAuthService('test', mockCredentials.none()); - function makeAuthReq(header?: string) { - return { headers: { authorization: header } } as Request; + function makeAuthReq(authorization?: string) { + return { headers: { authorization } } as Request; + } + function makeCookieAuthReq(cookie?: string) { + return { headers: { cookie } } as Request; } it('should authenticate unauthenticated requests', async () => { @@ -68,6 +71,59 @@ describe('MockHttpAuthService', () => { ).resolves.toEqual(mockCredentials.user('user:default/other')); }); + it('should authenticate limited user requests', async () => { + await expect( + httpAuth.credentials( + makeCookieAuthReq(mockCredentials.limitedUser.cookie()), + ), + ).resolves.toEqual(mockCredentials.none()); + + await expect( + httpAuth.credentials( + makeCookieAuthReq(mockCredentials.limitedUser.cookie()), + { allowLimitedAccess: true }, + ), + ).resolves.toEqual(mockCredentials.user()); + + await expect( + httpAuth.credentials(makeAuthReq(mockCredentials.user.header()), { + allowLimitedAccess: true, + }), + ).resolves.toEqual(mockCredentials.user()); + + await expect( + httpAuth.credentials( + makeCookieAuthReq(mockCredentials.limitedUser.cookie()), + { + allow: ['user'], + }, + ), + ).rejects.toThrow('Missing credentials'); + + await expect( + httpAuth.credentials( + makeCookieAuthReq(mockCredentials.limitedUser.cookie()), + { + allow: ['none', 'service'], + allowLimitedAccess: true, + }, + ), + ).rejects.toThrow("This endpoint does not allow 'user' credentials"); + + await expect( + httpAuth.credentials( + makeAuthReq(`Bearer ${mockCredentials.limitedUser.token()}`), + { allowLimitedAccess: true }, + ), + ).resolves.toEqual(mockCredentials.user()); + + await expect( + httpAuth.credentials( + makeAuthReq(`Bearer ${mockCredentials.limitedUser.token()}`), + ), + ).rejects.toThrow('Limited user token is not allowed'); + }); + it('should authenticate service requests', async () => { await expect( httpAuth.credentials(makeAuthReq(mockCredentials.service.header())), @@ -161,9 +217,42 @@ describe('MockHttpAuthService', () => { ).rejects.toThrow('Service token is invalid'); }); - it('does not implement .issueUserCookie', async () => { - await expect(httpAuth.issueUserCookie({} as any)).rejects.toThrow( - 'Not implemented', + it('should issue user cookie from request credentials', async () => { + const setHeader = jest.fn(); + + await expect( + httpAuth.issueUserCookie({ + req: makeAuthReq(mockCredentials.user.header()), + setHeader, + } as any), + ).resolves.toEqual({ + expiresAt: expect.any(Date), + }); + + expect(setHeader).toHaveBeenCalledWith( + 'Set-Cookie', + mockCredentials.limitedUser.cookie(), + ); + }); + + it('should issue user cookie from explicit credentials', async () => { + const setHeader = jest.fn(); + + await expect( + httpAuth.issueUserCookie( + { + req: makeAuthReq(mockCredentials.user.header()), + setHeader, + } as any, + { credentials: mockCredentials.user('user:default/other') }, + ), + ).resolves.toEqual({ + expiresAt: expect.any(Date), + }); + + expect(setHeader).toHaveBeenCalledWith( + 'Set-Cookie', + mockCredentials.limitedUser.cookie('user:default/other'), ); }); }); diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts index 9b133f996b..9a69620473 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts @@ -18,16 +18,18 @@ import { AuthService, BackstageCredentials, BackstagePrincipalTypes, + BackstageUserPrincipal, HttpAuthService, } from '@backstage/backend-plugin-api'; import { Request, Response } from 'express'; +import { parse as parseCookie } from 'cookie'; import { MockAuthService } from './MockAuthService'; +import { AuthenticationError, NotAllowedError } from '@backstage/errors'; import { - AuthenticationError, - NotAllowedError, - NotImplementedError, -} from '@backstage/errors'; -import { mockCredentials } from './mockCredentials'; + MOCK_NONE_TOKEN, + MOCK_AUTH_COOKIE, + mockCredentials, +} from './mockCredentials'; // TODO: support mock cookie auth? export class MockHttpAuthService implements HttpAuthService { @@ -42,33 +44,52 @@ export class MockHttpAuthService implements HttpAuthService { this.#defaultCredentials = defaultCredentials; } - async #getCredentials(req: Request) { + async #getCredentials(req: Request, allowLimitedAccess: boolean) { const header = req.headers.authorization; - - if (header === mockCredentials.none.header()) { - return mockCredentials.none(); - } - const token = typeof header === 'string' ? header.match(/^Bearer[ ]+(\S+)$/i)?.[1] : undefined; - if (!token) { - return this.#defaultCredentials; + if (token) { + if (token === MOCK_NONE_TOKEN) { + return this.#auth.getNoneCredentials(); + } + + return await this.#auth.authenticate(token, { + allowLimitedAccess, + }); } - return await this.#auth.authenticate(token); + if (allowLimitedAccess) { + const cookieHeader = req.headers.cookie; + + if (cookieHeader) { + const cookies = parseCookie(cookieHeader); + const cookie = cookies[MOCK_AUTH_COOKIE]; + + if (cookie) { + return await this.#auth.authenticate(cookie, { + allowLimitedAccess: true, + }); + } + } + } + + return this.#defaultCredentials; } async credentials( req: Request, options?: { allow?: Array; - allowedAuthMethods?: Array<'token' | 'cookie'>; + allowLimitedAccess?: boolean; }, ): Promise> { - const credentials = await this.#getCredentials(req); + const credentials = await this.#getCredentials( + req, + options?.allowLimitedAccess ?? false, + ); const allowedPrincipalTypes = options?.allow; if (!allowedPrincipalTypes) { @@ -80,7 +101,7 @@ export class MockHttpAuthService implements HttpAuthService { return credentials as any; } - throw new AuthenticationError(); + throw new AuthenticationError('Missing credentials'); } else if (this.#auth.isPrincipal(credentials, 'user')) { if (allowedPrincipalTypes.includes('user' as TAllowed)) { return credentials as any; @@ -104,7 +125,19 @@ export class MockHttpAuthService implements HttpAuthService { ); } - async issueUserCookie(_res: Response): Promise { - throw new NotImplementedError('Not implemented'); + async issueUserCookie( + res: Response, + options?: { credentials?: BackstageCredentials }, + ): Promise<{ expiresAt: Date }> { + const credentials = + options?.credentials ?? + (await this.credentials(res.req, { allow: ['user'] })); + + res.setHeader( + 'Set-Cookie', + mockCredentials.limitedUser.cookie(credentials.principal.userEntityRef), + ); + + return { expiresAt: new Date(Date.now() + 3600_000) }; } } diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts index 67cda92be8..ed0071d4b8 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts @@ -36,6 +36,18 @@ describe('mockCredentials', () => { }); }); + it('creates a mocked credentials object for a limited user principal', () => { + expect(mockCredentials.limitedUser()).toEqual({ + $$type: '@backstage/BackstageCredentials', + principal: { type: 'user', userEntityRef: 'user:default/mock' }, + }); + + expect(mockCredentials.limitedUser('user:default/other')).toEqual({ + $$type: '@backstage/BackstageCredentials', + principal: { type: 'user', userEntityRef: 'user:default/other' }, + }); + }); + it('creates a mocked credentials object for a service principal', () => { expect(mockCredentials.service()).toEqual({ $$type: '@backstage/BackstageCredentials', @@ -68,6 +80,22 @@ describe('mockCredentials', () => { ); }); + it('creates limited user tokens and headers', () => { + expect(mockCredentials.limitedUser.token('user:default/other')).toBe( + 'mock-limited-user-token:{"sub":"user:default/other"}', + ); + expect(mockCredentials.limitedUser.invalidToken()).toBe( + 'mock-invalid-limited-user-token', + ); + + expect(mockCredentials.limitedUser.cookie('user:default/other')).toBe( + 'backstage-auth=mock-limited-user-token:{"sub":"user:default/other"}', + ); + expect(mockCredentials.limitedUser.invalidCookie()).toBe( + 'backstage-auth=mock-invalid-limited-user-token', + ); + }); + it('creates service tokens and headers', () => { expect(mockCredentials.service.token()).toBe('mock-service-token'); expect( diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.ts b/packages/backend-test-utils/src/next/services/mockCredentials.ts index 8dac9c1a4b..16d2381c73 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts @@ -24,10 +24,18 @@ import { export const DEFAULT_MOCK_USER_ENTITY_REF = 'user:default/mock'; export const DEFAULT_MOCK_SERVICE_SUBJECT = 'external:test-service'; +export const MOCK_AUTH_COOKIE = 'backstage-auth'; + export const MOCK_NONE_TOKEN = 'mock-none-token'; + export const MOCK_USER_TOKEN = 'mock-user-token'; export const MOCK_USER_TOKEN_PREFIX = 'mock-user-token:'; export const MOCK_INVALID_USER_TOKEN = 'mock-invalid-user-token'; + +export const MOCK_USER_LIMITED_TOKEN_PREFIX = 'mock-limited-user-token:'; +export const MOCK_INVALID_USER_LIMITED_TOKEN = + 'mock-invalid-limited-user-token'; + export const MOCK_SERVICE_TOKEN = 'mock-service-token'; export const MOCK_SERVICE_TOKEN_PREFIX = 'mock-service-token:'; export const MOCK_INVALID_SERVICE_TOKEN = 'mock-invalid-service-token'; @@ -143,6 +151,54 @@ export namespace mockCredentials { } } + /** + * Creates a mocked credentials object for a user principal with limited + * access. + * + * The default user entity reference is 'user:default/mock'. + */ + export function limitedUser( + userEntityRef: string = DEFAULT_MOCK_USER_ENTITY_REF, + ): BackstageCredentials { + return user(userEntityRef); + } + + /** + * Utilities related to limited user credentials. + */ + export namespace limitedUser { + /** + * Creates a mocked limited user token. If a payload is provided it will be + * encoded into the token and forwarded to the credentials object when + * authenticated by the mock auth service. + */ + export function token( + userEntityRef: string = DEFAULT_MOCK_USER_ENTITY_REF, + ): string { + validateUserEntityRef(userEntityRef); + return `${MOCK_USER_LIMITED_TOKEN_PREFIX}${JSON.stringify({ + sub: userEntityRef, + } satisfies UserTokenPayload)}`; + } + + /** + * Returns an authorization header with a mocked limited user token. If a + * payload is provided it will be encoded into the token and forwarded to + * the credentials object when authenticated by the mock auth service. + */ + export function cookie(userEntityRef?: string): string { + return `${MOCK_AUTH_COOKIE}=${token(userEntityRef)}`; + } + + export function invalidToken(): string { + return MOCK_INVALID_USER_LIMITED_TOKEN; + } + + export function invalidCookie(): string { + return `${MOCK_AUTH_COOKIE}=${invalidToken()}`; + } + } + /** * Creates a mocked credentials object for a service principal. * diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 7300b34105..3ff8a4e017 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -202,9 +202,11 @@ export namespace mockServices { }); export const mock = simpleMock(coreServices.auth, () => ({ authenticate: jest.fn(), + getNoneCredentials: jest.fn(), getOwnServiceCredentials: jest.fn(), isPrincipal: jest.fn() as any, getPluginRequestToken: jest.fn(), + getLimitedUserToken: jest.fn(), })); } diff --git a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts index 9195551993..cfa89a6bfd 100644 --- a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts +++ b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts @@ -236,7 +236,7 @@ describe('resourcesRoutes', () => { .expect(401, { error: { name: 'AuthenticationError', - message: '', + message: 'Missing credentials', }, request: { method: 'POST', @@ -508,7 +508,7 @@ describe('resourcesRoutes', () => { .expect(401, { error: { name: 'AuthenticationError', - message: '', + message: 'Missing credentials', }, request: { method: 'POST', diff --git a/yarn.lock b/yarn.lock index 578ae66433..5d0e70675f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3483,6 +3483,7 @@ __metadata: "@backstage/types": "workspace:^" "@types/supertest": ^2.0.8 better-sqlite3: ^9.0.0 + cookie: ^0.6.0 express: ^4.17.1 fs-extra: ^11.0.0 knex: ^3.0.0