backend-{plugin,app}-api: refactored auth APIs to use principals

Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Co-authored-by: Carl-Erik Bergström <cbergstrom@spotify.com>
Co-authored-by: blam <ben@blam.sh>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-02-10 09:53:37 +01:00
parent 6a685868ff
commit a19ef483f1
9 changed files with 296 additions and 170 deletions
@@ -19,12 +19,15 @@ import {
mockServices,
} from '@backstage/backend-test-utils';
import {
InternalBackstageServiceCredentials,
InternalBackstageUserCredentials,
InternalBackstageCredentials,
authServiceFactory,
} from './authServiceFactory';
import { decodeJwt } from 'jose';
import { discoveryServiceFactory } from '../discovery';
import {
BackstageServicePrincipal,
BackstageUserPrincipal,
} from '@backstage/backend-plugin-api';
// TODO: Ship discovery mock service in the service factory tester
const mockDeps = [
@@ -48,18 +51,22 @@ describe('authServiceFactory', () => {
const searchAuth = await tester.get('search');
const catalogAuth = await tester.get('catalog');
const { token: searchToken } = await searchAuth.issueServiceToken();
const { token: searchToken } = await searchAuth.issueToken({});
await expect(searchAuth.authenticate(searchToken)).resolves.toEqual(
expect.objectContaining({
type: 'service',
subject: 'external:backstage-plugin',
principal: {
type: 'service',
subject: 'external:backstage-plugin',
},
}),
);
await expect(catalogAuth.authenticate(searchToken)).resolves.toEqual(
expect.objectContaining({
type: 'service',
subject: 'external:backstage-plugin',
principal: {
type: 'service',
subject: 'external:backstage-plugin',
},
}),
);
});
@@ -72,14 +79,17 @@ describe('authServiceFactory', () => {
const catalogAuth = await tester.get('catalog');
await expect(
catalogAuth.issueServiceToken({
catalogAuth.issueToken({
forward: {
$$type: '@backstage/BackstageCredentials',
version: 'v1',
type: 'user',
userEntityRef: 'user:default/alice',
authMethod: 'token',
token: 'alice-token',
} as InternalBackstageUserCredentials,
principal: {
type: 'user',
userEntityRef: 'user:default/alice',
},
} as InternalBackstageCredentials<BackstageUserPrincipal>,
}),
).resolves.toEqual({ token: 'alice-token' });
});
@@ -91,14 +101,17 @@ describe('authServiceFactory', () => {
const catalogAuth = await tester.get('catalog');
const { token } = await catalogAuth.issueServiceToken({
const { token } = await catalogAuth.issueToken({
forward: {
$$type: '@backstage/BackstageCredentials',
version: 'v1',
type: 'service',
subject: 'external:backstage-plugin',
authMethod: 'token',
token: 'some-upstream-service-token',
} as InternalBackstageServiceCredentials,
principal: {
type: 'service',
subject: 'external:upstream-service',
},
} as InternalBackstageCredentials<BackstageServicePrincipal>,
});
expect(decodeJwt(token)).toEqual(
@@ -18,8 +18,10 @@ import { ServerTokenManager, TokenManager } from '@backstage/backend-common';
import {
AuthService,
BackstageCredentials,
BackstageServiceCredentials,
BackstageUserCredentials,
BackstagePrincipalTypes,
BackstageServicePrincipal,
BackstageNonePrincipal,
BackstageUserPrincipal,
IdentityService,
coreServices,
createServiceFactory,
@@ -32,58 +34,76 @@ import {
import { decodeJwt } from 'jose';
/** @internal */
export type InternalBackstageServiceCredentials =
BackstageServiceCredentials & {
export type InternalBackstageCredentials<TPrincipal = unknown> =
BackstageCredentials<TPrincipal> & {
version: string;
token: string;
token?: string;
authMethod: 'token' | 'cookie' | 'none';
};
function createServiceCredentials(
export function createCredentialsWithServicePrincipal(
sub: string,
token: string,
): BackstageServiceCredentials {
): InternalBackstageCredentials<BackstageServicePrincipal> {
return {
$$type: '@backstage/BackstageCredentials',
version: 'v1',
token,
type: 'service',
subject: sub,
} as InternalBackstageServiceCredentials;
principal: {
type: 'service',
subject: sub,
},
authMethod: 'token',
};
}
/** @internal */
export type InternalBackstageUserCredentials = BackstageUserCredentials & {
version: string;
token: string;
};
function createUserCredentials(
export function createCredentialsWithUserPrincipal(
sub: string,
token: string,
): BackstageUserCredentials {
authMethod: 'token' | 'cookie' = 'token',
): InternalBackstageCredentials<BackstageUserPrincipal> {
return {
$$type: '@backstage/BackstageCredentials',
version: 'v1',
token,
type: 'user',
userEntityRef: sub,
} as InternalBackstageUserCredentials;
principal: {
type: 'user',
userEntityRef: sub,
},
authMethod,
};
}
export function createCredentialsWithNonePrincipal(): InternalBackstageCredentials<BackstageNonePrincipal> {
return {
$$type: '@backstage/BackstageCredentials',
version: 'v1',
principal: {
type: 'none',
},
authMethod: 'none',
};
}
export function toInternalBackstageCredentials(
credentials: BackstageCredentials,
): InternalBackstageServiceCredentials | InternalBackstageUserCredentials {
): InternalBackstageCredentials<
BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal
> {
if (credentials.$$type !== '@backstage/BackstageCredentials') {
throw new Error('Invalid credential type');
}
const internalCredentials = credentials as
| InternalBackstageServiceCredentials
| InternalBackstageUserCredentials;
const internalCredentials = credentials as InternalBackstageCredentials<
BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal
>;
if (internalCredentials.version !== 'v1') {
throw new Error(
`Invalid credential version ${internalCredentials.version}`,
);
}
return internalCredentials;
}
@@ -100,7 +120,10 @@ class DefaultAuthService implements AuthService {
// Legacy service-to-service token
if (sub === 'backstage-server' && !aud) {
await this.tokenManager.authenticate(token);
return createServiceCredentials('external:backstage-plugin', token);
return createCredentialsWithServicePrincipal(
'external:backstage-plugin',
token,
);
}
// User Backstage token
@@ -111,21 +134,42 @@ class DefaultAuthService implements AuthService {
} as IdentityApiGetIdentityRequest);
if (!identity) {
throw new AuthenticationError('No identity found');
throw new AuthenticationError('Invalid user token');
}
return createUserCredentials(identity.identity.userEntityRef, token);
return createCredentialsWithUserPrincipal(
identity.identity.userEntityRef,
token,
);
}
async issueServiceToken(options?: {
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
credentials: BackstageCredentials,
type: TType,
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]> {
const principal = credentials.principal as
| BackstageUserPrincipal
| BackstageServicePrincipal;
if (principal.type !== type) {
return false;
}
return true;
}
async issueToken(options?: {
forward?: BackstageCredentials;
}): Promise<{ token: string }> {
const internalForward =
options?.forward && toInternalBackstageCredentials(options.forward);
if (internalForward) {
const { type } = internalForward;
const { type } = internalForward.principal;
if (type === 'user') {
if (!internalForward.token) {
throw new Error('User credentials is unexpectedly missing token');
}
return { token: internalForward.token };
} else if (type !== 'service') {
throw new AuthenticationError(
@@ -16,19 +16,21 @@
import {
AuthService,
BackstageCredentialTypes,
BackstageCredentials,
BackstageUnauthorizedCredentials,
BackstageHttpAccessToPrincipalTypesMapping,
DiscoveryService,
HttpAuthService,
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { NotAllowedError } from '@backstage/errors';
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
import { parse as parseCookie } from 'cookie';
import { Request, Response } from 'express';
import { decodeJwt } from 'jose';
import { toInternalBackstageCredentials } from '../auth/authServiceFactory';
import {
createCredentialsWithNonePrincipal,
toInternalBackstageCredentials,
} from '../auth/authServiceFactory';
const BACKSTAGE_AUTH_COOKIE = 'backstage-auth';
@@ -58,20 +60,9 @@ function getTokenFromRequest(req: Request) {
const credentialsSymbol = Symbol('backstage-credentials');
type RequestWithCredentials = Request & {
[credentialsSymbol]?: Promise<{
credentials: BackstageCredentials | BackstageUnauthorizedCredentials;
// TODO: This is temporary and should be removed once we have proper cookie handling in place
isCookie: boolean;
}>;
[credentialsSymbol]?: Promise<BackstageCredentials>;
};
function createUnauthorizedCredentials(): BackstageUnauthorizedCredentials {
return {
$$type: '@backstage/BackstageCredentials',
type: 'unauthorized',
};
}
class DefaultHttpAuthService implements HttpAuthService {
constructor(
private readonly auth: AuthService,
@@ -79,63 +70,94 @@ class DefaultHttpAuthService implements HttpAuthService {
private readonly pluginId: string,
) {}
async #getCredentials(req: Request) {
async #extractCredentialsFromRequest(req: Request) {
const { token, isCookie } = getTokenFromRequest(req);
// TODO: Is this where we match against configured rules?
if (!token) {
return { credentials: createUnauthorizedCredentials(), isCookie };
return createCredentialsWithNonePrincipal();
}
return { credentials: await this.auth.authenticate(token), isCookie };
}
async #getCredentialsCached(req: /* */ RequestWithCredentials) {
return (req[credentialsSymbol] ??= this.#getCredentials(req));
}
async credentials<TAllowed extends keyof BackstageCredentialTypes>(
req: RequestWithCredentials,
options: {
allow: TAllowed[];
},
): Promise<BackstageCredentialTypes[TAllowed]> {
const { credentials, isCookie } = await this.#getCredentialsCached(req);
if (credentials.type === 'user' && isCookie) {
if (options.allow.includes('user-cookie' as TAllowed)) {
return credentials as BackstageCredentialTypes[TAllowed];
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',
);
}
throw new NotAllowedError(
`This endpoint does not allow 'user-cookie' credentials`,
);
credentials.authMethod = 'cookie';
}
if (!options.allow.includes(credentials.type as TAllowed)) {
throw new NotAllowedError(
`This endpoint does not allow '${credentials.type}' credentials`,
);
}
return credentials as BackstageCredentialTypes[TAllowed];
return credentials;
}
async requestHeaders(options?: {
async #getCredentials(req: /* */ RequestWithCredentials) {
return (req[credentialsSymbol] ??=
this.#extractCredentialsFromRequest(req));
}
async credentials<
TAllowed extends
| keyof BackstageHttpAccessToPrincipalTypesMapping
| undefined = undefined,
>(
req: Request,
options?: {
allow: Array<TAllowed>;
},
): Promise<
BackstageCredentials<
TAllowed extends keyof BackstageHttpAccessToPrincipalTypesMapping
? BackstageHttpAccessToPrincipalTypesMapping[TAllowed]
: unknown
>
> {
const credentials = toInternalBackstageCredentials(
await this.#getCredentials(req),
);
// TODO break out into more readable function as we always
// want to check cookie regardless if options are set or not
// Probably asserts function that ensures authMethod = 'token' after cookie check
if (credentials.authMethod === 'cookie') {
if (options && !options.allow.includes('user-cookie' as TAllowed)) {
throw new NotAllowedError(
`This endpoint does not allow 'user-cookie' credentials`,
);
}
} else if (
options &&
!options.allow.includes(credentials.principal.type as TAllowed)
) {
throw new NotAllowedError(
`This endpoint does not allow '${credentials.principal.type}' credentials`,
);
}
return credentials as any;
}
async requestHeaders(options: {
forward?: BackstageCredentials;
}): Promise<Record<string, string>> {
return {
Authorization: `Bearer ${await this.auth.issueServiceToken(options)}`,
Authorization: `Bearer ${await this.auth.issueToken(options)}`,
};
}
async issueUserCookie(res: Response): Promise<void> {
const credentials = await this.credentials(res.req, { allow: ['user'] });
// https://backstage.spotify.net/api/catalog
// https://backstage.example.com/api/catalog
const externalBaseUrlStr = await this.discovery.getExternalBaseUrl(
this.pluginId,
);
const externalBaseUrl = new URL(externalBaseUrlStr);
const { token } = toInternalBackstageCredentials(credentials);
if (!token) {
throw new Error('User credentials is unexpectedly missing token');
}
// TODO: Proper refresh and expiration handling
const expires = decodeJwt(token).exp!;
@@ -149,6 +171,7 @@ class DefaultHttpAuthService implements HttpAuthService {
priority: 'high',
sameSite: 'lax', // TBD
});
throw new Error('Method not implemented.');
}
}
@@ -15,11 +15,11 @@
*/
import {
BackstageUserCredentials,
UserInfoService,
BackstageUserInfo,
coreServices,
createServiceFactory,
BackstageCredentials,
} from '@backstage/backend-plugin-api';
import { toInternalBackstageCredentials } from '../auth/authServiceFactory';
import { decodeJwt } from 'jose';
@@ -27,12 +27,15 @@ import { decodeJwt } from 'jose';
// TODO: The intention is for this to eventually be replaced by a call to the auth-backend
export class DefaultUserInfoService implements UserInfoService {
async getUserInfo(
credentials: BackstageUserCredentials,
credentials: BackstageCredentials,
): Promise<BackstageUserInfo> {
const internalCredentials = toInternalBackstageCredentials(credentials);
if (internalCredentials.type !== 'user') {
if (internalCredentials.principal.type !== 'user') {
throw new Error('Only user credentials are supported');
}
if (!internalCredentials.token) {
throw new Error('User credentials is unexpectedly missing token');
}
const { sub: userEntityRef, ent: ownershipEntityRefs = [] } = decodeJwt(
internalCredentials.token,
);
+50 -34
View File
@@ -22,7 +22,12 @@ export interface AuthService {
// (undocumented)
authenticate(token: string): Promise<BackstageCredentials>;
// (undocumented)
issueServiceToken(options?: { forward?: BackstageCredentials }): Promise<{
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
credentials: BackstageCredentials,
type: TType,
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]>;
// (undocumented)
issueToken(options: { forward?: BackstageCredentials }): Promise<{
token: string;
}>;
}
@@ -89,36 +94,35 @@ export interface BackendPluginRegistrationPoints {
}
// @public (undocumented)
export type BackstageCredentials =
| BackstageUserCredentials
| BackstageServiceCredentials;
// @public (undocumented)
export type BackstageCredentialTypes = {
user: BackstageUserCredentials;
'user-cookie': BackstageUserCredentials;
service: BackstageServiceCredentials;
unauthorized: BackstageUnauthorizedCredentials;
export type BackstageCredentials<TPrincipal = unknown> = {
$$type: '@backstage/BackstageCredentials';
principal: TPrincipal;
};
// @public (undocumented)
export type BackstageServiceCredentials = {
$$type: '@backstage/BackstageCredentials';
export type BackstageHttpAccessToPrincipalTypesMapping = {
user: BackstageUserPrincipal;
'user-cookie': BackstageUserPrincipal;
service: BackstageServicePrincipal;
unauthenticated: BackstageNonePrincipal;
};
// @public (undocumented)
export type BackstageNonePrincipal = {
type: 'none';
};
// @public (undocumented)
export type BackstagePrincipalTypes = {
user: BackstageUserPrincipal;
service: BackstageServicePrincipal;
};
// @public (undocumented)
export type BackstageServicePrincipal = {
type: 'service';
subject: string;
};
// @public (undocumented)
export type BackstageUnauthorizedCredentials = {
$$type: '@backstage/BackstageCredentials';
type: 'unauthorized';
};
// @public (undocumented)
export type BackstageUserCredentials = {
$$type: '@backstage/BackstageCredentials';
type: 'user';
userEntityRef: string;
permissions?: string[];
};
// @public (undocumented)
@@ -129,6 +133,12 @@ export interface BackstageUserInfo {
userEntityRef: string;
}
// @public (undocumented)
export type BackstageUserPrincipal = {
type: 'user';
userEntityRef: string;
};
// @public
export interface CacheService {
delete(key: string): Promise<void>;
@@ -281,14 +291,22 @@ export interface ExtensionPointConfig {
// @public (undocumented)
export interface HttpAuthService {
// (undocumented)
createHttpPluginRouterMiddleware(): Handler;
// (undocumented)
credentials<TAllowed extends keyof BackstageCredentialTypes>(
credentials<
TAllowed extends
| keyof BackstageHttpAccessToPrincipalTypesMapping
| undefined = undefined,
>(
req: Request_2,
options: {
options?: {
allow: Array<TAllowed>;
},
): Promise<BackstageCredentialTypes[TAllowed]>;
): Promise<
BackstageCredentials<
TAllowed extends keyof BackstageHttpAccessToPrincipalTypesMapping
? BackstageHttpAccessToPrincipalTypesMapping[TAllowed]
: unknown
>
>;
// (undocumented)
issueUserCookie(res: Response_2): Promise<void>;
// (undocumented)
@@ -547,8 +565,6 @@ export interface UrlReaderService {
// @public (undocumented)
export interface UserInfoService {
// (undocumented)
getUserInfo(
credentials: BackstageUserCredentials,
): Promise<BackstageUserInfo>;
getUserInfo(credentials: BackstageCredentials): Promise<BackstageUserInfo>;
}
```
@@ -17,9 +17,7 @@
/**
* @public
*/
export type BackstageUserCredentials = {
$$type: '@backstage/BackstageCredentials';
export type BackstageUserPrincipal = {
type: 'user';
userEntityRef: string;
@@ -28,27 +26,54 @@ export type BackstageUserCredentials = {
/**
* @public
*/
export type BackstageServiceCredentials = {
$$type: '@backstage/BackstageCredentials';
type: 'service';
subject: string;
export type BackstageNonePrincipal = {
type: 'none';
};
/**
* @public
*/
export type BackstageCredentials =
| BackstageUserCredentials
| BackstageServiceCredentials;
export type BackstageServicePrincipal = {
type: 'service';
// Exact format TBD, possibly 'plugin:<pluginId>' or 'external:<externalServiceId>'
subject: string;
// Not implemented in the first iteration, but this is how we might extend this in the future
permissions?: string[];
};
/**
* @public
*/
export type BackstageCredentials<TPrincipal = unknown> = {
$$type: '@backstage/BackstageCredentials';
principal: TPrincipal;
};
/**
* @public
*/
export type BackstagePrincipalTypes = {
user: BackstageUserPrincipal;
service: BackstageServicePrincipal;
};
/**
* @public
*/
export interface AuthService {
authenticate(token: string): Promise<BackstageCredentials>;
issueServiceToken(options?: {
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
credentials: BackstageCredentials,
type: TType,
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]>;
// TODO: should the caller provide the target plugin ID?
// TODO: how can we make it very difficult to forget to forward credentials
issueToken(options: {
forward?: BackstageCredentials;
}): Promise<{ token: string }>;
}
@@ -16,36 +16,39 @@
import { Request, Response } from 'express';
import {
BackstageUserCredentials,
BackstageServiceCredentials,
BackstageCredentials,
BackstageServicePrincipal,
BackstageNonePrincipal,
BackstageUserPrincipal,
} from './AuthService';
/** @public */
export type BackstageUnauthorizedCredentials = {
$$type: '@backstage/BackstageCredentials';
type: 'unauthorized';
};
/** @public */
export type BackstageCredentialTypes = {
user: BackstageUserCredentials;
'user-cookie': BackstageUserCredentials;
service: BackstageServiceCredentials;
unauthorized: BackstageUnauthorizedCredentials;
export type BackstageHttpAccessToPrincipalTypesMapping = {
user: BackstageUserPrincipal;
'user-cookie': BackstageUserPrincipal;
service: BackstageServicePrincipal;
unauthenticated: BackstageNonePrincipal;
};
/** @public */
export interface HttpAuthService {
credentials<TAllowed extends keyof BackstageCredentialTypes>(
credentials<
TAllowed extends
| keyof BackstageHttpAccessToPrincipalTypesMapping
| undefined = undefined,
>(
req: Request,
options: {
options?: {
allow: Array<TAllowed>;
},
): Promise<BackstageCredentialTypes[TAllowed]>;
): Promise<
BackstageCredentials<
TAllowed extends keyof BackstageHttpAccessToPrincipalTypesMapping
? BackstageHttpAccessToPrincipalTypesMapping[TAllowed]
: unknown
>
>;
// TODO: Keep an eye on this, might not be needed
requestHeaders(options?: {
forward?: BackstageCredentials;
}): Promise<Record<string, string>>;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { BackstageUserCredentials } from './AuthService';
import { BackstageCredentials } from './AuthService';
/** @public */
export interface BackstageUserInfo {
@@ -24,7 +24,5 @@ export interface BackstageUserInfo {
/** @public */
export interface UserInfoService {
getUserInfo(
credentials: BackstageUserCredentials,
): Promise<BackstageUserInfo>;
getUserInfo(credentials: BackstageCredentials): Promise<BackstageUserInfo>;
}
@@ -18,8 +18,10 @@ export { coreServices } from './coreServices';
export type {
AuthService,
BackstageCredentials,
BackstageServiceCredentials,
BackstageUserCredentials,
BackstageUserPrincipal,
BackstageServicePrincipal,
BackstagePrincipalTypes,
BackstageNonePrincipal,
} from './AuthService';
export type {
CacheService,
@@ -35,8 +37,7 @@ export type {
} from './HttpRouterService';
export type {
HttpAuthService,
BackstageCredentialTypes,
BackstageUnauthorizedCredentials,
BackstageHttpAccessToPrincipalTypesMapping,
} from './HttpAuthService';
export type {
LifecycleService,