backend-plugin-api: updated cookie auth implementation

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-02-26 01:28:27 +01:00
parent e210800545
commit d455112cbf
16 changed files with 474 additions and 158 deletions
@@ -35,7 +35,6 @@ export type InternalBackstageCredentials<TPrincipal = unknown> =
BackstageCredentials<TPrincipal> & {
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<BackstageUserPrincipal> {
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',
};
}
@@ -135,6 +132,7 @@ class DefaultAuthService implements AuthService {
return createCredentialsWithUserPrincipal(
identity.identity.userEntityRef,
token,
this.#getJwtExpiration(token),
);
}
@@ -214,12 +212,15 @@ class DefaultAuthService implements AuthService {
);
}
return { token, expiresAt: this.#getJwtExpiration(token) };
}
#getJwtExpiration(token: string) {
const { exp } = decodeJwt(token);
if (!exp) {
throw new AuthenticationError('User token is missing expiration');
}
return { token, expiresAt: new Date(exp * 1000) };
return new Date(exp * 1000);
}
}
@@ -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,74 @@ 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;
}
const credentialsSymbol = Symbol('backstage-credentials');
const limitedCredentialsSymbol = Symbol('backstage-limited-credentials');
type RequestWithCredentials = Request & {
[credentialsSymbol]?: Promise<BackstageCredentials>;
[limitedCredentialsSymbol]?: Promise<BackstageCredentials>;
};
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.getNoneCredentials();
}
return await this.#auth.authenticate(cookie, {
allowLimitedAccess: true,
});
}
async #getCredentials(req: RequestWithCredentials) {
@@ -96,73 +114,130 @@ class DefaultHttpAuthService implements HttpAuthService {
this.#extractCredentialsFromRequest(req));
}
async #getLimitedCredentials(req: RequestWithCredentials) {
return (req[limitedCredentialsSymbol] ??=
this.#extractLimitedCredentialsFromRequest(req));
}
async credentials<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
req: Request,
options?: {
allow?: Array<TAllowed>;
allowedAuthMethods?: Array<'token' | 'cookie'>;
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
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<void> {
const credentials = await this.credentials(res.req, { allow: ['user'] });
async issueUserCookie(
res: Response,
options?: { credentials?: BackstageCredentials },
): Promise<{ expiresAt: Date }> {
let credentials: BackstageCredentials<BackstageUserPrincipal>;
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 &&
existingExpiresAt.getTime() < Date.now() - FIVE_MINUTES_MS
) {
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<Date | undefined> {
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;
}
}
@@ -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);
});
});
@@ -76,7 +76,7 @@ export function createCredentialsBarrier(options: {
httpAuth
.credentials(req, {
allow: ['user', 'service'],
allowedAuthMethods: allowsCookie ? ['token', 'cookie'] : ['token'],
allowLimitedAccess: allowsCookie,
})
.then(
() => next(),
@@ -96,6 +96,7 @@ class AuthCompat implements AuthService {
return createCredentialsWithUserPrincipal(
identity.identity.userEntityRef,
token,
this.#getJwtExpiration(token),
);
}
@@ -144,12 +145,15 @@ class AuthCompat implements AuthService {
);
}
return { token, expiresAt: this.#getJwtExpiration(token) };
}
#getJwtExpiration(token: string) {
const { exp } = decodeJwt(token);
if (!exp) {
throw new AuthenticationError('User token is missing expiration');
}
return { token, expiresAt: new Date(exp * 1000) };
return new Date(exp * 1000);
}
}
@@ -174,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);
@@ -183,7 +191,7 @@ class HttpAuthCompat implements HttpAuthService {
}
const credentials = toInternalBackstageCredentials(
await this.auth.authenticate(token),
await this.#auth.authenticate(token),
);
return credentials;
@@ -198,39 +206,50 @@ class HttpAuthCompat implements HttpAuthService {
req: Request,
options?: {
allow?: Array<TAllowed>;
allowedAuthMethods?: Array<'token' | 'cookie'>;
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
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<void> {}
async issueUserCookie(_res: Response): Promise<{ expiresAt: Date }> {
return { expiresAt: new Date(Date.now() + 3600_000) };
}
}
export class UserInfoCompat implements UserInfoService {
+12 -2
View File
@@ -38,6 +38,8 @@ export interface AuthService {
expiresAt: Date;
}>;
// (undocumented)
getNoneCredentials(): Promise<BackstageCredentials<BackstageNonePrincipal>>;
// (undocumented)
getOwnServiceCredentials(): Promise<
BackstageCredentials<BackstageServicePrincipal>
>;
@@ -119,6 +121,7 @@ export interface BackendPluginRegistrationPoints {
// @public (undocumented)
export type BackstageCredentials<TPrincipal = unknown> = {
$$type: '@backstage/BackstageCredentials';
expiresAt?: Date;
principal: TPrincipal;
};
@@ -311,11 +314,18 @@ export interface HttpAuthService {
req: Request_2<any, any, any, any, any>,
options?: {
allow?: Array<TAllowed>;
allowedAuthMethods?: Array<'token' | 'cookie'>;
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>>;
// (undocumented)
issueUserCookie(res: Response_2): Promise<void>;
issueUserCookie(
res: Response_2,
options?: {
credentials?: BackstageCredentials<BackstageUserPrincipal>;
},
): Promise<{
expiresAt: Date;
}>;
}
// @public (undocumented)
@@ -46,6 +46,8 @@ export type BackstageServicePrincipal = {
export type BackstageCredentials<TPrincipal = unknown> = {
$$type: '@backstage/BackstageCredentials';
expiresAt?: Date;
principal: TPrincipal;
};
@@ -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<any, any, any, any, any>,
options?: {
allow?: Array<TAllowed>;
allowedAuthMethods?: Array<'token' | 'cookie'>;
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>>;
issueUserCookie(res: Response): Promise<void>;
issueUserCookie(
res: Response,
options?: {
credentials?: BackstageCredentials<BackstageUserPrincipal>;
},
): Promise<{ expiresAt: Date }>;
}
+2 -2
View File
@@ -54,9 +54,9 @@ export namespace mockCredentials {
userEntityRef?: string,
): BackstageCredentials<BackstageUserPrincipal>;
export namespace limitedUser {
export function header(userEntityRef?: string): string;
export function cookie(userEntityRef?: string): string;
// (undocumented)
export function invalidHeader(): string;
export function invalidCookie(): string;
// (undocumented)
export function invalidToken(): string;
export function token(userEntityRef?: string): string;
+26 -25
View File
@@ -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": "*"
}
}
@@ -261,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'",
);
});
});
@@ -28,7 +28,6 @@ import {
MOCK_USER_TOKEN,
MOCK_USER_TOKEN_PREFIX,
MOCK_INVALID_USER_TOKEN,
MOCK_USER_LIMITED_TOKEN,
MOCK_USER_LIMITED_TOKEN_PREFIX,
MOCK_INVALID_USER_LIMITED_TOKEN,
MOCK_SERVICE_TOKEN,
@@ -58,11 +57,6 @@ export class MockAuthService implements AuthService {
switch (token) {
case MOCK_USER_TOKEN:
return mockCredentials.user();
case MOCK_USER_LIMITED_TOKEN:
if (!options?.allowLimitedAccess) {
throw new AuthenticationError('Limited user token is not allowed');
}
return mockCredentials.user();
case MOCK_SERVICE_TOKEN:
return mockCredentials.service();
case MOCK_INVALID_USER_TOKEN:
@@ -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'),
);
});
});
@@ -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<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
req: Request,
options?: {
allow?: Array<TAllowed>;
allowedAuthMethods?: Array<'token' | 'cookie'>;
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
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<void> {
throw new NotImplementedError('Not implemented');
async issueUserCookie(
res: Response,
options?: { credentials?: BackstageCredentials<BackstageUserPrincipal> },
): 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) };
}
}
@@ -202,6 +202,7 @@ 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(),
+1
View File
@@ -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