backend-plugin-api: add support for limited user tokens

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-02-23 17:19:50 +01:00
parent 45b7ada098
commit 9823e313c3
10 changed files with 254 additions and 4 deletions
@@ -111,6 +111,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<BackstageCredentials> {
const { sub, aud } = decodeJwt(token);
@@ -193,6 +194,27 @@ class DefaultAuthService implements AuthService {
);
}
}
async getLimitedUserToken(
credentials: BackstageCredentials<BackstageUserPrincipal>,
): Promise<{ token: string; expiresAt: Date }> {
const internalCredentials = toInternalBackstageCredentials(credentials);
const { token } = internalCredentials;
if (!token) {
throw new AuthenticationError(
'User credentials is unexpectedly missing token',
);
}
const { exp } = decodeJwt(token);
if (!exp) {
throw new AuthenticationError('User token is missing expiration');
}
return { token, expiresAt: new Date(exp * 1000) };
}
}
/** @public */
@@ -123,6 +123,27 @@ class AuthCompat implements AuthService {
);
}
}
async getLimitedUserToken(
credentials: BackstageCredentials<BackstageUserPrincipal>,
): Promise<{ token: string; expiresAt: Date }> {
const internalCredentials = toInternalBackstageCredentials(credentials);
const { token } = internalCredentials;
if (!token) {
throw new AuthenticationError(
'User credentials is unexpectedly missing token',
);
}
const { exp } = decodeJwt(token);
if (!exp) {
throw new AuthenticationError('User token is missing expiration');
}
return { token, expiresAt: new Date(exp * 1000) };
}
}
function getTokenFromRequest(req: Request) {
+13 -1
View File
@@ -24,7 +24,19 @@ import { Response as Response_2 } from 'express';
// @public (undocumented)
export interface AuthService {
// (undocumented)
authenticate(token: string): Promise<BackstageCredentials>;
authenticate(
token: string,
options?: {
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials>;
// (undocumented)
getLimitedUserToken(
credentials: BackstageCredentials<BackstageUserPrincipal>,
): Promise<{
token: string;
expiresAt: Date;
}>;
// (undocumented)
getOwnServiceCredentials(): Promise<
BackstageCredentials<BackstageServicePrincipal>
@@ -63,7 +63,12 @@ export type BackstagePrincipalTypes = {
* @public
*/
export interface AuthService {
authenticate(token: string): Promise<BackstageCredentials>;
authenticate(
token: string,
options?: {
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials>;
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
credentials: BackstageCredentials,
@@ -78,4 +83,8 @@ export interface AuthService {
onBehalfOf: BackstageCredentials;
targetPluginId: string;
}): Promise<{ token: string }>;
getLimitedUserToken(
credentials: BackstageCredentials<BackstageUserPrincipal>,
): Promise<{ token: string; expiresAt: Date }>;
}
+11
View File
@@ -50,6 +50,17 @@ export function isDockerDisabledForTests(): boolean;
// @public (undocumented)
export namespace mockCredentials {
export function limitedUser(
userEntityRef?: string,
): BackstageCredentials<BackstageUserPrincipal>;
export namespace limitedUser {
export function header(userEntityRef?: string): string;
// (undocumented)
export function invalidHeader(): string;
// (undocumented)
export function invalidToken(): string;
export function token(userEntityRef?: string): string;
}
export function none(): BackstageCredentials<BackstageNonePrincipal>;
export namespace none {
export function header(): string;
@@ -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()),
@@ -27,9 +27,12 @@ import {
mockCredentials,
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,
MOCK_SERVICE_TOKEN_PREFIX,
MOCK_INVALID_USER_TOKEN,
MOCK_INVALID_SERVICE_TOKEN,
UserTokenPayload,
ServiceTokenPayload,
@@ -48,14 +51,24 @@ export class MockAuthService implements AuthService {
this.disableDefaultAuthPolicy = options.disableDefaultAuthPolicy;
}
async authenticate(token: string): Promise<BackstageCredentials> {
async authenticate(
token: string,
options?: { allowLimitedAccess?: boolean },
): Promise<BackstageCredentials> {
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:
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 +85,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),
@@ -144,4 +169,21 @@ export class MockAuthService implements AuthService {
}),
};
}
async getLimitedUserToken(
credentials: BackstageCredentials<BackstageUserPrincipal>,
): 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),
};
}
}
@@ -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,26 @@ describe('mockCredentials', () => {
);
});
it('creates limited user tokens and headers', () => {
expect(mockCredentials.limitedUser.token()).toBe('mock-limited-user-token');
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.header()).toBe(
'Bearer mock-limited-user-token',
);
expect(mockCredentials.limitedUser.header('user:default/other')).toBe(
'Bearer mock-limited-user-token:{"sub":"user:default/other"}',
);
expect(mockCredentials.limitedUser.invalidHeader()).toBe(
'Bearer mock-invalid-limited-user-token',
);
});
it('creates service tokens and headers', () => {
expect(mockCredentials.service.token()).toBe('mock-service-token');
expect(
@@ -25,9 +25,16 @@ export const DEFAULT_MOCK_USER_ENTITY_REF = 'user:default/mock';
export const DEFAULT_MOCK_SERVICE_SUBJECT = 'external:test-service';
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 = 'mock-limited-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 +150,55 @@ 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<BackstageUserPrincipal> {
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): string {
if (userEntityRef) {
validateUserEntityRef(userEntityRef);
return `${MOCK_USER_LIMITED_TOKEN_PREFIX}${JSON.stringify({
sub: userEntityRef,
} satisfies UserTokenPayload)}`;
}
return MOCK_USER_LIMITED_TOKEN;
}
/**
* 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 header(userEntityRef?: string): string {
return `Bearer ${token(userEntityRef)}`;
}
export function invalidToken(): string {
return MOCK_INVALID_USER_LIMITED_TOKEN;
}
export function invalidHeader(): string {
return `Bearer ${invalidToken()}`;
}
}
/**
* Creates a mocked credentials object for a service principal.
*
@@ -205,6 +205,7 @@ export namespace mockServices {
getOwnServiceCredentials: jest.fn(),
isPrincipal: jest.fn() as any,
getPluginRequestToken: jest.fn(),
getLimitedUserToken: jest.fn(),
}));
}