backend-plugin-api: updated cookie auth implementation
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user