Merge pull request #25236 from backstage/freben/move-auth

[NBS 1.0] move the auth services from `backend-app-api` to `backend-defaults`
This commit is contained in:
Fredrik Adelöw
2024-06-17 16:14:14 +02:00
committed by GitHub
76 changed files with 1330 additions and 473 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Import utility functions from `backend-defaults` instead of `backend-app-api`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Deprecated `authServiceFactory`, `httpAuthServiceFactory`, and `userInfoServiceFactory`. Please import them from `@backstage/backend-defaults/auth`, `@backstage/backend-defaults/httpAuth`, and `@backstage/backend-defaults/userInfo` respectively instead.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Improved `coreServices` doc comments
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
Added `@backstage/backend-defaults/auth`, `@backstage/backend-defaults/httpAuth`, and `@backstage/backend-defaults/userInfo` to house their respective backend service factories. You should now import these services from those new locations, instead of `@backstage/backend-app-api`.
+7 -7
View File
@@ -45,7 +45,7 @@ import { transport } from 'winston';
import { UrlReaderService } from '@backstage/backend-plugin-api';
import { UserInfoService } from '@backstage/backend-plugin-api';
// @public (undocumented)
// @public @deprecated (undocumented)
export const authServiceFactory: () => ServiceFactory<AuthService, 'plugin'>;
// @public (undocumented)
@@ -151,7 +151,7 @@ export class HostDiscovery implements DiscoveryService {
getExternalBaseUrl(pluginId: string): Promise<string>;
}
// @public (undocumented)
// @public @deprecated (undocumented)
export const httpAuthServiceFactory: () => ServiceFactory<
HttpAuthService,
'plugin'
@@ -162,7 +162,7 @@ export interface HttpRouterFactoryOptions {
getPath?(pluginId: string): string;
}
// @public (undocumented)
// @public
export const httpRouterServiceFactory: (
options?: HttpRouterFactoryOptions | undefined,
) => ServiceFactory<HttpRouterService, 'plugin'>;
@@ -224,7 +224,7 @@ export function loadBackendConfig(options: {
config: Config;
}>;
// @public (undocumented)
// @public
export const loggerServiceFactory: () => ServiceFactory<
LoggerService,
'plugin'
@@ -303,7 +303,7 @@ export interface RootHttpRouterConfigureContext {
server: Server;
}
// @public (undocumented)
// @public
export type RootHttpRouterFactoryOptions = {
indexPath?: string | false;
configure?(context: RootHttpRouterConfigureContext): void;
@@ -320,7 +320,7 @@ export const rootLifecycleServiceFactory: () => ServiceFactory<
'root'
>;
// @public (undocumented)
// @public
export const rootLoggerServiceFactory: () => ServiceFactory<
RootLoggerService,
'root'
@@ -344,7 +344,7 @@ export const urlReaderServiceFactory: () => ServiceFactory<
'plugin'
>;
// @public (undocumented)
// @public @deprecated (undocumented)
export const userInfoServiceFactory: () => ServiceFactory<
UserInfoService,
'plugin'
@@ -14,72 +14,11 @@
* limitations under the License.
*/
import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { DefaultAuthService } from './DefaultAuthService';
import { ExternalTokenHandler } from './external/ExternalTokenHandler';
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
import { createPluginKeySource } from './plugin/keys/createPluginKeySource';
import { UserTokenHandler } from './user/UserTokenHandler';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { authServiceFactory as _authServiceFactory } from '../../../../../backend-defaults/src/entrypoints/auth';
/** @public */
export const authServiceFactory = createServiceFactory({
service: coreServices.auth,
deps: {
config: coreServices.rootConfig,
logger: coreServices.rootLogger,
discovery: coreServices.discovery,
plugin: coreServices.pluginMetadata,
database: coreServices.database,
// Re-using the token manager makes sure that we use the same generated keys for
// development as plugins that have not yet been migrated. It's important that this
// keeps working as long as there are plugins that have not been migrated to the
// new auth services in the new backend system.
tokenManager: coreServices.tokenManager,
},
async factory({ config, discovery, plugin, tokenManager, logger, database }) {
const disableDefaultAuthPolicy =
config.getOptionalBoolean(
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
) ?? false;
const keyDuration = { hours: 1 };
const keySource = await createPluginKeySource({
config,
database,
logger,
keyDuration,
});
const userTokens = UserTokenHandler.create({
discovery,
});
const pluginTokens = PluginTokenHandler.create({
ownPluginId: plugin.getId(),
logger,
keySource,
keyDuration,
discovery,
});
const externalTokens = ExternalTokenHandler.create({
ownPluginId: plugin.getId(),
config,
logger,
});
return new DefaultAuthService(
userTokens,
pluginTokens,
externalTokens,
tokenManager,
plugin.getId(),
disableDefaultAuthPolicy,
keySource,
);
},
});
/**
* @public
* @deprecated Please import from `@backstage/backend-defaults/auth` instead.
*/
export const authServiceFactory = _authServiceFactory;
@@ -14,4 +14,7 @@
* limitations under the License.
*/
export * from './auth';
export * from './httpAuth';
export * from './scheduler';
export * from './userInfo';
@@ -14,273 +14,11 @@
* limitations under the License.
*/
import {
AuthService,
BackstageCredentials,
BackstagePrincipalTypes,
BackstageUserPrincipal,
DiscoveryService,
HttpAuthService,
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
import { parse as parseCookie } from 'cookie';
import { Request, Response } from 'express';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { httpAuthServiceFactory as _httpAuthServiceFactory } from '../../../../../backend-defaults/src/entrypoints/httpAuth';
const FIVE_MINUTES_MS = 5 * 60 * 1000;
const BACKSTAGE_AUTH_COOKIE = 'backstage-auth';
function getTokenFromRequest(req: Request) {
// TODO: support multiple auth headers (iterate rawHeaders)
const authHeader = req.headers.authorization;
if (typeof authHeader === 'string') {
const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i);
const token = matches?.[1];
if (token) {
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;
}
}
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<BackstageCredentials>;
[limitedCredentialsSymbol]?: Promise<BackstageCredentials>;
};
class DefaultHttpAuthService implements HttpAuthService {
readonly #auth: AuthService;
readonly #discovery: DiscoveryService;
readonly #pluginId: string;
constructor(
auth: AuthService,
discovery: DiscoveryService,
pluginId: string,
) {
this.#auth = auth;
this.#discovery = discovery;
this.#pluginId = pluginId;
}
async #extractCredentialsFromRequest(req: Request) {
const token = getTokenFromRequest(req);
if (!token) {
return await this.#auth.getNoneCredentials();
}
return await this.#auth.authenticate(token);
}
async #extractLimitedCredentialsFromRequest(req: Request) {
const token = getTokenFromRequest(req);
if (token) {
return await this.#auth.authenticate(token, {
allowLimitedAccess: true,
});
}
const cookie = getCookieFromRequest(req);
if (cookie) {
return await this.#auth.authenticate(cookie, {
allowLimitedAccess: true,
});
}
return await this.#auth.getNoneCredentials();
}
async #getCredentials(req: RequestWithCredentials) {
return (req[credentialsSymbol] ??=
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>;
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
// 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 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;
}
throw new NotAllowedError(
`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`,
);
}
throw new NotAllowedError(
'Unknown principal type, this should never happen',
);
}
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<BackstageUserPrincipal>;
if (options?.credentials) {
if (this.#auth.isPrincipal(options.credentials, 'none')) {
res.clearCookie(
BACKSTAGE_AUTH_COOKIE,
await this.#getCookieOptions(res.req),
);
return { expiresAt: new Date() };
}
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 { token, expiresAt } = await this.#auth.getLimitedUserToken(
credentials,
);
if (!token) {
throw new Error('User credentials is unexpectedly missing token');
}
res.cookie(BACKSTAGE_AUTH_COOKIE, token, {
...(await this.#getCookieOptions(res.req)),
expires: expiresAt,
});
return { expiresAt };
}
async #getCookieOptions(_req: Request): Promise<{
domain: string;
httpOnly: true;
secure: boolean;
priority: 'high';
sameSite: 'none' | 'lax';
}> {
// TODO: eventually we should read from `${req.protocol}://${req.hostname}`
// once https://github.com/backstage/backstage/issues/24169 has landed
const externalBaseUrlStr = await this.#discovery.getExternalBaseUrl(
this.#pluginId,
);
const externalBaseUrl = new URL(externalBaseUrlStr);
const secure =
externalBaseUrl.protocol === 'https:' ||
externalBaseUrl.hostname === 'localhost';
return {
domain: externalBaseUrl.hostname,
httpOnly: true,
secure,
priority: 'high',
sameSite: secure ? 'none' : 'lax',
};
}
async #existingCookieExpiration(req: Request): Promise<Date | undefined> {
const existingCookie = getCookieFromRequest(req);
if (!existingCookie) {
return undefined;
}
try {
const existingCredentials = await this.#auth.authenticate(
existingCookie,
{
allowLimitedAccess: true,
},
);
if (!this.#auth.isPrincipal(existingCredentials, 'user')) {
return undefined;
}
return existingCredentials.expiresAt;
} catch (error) {
if (error.name === 'AuthenticationError') {
return undefined;
}
throw error;
}
}
}
/** @public */
export const httpAuthServiceFactory = createServiceFactory({
service: coreServices.httpAuth,
deps: {
auth: coreServices.auth,
discovery: coreServices.discovery,
plugin: coreServices.pluginMetadata,
},
async factory({ auth, discovery, plugin }) {
return new DefaultHttpAuthService(auth, discovery, plugin.getId());
},
});
/**
* @public
* @deprecated Please import from `@backstage/backend-defaults/httpAuth` instead.
*/
export const httpAuthServiceFactory = _httpAuthServiceFactory;
@@ -36,7 +36,15 @@ export interface HttpRouterFactoryOptions {
getPath?(pluginId: string): string;
}
/** @public */
/**
* HTTP route registration for plugins.
*
* See {@link @backstage/code-plugin-api#HttpRouterService}
* and {@link https://backstage.io/docs/backend-system/core-services/http-router | the service docs}
* for more information.
*
* @public
*/
export const httpRouterServiceFactory = createServiceFactory(
(options?: HttpRouterFactoryOptions) => ({
service: coreServices.httpRouter,
@@ -14,12 +14,10 @@
* limitations under the License.
*/
export * from './auth';
export * from './cache';
export * from './config';
export * from './database';
export * from './discovery';
export * from './httpAuth';
export * from './httpRouter';
export * from './identity';
export * from './lifecycle';
@@ -30,6 +28,5 @@ export * from './rootLifecycle';
export * from './rootLogger';
export * from './tokenManager';
export * from './urlReader';
export * from './userInfo';
export * from './deprecated';
@@ -19,7 +19,15 @@ import {
coreServices,
} from '@backstage/backend-plugin-api';
/** @public */
/**
* Plugin-level logging.
*
* See {@link @backstage/code-plugin-api#LoggerService}
* and {@link https://backstage.io/docs/backend-system/core-services/logger | the service docs}
* for more information.
*
* @public
*/
export const loggerServiceFactory = createServiceFactory({
service: coreServices.logger,
deps: {
@@ -45,6 +45,12 @@ export interface RootHttpRouterConfigureContext {
}
/**
* HTTP route registration for root services.
*
* See {@link @backstage/code-plugin-api#RootHttpRouterService}
* and {@link https://backstage.io/docs/backend-system/core-services/root-http-router | the service docs}
* for more information.
*
* @public
*/
export type RootHttpRouterFactoryOptions = {
@@ -22,7 +22,15 @@ import { WinstonLogger } from '../../../logging';
import { transports, format } from 'winston';
import { createConfigSecretEnumerator } from '../../../config';
/** @public */
/**
* Root-level logging.
*
* See {@link @backstage/code-plugin-api#RootLoggerService}
* and {@link https://backstage.io/docs/backend-system/core-services/root-logger | the service docs}
* for more information.
*
* @public
*/
export const rootLoggerServiceFactory = createServiceFactory({
service: coreServices.rootLogger,
deps: {
@@ -14,90 +14,11 @@
* limitations under the License.
*/
import {
UserInfoService,
BackstageUserInfo,
coreServices,
createServiceFactory,
DiscoveryService,
BackstageCredentials,
} from '@backstage/backend-plugin-api';
import { ResponseError } from '@backstage/errors';
import { decodeJwt } from 'jose';
import fetch from 'node-fetch';
import { toInternalBackstageCredentials } from '../auth/helpers';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { userInfoServiceFactory as _userInfoServiceFactory } from '../../../../../backend-defaults/src/entrypoints/userInfo';
type Options = {
discovery: DiscoveryService;
};
export class DefaultUserInfoService implements UserInfoService {
private readonly discovery: DiscoveryService;
constructor(options: Options) {
this.discovery = options.discovery;
}
async getUserInfo(
credentials: BackstageCredentials,
): Promise<BackstageUserInfo> {
const internalCredentials = toInternalBackstageCredentials(credentials);
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: tokenEnt } = decodeJwt(
internalCredentials.token,
);
if (typeof userEntityRef !== 'string') {
throw new Error('User entity ref must be a string');
}
let ownershipEntityRefs = tokenEnt;
if (!ownershipEntityRefs) {
const userInfoResp = await fetch(
`${await this.discovery.getBaseUrl('auth')}/v1/userinfo`,
{
headers: {
Authorization: `Bearer ${internalCredentials.token}`,
},
},
);
if (!userInfoResp.ok) {
throw await ResponseError.fromResponse(userInfoResp);
}
const {
claims: { ent },
} = await userInfoResp.json();
ownershipEntityRefs = ent;
}
if (!ownershipEntityRefs) {
throw new Error('Ownership entity refs can not be determined');
} else if (
!Array.isArray(ownershipEntityRefs) ||
ownershipEntityRefs.some(ref => typeof ref !== 'string')
) {
throw new Error('Ownership entity refs must be an array of strings');
}
return { userEntityRef, ownershipEntityRefs };
}
}
/** @public */
export const userInfoServiceFactory = createServiceFactory({
service: coreServices.userInfo,
deps: {
discovery: coreServices.discovery,
},
async factory({ discovery }) {
return new DefaultUserInfoService({ discovery });
},
});
/**
* @public
* @deprecated Please import from `@backstage/backend-defaults/userInfo` instead.
*/
export const userInfoServiceFactory = _userInfoServiceFactory;
@@ -35,7 +35,7 @@ import {
createCredentialsWithUserPrincipal,
createCredentialsWithNonePrincipal,
toInternalBackstageCredentials,
} from '../../../backend-app-api/src/services/implementations/auth/helpers';
} from '../../../backend-defaults/src/entrypoints/auth/helpers';
// TODO is this circular thingy a problem? Test in e2e
import {
type IdentityApiGetIdentityRequest,
@@ -0,0 +1,13 @@
## API Report File for "@backstage/backend-defaults"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AuthService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
// @public
export const authServiceFactory: () => ServiceFactory<AuthService, 'plugin'>;
// (No @packageDocumentation comment for this package)
```
@@ -24,7 +24,7 @@ export type CacheManagerOptions = {
onError?: (err: Error) => void;
};
// @public (undocumented)
// @public
export const cacheServiceFactory: () => ServiceFactory<CacheService, 'plugin'>;
// @public (undocumented)
@@ -31,7 +31,7 @@ export type DatabaseManagerOptions = {
logger?: LoggerService;
};
// @public (undocumented)
// @public
export const databaseServiceFactory: () => ServiceFactory<
DatabaseService,
'plugin'
@@ -7,7 +7,7 @@ import { Config } from '@backstage/config';
import { DiscoveryService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
// @public (undocumented)
// @public
export const discoveryServiceFactory: () => ServiceFactory<
DiscoveryService,
'plugin'
@@ -0,0 +1,16 @@
## API Report File for "@backstage/backend-defaults"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { HttpAuthService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
// @public
export const httpAuthServiceFactory: () => ServiceFactory<
HttpAuthService,
'plugin'
>;
// (No @packageDocumentation comment for this package)
```
@@ -6,7 +6,7 @@
import { PermissionsService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
// @public (undocumented)
// @public
export const permissionsServiceFactory: () => ServiceFactory<
PermissionsService,
'plugin'
@@ -7,7 +7,7 @@ import { RemoteConfigSourceOptions } from '@backstage/config-loader';
import { RootConfigService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
// @public (undocumented)
// @public
export interface RootConfigFactoryOptions {
argv?: string[];
remote?: Pick<RemoteConfigSourceOptions, 'reloadInterval'>;
@@ -425,7 +425,7 @@ export class UrlReaders {
static default(options: UrlReadersOptions): UrlReaderService;
}
// @public (undocumented)
// @public
export const urlReaderServiceFactory: () => ServiceFactory<
UrlReaderService,
'plugin'
@@ -0,0 +1,16 @@
## API Report File for "@backstage/backend-defaults"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ServiceFactory } from '@backstage/backend-plugin-api';
import { UserInfoService } from '@backstage/backend-plugin-api';
// @public
export const userInfoServiceFactory: () => ServiceFactory<
UserInfoService,
'plugin'
>;
// (No @packageDocumentation comment for this package)
```
+277
View File
@@ -15,6 +15,283 @@
*/
export interface Config {
backend?: {
/**
* Options used by the default auth, httpAuth and userInfo services.
*/
auth?: {
/**
* This disables the otherwise default auth policy, which requires all
* requests to be authenticated with either user or service credentials.
*
* Disabling this check means that the backend will no longer block
* unauthenticated requests, but instead allow them to pass through to
* plugins.
*
* If permissions are enabled, unauthenticated requests will be treated
* exactly as such, leaving it to the permission policy to determine what
* permissions should be allowed for an unauthenticated identity. Note
* that this will also apply to service-to-service calls between plugins
* unless you configure credentials for service calls.
*/
dangerouslyDisableDefaultAuthPolicy?: boolean;
/** Controls how to store keys for plugin-to-plugin auth */
pluginKeyStore?:
| { type: 'database' }
| {
type: 'static';
static: {
/**
* Must be declared at least once and the first one will be used for signing.
*/
keys: Array<{
/**
* Path to the public key file in the SPKI format. Should be an absolute path.
*/
publicKeyFile: string;
/**
* Path to the matching private key file in the PKCS#8 format. Should be an absolute path.
*
* The first array entry must specify a private key file, the rest must not.
*/
privateKeyFile?: string;
/**
* ID to uniquely identify this key within the JWK set.
*/
keyId: string;
/**
* JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256.
* Must match the algorithm used to generate the keys in the provided files
*/
algorithm?: string;
}>;
};
};
/**
* Configures methods of external access, ie ways for callers outside of
* the Backstage ecosystem to get authorized for access to APIs that do
* not permit unauthorized access.
*/
externalAccess: Array<
| {
/**
* This is the legacy service-to-service access method, where a set
* of static keys were shared among plugins and used for symmetric
* signing and verification. These correspond to the old
* `backend.auth.keys` set and retain their behavior for backwards
* compatibility. Please migrate to other access methods when
* possible.
*
* Callers generate JWT tokens with the following payload:
*
* ```json
* {
* "sub": "backstage-plugin",
* "exp": <epoch seconds one hour in the future>
* }
* ```
*
* And sign them with HS256, using the base64 decoded secret. The
* tokens are then passed along with requests in the Authorization
* header:
*
* ```
* Authorization: Bearer eyJhbGciOiJIUzI...
* ```
*/
type: 'legacy';
options: {
/**
* Any set of base64 encoded random bytes to be used as both the
* signing and verification key. Should be sufficiently long so as
* not to be easy to guess by brute force.
*
* Can be generated eg using
*
* ```sh
* node -p 'require("crypto").randomBytes(24).toString("base64")'
* ```
*
* @visibility secret
*/
secret: string;
/**
* Sets the subject of the principal, when matching this token.
* Useful for debugging and tracking purposes.
*/
subject: string;
};
/**
* Restricts what types of access that are permitted for this access
* method. If no access restrictions are given, it'll have unlimited
* access. This access restriction applies for the framework level;
* individual plugins may have their own access control mechanisms
* on top of this.
*/
accessRestrictions?: Array<{
/**
* Permit access to make requests to this plugin.
*
* Can be further refined by setting additional fields below.
*/
plugin: string;
/**
* If given, this method is limited to only performing actions
* with these named permissions in this plugin.
*
* Note that this only applies where permissions checks are
* enabled in the first place. Endpoints that are not protected by
* the permissions system at all, are not affected by this
* setting.
*/
permission?: string | Array<string>;
/**
* If given, this method is limited to only performing actions
* whose permissions have these attributes.
*
* Note that this only applies where permissions checks are
* enabled in the first place. Endpoints that are not protected by
* the permissions system at all, are not affected by this
* setting.
*/
permissionAttribute?: {
/**
* One of more of 'create', 'read', 'update', or 'delete'.
*/
action?: string | Array<string>;
};
}>;
}
| {
/**
* This access method consists of random static tokens that can be
* handed out to callers.
*
* The tokens are then passed along verbatim with requests in the
* Authorization header:
*
* ```
* Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
* ```
*/
type: 'static';
options: {
/**
* A raw token that can be any string, but for security reasons
* should be sufficiently long so as not to be easy to guess by
* brute force.
*
* Can be generated eg using
*
* ```sh
* node -p 'require("crypto").randomBytes(24).toString("base64")'
* ```
*
* Since the tokens can be any string, you are free to add
* additional identifying data to them if you like. For example,
* adding a `freben-local-dev-` prefix for debugging purposes to a
* token that you know will be handed out for use as a personal
* access token during development.
*
* @visibility secret
*/
token: string;
/**
* Sets the subject of the principal, when matching this token.
* Useful for debugging and tracking purposes.
*/
subject: string;
};
/**
* Restricts what types of access that are permitted for this access
* method. If no access restrictions are given, it'll have unlimited
* access. This access restriction applies for the framework level;
* individual plugins may have their own access control mechanisms
* on top of this.
*/
accessRestrictions?: Array<{
/**
* Permit access to make requests to this plugin.
*
* Can be further refined by setting additional fields below.
*/
plugin: string;
/**
* If given, this method is limited to only performing actions
* with these named permissions in this plugin.
*
* Note that this only applies where permissions checks are
* enabled in the first place. Endpoints that are not protected by
* the permissions system at all, are not affected by this
* setting.
*/
permission?: string | Array<string>;
/**
* If given, this method is limited to only performing actions
* whose permissions have these attributes.
*
* Note that this only applies where permissions checks are
* enabled in the first place. Endpoints that are not protected by
* the permissions system at all, are not affected by this
* setting.
*/
permissionAttribute?: {
/**
* One of more of 'create', 'read', 'update', or 'delete'.
*/
action?: string | Array<string>;
};
}>;
}
| {
/**
* This access method consists of a JWKS endpoint that can be used to
* verify JWT tokens.
*
* Callers generate JWT tokens via 3rd party tooling
* and pass them in the Authorization header:
*
* ```
* Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
* ```
*/
type: 'jwks';
options: {
/**
* The full URL of the JWKS endpoint.
*/
url: string;
/**
* Sets the algorithm(s) that should be used to verify the JWT tokens.
* The passed JWTs must have been signed using one of the listed algorithms.
*/
algorithm?: string | string[];
/**
* Sets the issuer(s) that should be used to verify the JWT tokens.
* Passed JWTs must have an `iss` claim which matches one of the specified issuers.
*/
issuer?: string | string[];
/**
* Sets the audience(s) that should be used to verify the JWT tokens.
* The passed JWTs must have an "aud" claim that matches one of the audiences specified,
* or have no audience specified.
*/
audience?: string | string[];
/**
* Sets an optional subject prefix. Passes the subject to called plugins.
* Useful for debugging and tracking purposes.
*/
subjectPrefix?: string;
};
}
>;
};
};
/**
* Options used by the default discovery service.
*/
+17
View File
@@ -20,21 +20,27 @@
"license": "Apache-2.0",
"exports": {
".": "./src/index.ts",
"./auth": "./src/entrypoints/auth/index.ts",
"./cache": "./src/entrypoints/cache/index.ts",
"./database": "./src/entrypoints/database/index.ts",
"./discovery": "./src/entrypoints/discovery/index.ts",
"./httpAuth": "./src/entrypoints/httpAuth/index.ts",
"./lifecycle": "./src/entrypoints/lifecycle/index.ts",
"./permissions": "./src/entrypoints/permissions/index.ts",
"./rootConfig": "./src/entrypoints/rootConfig/index.ts",
"./rootLifecycle": "./src/entrypoints/rootLifecycle/index.ts",
"./scheduler": "./src/entrypoints/scheduler/index.ts",
"./urlReader": "./src/entrypoints/urlReader/index.ts",
"./userInfo": "./src/entrypoints/userInfo/index.ts",
"./package.json": "./package.json"
},
"main": "src/index.ts",
"types": "src/index.ts",
"typesVersions": {
"*": {
"auth": [
"src/entrypoints/auth/index.ts"
],
"cache": [
"src/entrypoints/cache/index.ts"
],
@@ -44,6 +50,9 @@
"discovery": [
"src/entrypoints/discovery/index.ts"
],
"httpAuth": [
"src/entrypoints/httpAuth/index.ts"
],
"lifecycle": [
"src/entrypoints/lifecycle/index.ts"
],
@@ -62,6 +71,9 @@
"urlReader": [
"src/entrypoints/urlReader/index.ts"
],
"userInfo": [
"src/entrypoints/userInfo/index.ts"
],
"package.json": [
"package.json"
]
@@ -88,6 +100,7 @@
"@aws-sdk/credential-providers": "^3.350.0",
"@aws-sdk/types": "^3.347.0",
"@backstage/backend-app-api": "workspace:^",
"@backstage/backend-common": "workspace:^",
"@backstage/backend-dev-utils": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/config": "workspace:^",
@@ -95,6 +108,7 @@
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/integration-aws-node": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/types": "workspace:^",
@@ -107,10 +121,13 @@
"base64-stream": "^1.0.0",
"better-sqlite3": "^9.0.0",
"concat-stream": "^2.0.0",
"cookie": "^0.6.0",
"cron": "^3.0.0",
"express": "^4.17.1",
"fs-extra": "^11.2.0",
"git-url-parse": "^14.0.0",
"isomorphic-git": "^1.23.0",
"jose": "^5.0.0",
"keyv": "^4.5.2",
"knex": "^3.0.0",
"lodash": "^4.17.21",
@@ -16,26 +16,26 @@
import {
Backend,
authServiceFactory,
createSpecializedBackend,
httpAuthServiceFactory,
httpRouterServiceFactory,
identityServiceFactory,
loggerServiceFactory,
rootHttpRouterServiceFactory,
rootLoggerServiceFactory,
tokenManagerServiceFactory,
userInfoServiceFactory,
} from '@backstage/backend-app-api';
import { authServiceFactory } from '@backstage/backend-defaults/auth';
import { cacheServiceFactory } from '@backstage/backend-defaults/cache';
import { databaseServiceFactory } from '@backstage/backend-defaults/database';
import { discoveryServiceFactory } from '@backstage/backend-defaults/discovery';
import { httpAuthServiceFactory } from '@backstage/backend-defaults/httpAuth';
import { lifecycleServiceFactory } from '@backstage/backend-defaults/lifecycle';
import { permissionsServiceFactory } from '@backstage/backend-defaults/permissions';
import { rootConfigServiceFactory } from '@backstage/backend-defaults/rootConfig';
import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle';
import { schedulerServiceFactory } from '@backstage/backend-defaults/scheduler';
import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader';
import { userInfoServiceFactory } from '@backstage/backend-defaults/userInfo';
import { eventsServiceFactory } from '@backstage/plugin-events-node';
export const defaultServiceFactories = [
@@ -19,6 +19,7 @@ import {
mockServices,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import { tokenManagerServiceFactory } from '@backstage/backend-app-api';
import { authServiceFactory } from './authServiceFactory';
import { base64url, decodeJwt } from 'jose';
import { discoveryServiceFactory } from '../discovery';
@@ -26,7 +27,6 @@ import {
BackstageServicePrincipal,
BackstageUserPrincipal,
} from '@backstage/backend-plugin-api';
import { tokenManagerServiceFactory } from '../tokenManager';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { InternalBackstageCredentials } from './types';
@@ -0,0 +1,93 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { DefaultAuthService } from './DefaultAuthService';
import { ExternalTokenHandler } from './external/ExternalTokenHandler';
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
import { createPluginKeySource } from './plugin/keys/createPluginKeySource';
import { UserTokenHandler } from './user/UserTokenHandler';
/**
* Handles token authentication and credentials management.
*
* See {@link @backstage/code-plugin-api#AuthService}
* and {@link https://backstage.io/docs/backend-system/core-services/auth | the service docs}
* for more information.
*
* @public
*/
export const authServiceFactory = createServiceFactory({
service: coreServices.auth,
deps: {
config: coreServices.rootConfig,
logger: coreServices.rootLogger,
discovery: coreServices.discovery,
plugin: coreServices.pluginMetadata,
database: coreServices.database,
// Re-using the token manager makes sure that we use the same generated keys for
// development as plugins that have not yet been migrated. It's important that this
// keeps working as long as there are plugins that have not been migrated to the
// new auth services in the new backend system.
tokenManager: coreServices.tokenManager,
},
async factory({ config, discovery, plugin, tokenManager, logger, database }) {
const disableDefaultAuthPolicy =
config.getOptionalBoolean(
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
) ?? false;
const keyDuration = { hours: 1 };
const keySource = await createPluginKeySource({
config,
database,
logger,
keyDuration,
});
const userTokens = UserTokenHandler.create({
discovery,
});
const pluginTokens = PluginTokenHandler.create({
ownPluginId: plugin.getId(),
logger,
keySource,
keyDuration,
discovery,
});
const externalTokens = ExternalTokenHandler.create({
ownPluginId: plugin.getId(),
config,
logger,
});
return new DefaultAuthService(
userTokens,
pluginTokens,
externalTokens,
tokenManager,
plugin.getId(),
disableDefaultAuthPolicy,
keySource,
);
},
});
@@ -0,0 +1,17 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { authServiceFactory } from './authServiceFactory';
@@ -37,8 +37,8 @@ type Row = {
export function applyDatabaseMigrations(knex: Knex): Promise<void> {
const migrationsDir = resolvePackagePath(
'@backstage/backend-app-api',
'migrations',
'@backstage/backend-defaults',
'migrations/auth',
);
return knex.migrate.latest({
@@ -21,6 +21,12 @@ import {
import { CacheManager } from './CacheManager';
/**
* Key-value store for caching data.
*
* See {@link @backstage/code-plugin-api#CacheService}
* and {@link https://backstage.io/docs/backend-system/core-services/cache | the service docs}
* for more information.
*
* @public
*/
export const cacheServiceFactory = createServiceFactory({
@@ -22,6 +22,12 @@ import { ConfigReader } from '@backstage/config';
import { DatabaseManager } from './DatabaseManager';
/**
* Database access and management via `knex`.
*
* See {@link @backstage/code-plugin-api#DatabaseService}
* and {@link https://backstage.io/docs/backend-system/core-services/database | the service docs}
* for more information.
*
* @public
*/
export const databaseServiceFactory = createServiceFactory({
@@ -20,7 +20,15 @@ import {
} from '@backstage/backend-plugin-api';
import { HostDiscovery } from './HostDiscovery';
/** @public */
/**
* Service discovery for inter-plugin communication.
*
* See {@link @backstage/code-plugin-api#DiscoveryService}
* and {@link https://backstage.io/docs/backend-system/core-services/discovery | the service docs}
* for more information.
*
* @public
*/
export const discoveryServiceFactory = createServiceFactory({
service: coreServices.discovery,
deps: {
@@ -0,0 +1,294 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AuthService,
BackstageCredentials,
BackstagePrincipalTypes,
BackstageUserPrincipal,
DiscoveryService,
HttpAuthService,
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
import { parse as parseCookie } from 'cookie';
import { Request, Response } from 'express';
const FIVE_MINUTES_MS = 5 * 60 * 1000;
const BACKSTAGE_AUTH_COOKIE = 'backstage-auth';
function getTokenFromRequest(req: Request) {
// TODO: support multiple auth headers (iterate rawHeaders)
const authHeader = req.headers.authorization;
if (typeof authHeader === 'string') {
const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i);
const token = matches?.[1];
if (token) {
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;
}
}
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<BackstageCredentials>;
[limitedCredentialsSymbol]?: Promise<BackstageCredentials>;
};
class DefaultHttpAuthService implements HttpAuthService {
readonly #auth: AuthService;
readonly #discovery: DiscoveryService;
readonly #pluginId: string;
constructor(
auth: AuthService,
discovery: DiscoveryService,
pluginId: string,
) {
this.#auth = auth;
this.#discovery = discovery;
this.#pluginId = pluginId;
}
async #extractCredentialsFromRequest(req: Request) {
const token = getTokenFromRequest(req);
if (!token) {
return await this.#auth.getNoneCredentials();
}
return await this.#auth.authenticate(token);
}
async #extractLimitedCredentialsFromRequest(req: Request) {
const token = getTokenFromRequest(req);
if (token) {
return await this.#auth.authenticate(token, {
allowLimitedAccess: true,
});
}
const cookie = getCookieFromRequest(req);
if (cookie) {
return await this.#auth.authenticate(cookie, {
allowLimitedAccess: true,
});
}
return await this.#auth.getNoneCredentials();
}
async #getCredentials(req: RequestWithCredentials) {
return (req[credentialsSymbol] ??=
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>;
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
// 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 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;
}
throw new NotAllowedError(
`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`,
);
}
throw new NotAllowedError(
'Unknown principal type, this should never happen',
);
}
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<BackstageUserPrincipal>;
if (options?.credentials) {
if (this.#auth.isPrincipal(options.credentials, 'none')) {
res.clearCookie(
BACKSTAGE_AUTH_COOKIE,
await this.#getCookieOptions(res.req),
);
return { expiresAt: new Date() };
}
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 { token, expiresAt } = await this.#auth.getLimitedUserToken(
credentials,
);
if (!token) {
throw new Error('User credentials is unexpectedly missing token');
}
res.cookie(BACKSTAGE_AUTH_COOKIE, token, {
...(await this.#getCookieOptions(res.req)),
expires: expiresAt,
});
return { expiresAt };
}
async #getCookieOptions(_req: Request): Promise<{
domain: string;
httpOnly: true;
secure: boolean;
priority: 'high';
sameSite: 'none' | 'lax';
}> {
// TODO: eventually we should read from `${req.protocol}://${req.hostname}`
// once https://github.com/backstage/backstage/issues/24169 has landed
const externalBaseUrlStr = await this.#discovery.getExternalBaseUrl(
this.#pluginId,
);
const externalBaseUrl = new URL(externalBaseUrlStr);
const secure =
externalBaseUrl.protocol === 'https:' ||
externalBaseUrl.hostname === 'localhost';
return {
domain: externalBaseUrl.hostname,
httpOnly: true,
secure,
priority: 'high',
sameSite: secure ? 'none' : 'lax',
};
}
async #existingCookieExpiration(req: Request): Promise<Date | undefined> {
const existingCookie = getCookieFromRequest(req);
if (!existingCookie) {
return undefined;
}
try {
const existingCredentials = await this.#auth.authenticate(
existingCookie,
{
allowLimitedAccess: true,
},
);
if (!this.#auth.isPrincipal(existingCredentials, 'user')) {
return undefined;
}
return existingCredentials.expiresAt;
} catch (error) {
if (error.name === 'AuthenticationError') {
return undefined;
}
throw error;
}
}
}
/**
* Authentication of HTTP requests.
*
* See {@link @backstage/code-plugin-api#HttpAuthService}
* and {@link https://backstage.io/docs/backend-system/core-services/http-auth | the service docs}
* for more information.
*
* @public
*/
export const httpAuthServiceFactory = createServiceFactory({
service: coreServices.httpAuth,
deps: {
auth: coreServices.auth,
discovery: coreServices.discovery,
plugin: coreServices.pluginMetadata,
},
async factory({ auth, discovery, plugin }) {
return new DefaultHttpAuthService(auth, discovery, plugin.getId());
},
});
@@ -0,0 +1,17 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { httpAuthServiceFactory } from './httpAuthServiceFactory';
@@ -85,7 +85,11 @@ export class BackendPluginLifecycleImpl implements LifecycleService {
}
/**
* Allows plugins to register shutdown hooks that are run when the process is about to exit.
* Registration of plugin startup and shutdown lifecycle hooks.
*
* See {@link @backstage/code-plugin-api#LifecycleService}
* and {@link https://backstage.io/docs/backend-system/core-services/lifecycle | the service docs}
* for more information.
*
* @public
*/
@@ -21,6 +21,12 @@ import {
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
/**
* Permission system integration for authorization of user actions.
*
* See {@link @backstage/code-plugin-api#PermissionsService}
* and {@link https://backstage.io/docs/backend-system/core-services/permissions | the service docs}
* for more information.
*
* @public
*/
export const permissionsServiceFactory = createServiceFactory({
@@ -24,6 +24,12 @@ import {
} from '@backstage/config-loader';
/**
* Access to static configuration.
*
* See {@link @backstage/code-plugin-api#RootConfigService}
* and {@link https://backstage.io/docs/backend-system/core-services/root-config | the service docs}
* for more information.
*
* @public
*/
export interface RootConfigFactoryOptions {
@@ -105,7 +105,11 @@ export class BackendLifecycleImpl implements RootLifecycleService {
}
/**
* Allows plugins to register shutdown hooks that are run when the process is about to exit.
* Registration of backend startup and shutdown lifecycle hooks.
*
* See {@link @backstage/code-plugin-api#RootLifecycleService}
* and {@link https://backstage.io/docs/backend-system/core-services/root-lifecycle | the service docs}
* for more information.
*
* @public
*/
@@ -21,7 +21,11 @@ import {
import { DefaultSchedulerService } from './lib/DefaultSchedulerService';
/**
* The default service factory for {@link @backstage/backend-plugin-api#coreServices.scheduler}.
* Scheduling of distributed background tasks.
*
* See {@link @backstage/code-plugin-api#SchedulerService}
* and {@link https://backstage.io/docs/backend-system/core-services/scheduler | the service docs}
* for more information.
*
* @public
*/
@@ -20,7 +20,15 @@ import {
createServiceFactory,
} from '@backstage/backend-plugin-api';
/** @public */
/**
* Reading content from external systems.
*
* See {@link @backstage/code-plugin-api#UrlReaderService}
* and {@link https://backstage.io/docs/backend-system/core-services/url-reader | the service docs}
* for more information.
*
* @public
*/
export const urlReaderServiceFactory = createServiceFactory({
service: coreServices.urlReader,
deps: {
@@ -0,0 +1,169 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BackstageUserPrincipal } from '@backstage/backend-plugin-api';
import {
mockServices,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import { JsonObject } from '@backstage/types';
import { SignJWT, base64url, importJWK } from 'jose';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { InternalBackstageCredentials } from '../auth/types';
import { DefaultUserInfoService } from './DefaultUserInfoService';
describe('DefaultUserInfoService', () => {
const server = setupServer();
setupRequestMockHandlers(server);
const mockPublicKey = {
kty: 'EC',
x: 'GHlwg744e8JekzukPTdtix6R868D6fcWy0ooOx-NEZI',
y: 'Lyujcm0M6X9_yQi3l1eH09z0brU8K9cwrLml_fRFKro',
crv: 'P-256',
kid: 'mock',
alg: 'ES256',
};
const mockPrivateKey = {
...mockPublicKey,
d: 'KEn_mDqXYbZdRHb-JnCrW53LDOv5x4NL1FnlKcqBsFI',
};
function encodeData(data: JsonObject) {
return base64url.encode(JSON.stringify(data));
}
async function createToken(options: {
header: JsonObject;
payload: JsonObject;
signature?: string;
}) {
if (options.signature) {
const header = encodeData(options.header);
const payload = encodeData(options.payload);
return `${header}.${payload}.${options.signature}`;
}
return await new SignJWT(options.payload)
.setProtectedHeader({ ...options.header, alg: 'ES256' })
.sign(await importJWK(mockPrivateKey));
}
const discovery = mockServices.discovery.mock({
getBaseUrl: async pluginId => `https://example.com/api/${pluginId}`,
});
it('makes the expected call when no ent in the token', async () => {
const token = await createToken({
header: {
typ: 'vnd.backstage.user',
alg: 'ES256',
kid: mockPublicKey.kid,
},
payload: {
sub: 'user:default/alice',
},
});
const credentials = {
$$type: '@backstage/BackstageCredentials',
version: 'v1',
token: token,
principal: {
type: 'user',
userEntityRef: 'user:default/alice',
},
} as InternalBackstageCredentials<BackstageUserPrincipal>;
server.use(
rest.get('https://example.com/api/auth/v1/userinfo', (req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(`Bearer ${token}`);
return res(ctx.json({ claims: { ent: ['group:default/my-team'] } }));
}),
);
const service = new DefaultUserInfoService({ discovery });
await expect(service.getUserInfo(credentials)).resolves.toEqual({
userEntityRef: 'user:default/alice',
ownershipEntityRefs: ['group:default/my-team'],
});
});
it('uses the ent from the token when present', async () => {
const token = await createToken({
header: {
typ: 'vnd.backstage.user',
alg: 'ES256',
kid: mockPublicKey.kid,
},
payload: {
sub: 'user:default/alice',
ent: ['group:default/my-team'],
},
});
const credentials = {
$$type: '@backstage/BackstageCredentials',
version: 'v1',
token: token,
principal: {
type: 'user',
userEntityRef: 'user:default/alice',
},
} as InternalBackstageCredentials<BackstageUserPrincipal>;
const service = new DefaultUserInfoService({ discovery });
await expect(service.getUserInfo(credentials)).resolves.toEqual({
userEntityRef: 'user:default/alice',
ownershipEntityRefs: ['group:default/my-team'],
});
});
it('passes on server errors', async () => {
const token = await createToken({
header: {
typ: 'vnd.backstage.user',
alg: 'ES256',
kid: mockPublicKey.kid,
},
payload: {
sub: 'user:default/alice',
},
});
const credentials = {
$$type: '@backstage/BackstageCredentials',
version: 'v1',
token: token,
principal: {
type: 'user',
userEntityRef: 'user:default/alice',
},
} as InternalBackstageCredentials<BackstageUserPrincipal>;
server.use(
rest.get('https://example.com/api/auth/v1/userinfo', (_req, res, ctx) => {
return res(ctx.status(404));
}),
);
const service = new DefaultUserInfoService({ discovery });
await expect(
service.getUserInfo(credentials),
).rejects.toMatchInlineSnapshot(
`[ResponseError: Request failed with 404 Not Found]`,
);
});
});
@@ -0,0 +1,90 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
UserInfoService,
BackstageUserInfo,
DiscoveryService,
BackstageCredentials,
} from '@backstage/backend-plugin-api';
import { ResponseError } from '@backstage/errors';
import { decodeJwt } from 'jose';
import fetch from 'node-fetch';
import { toInternalBackstageCredentials } from '../auth/helpers';
export type Options = {
discovery: DiscoveryService;
};
export class DefaultUserInfoService implements UserInfoService {
private readonly discovery: DiscoveryService;
constructor(options: Options) {
this.discovery = options.discovery;
}
async getUserInfo(
credentials: BackstageCredentials,
): Promise<BackstageUserInfo> {
const internalCredentials = toInternalBackstageCredentials(credentials);
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: tokenEnt } = decodeJwt(
internalCredentials.token,
);
if (typeof userEntityRef !== 'string') {
throw new Error('User entity ref must be a string');
}
let ownershipEntityRefs = tokenEnt;
if (!ownershipEntityRefs) {
const userInfoResp = await fetch(
`${await this.discovery.getBaseUrl('auth')}/v1/userinfo`,
{
headers: {
Authorization: `Bearer ${internalCredentials.token}`,
},
},
);
if (!userInfoResp.ok) {
throw await ResponseError.fromResponse(userInfoResp);
}
const {
claims: { ent },
} = await userInfoResp.json();
ownershipEntityRefs = ent;
}
if (!ownershipEntityRefs) {
throw new Error('Ownership entity refs can not be determined');
} else if (
!Array.isArray(ownershipEntityRefs) ||
ownershipEntityRefs.some(ref => typeof ref !== 'string')
) {
throw new Error('Ownership entity refs must be an array of strings');
}
return { userEntityRef, ownershipEntityRefs };
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { userInfoServiceFactory } from './userInfoServiceFactory';
@@ -0,0 +1,40 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { DefaultUserInfoService } from './DefaultUserInfoService';
/**
* Authenticated user information retrieval.
*
* See {@link @backstage/code-plugin-api#UserInfoService}
* and {@link https://backstage.io/docs/backend-system/core-services/user-info | the service docs}
* for more information.
*
* @public
*/
export const userInfoServiceFactory = createServiceFactory({
service: coreServices.userInfo,
deps: {
discovery: coreServices.discovery,
},
async factory({ discovery }) {
return new DefaultUserInfoService({ discovery });
},
});
@@ -23,7 +23,11 @@ import { createServiceRef } from '../system';
*/
export namespace coreServices {
/**
* The service reference for the plugin scoped {@link AuthService}.
* Handles token authentication and credentials management.
*
* See {@link AuthService}
* and {@link https://backstage.io/docs/backend-system/core-services/auth | the service docs}
* for more information.
*
* @public
*/
@@ -32,7 +36,11 @@ export namespace coreServices {
});
/**
* The service reference for the plugin scoped {@link UserInfoService}.
* Authenticated user information retrieval.
*
* See {@link UserInfoService}
* and {@link https://backstage.io/docs/backend-system/core-services/user-info | the service docs}
* for more information.
*
* @public
*/
@@ -43,7 +51,11 @@ export namespace coreServices {
});
/**
* The service reference for the plugin scoped {@link CacheService}.
* Key-value store for caching data.
*
* See {@link CacheService}
* and {@link https://backstage.io/docs/backend-system/core-services/cache | the service docs}
* for more information.
*
* @public
*/
@@ -52,7 +64,11 @@ export namespace coreServices {
});
/**
* The service reference for the root scoped {@link RootConfigService}.
* Access to static configuration.
*
* See {@link RootConfigService}
* and {@link https://backstage.io/docs/backend-system/core-services/root-config | the service docs}
* for more information.
*
* @public
*/
@@ -61,7 +77,11 @@ export namespace coreServices {
>({ id: 'core.rootConfig', scope: 'root' });
/**
* The service reference for the plugin scoped {@link DatabaseService}.
* Database access and management via `knex`.
*
* See {@link DatabaseService}
* and {@link https://backstage.io/docs/backend-system/core-services/database | the service docs}
* for more information.
*
* @public
*/
@@ -70,7 +90,11 @@ export namespace coreServices {
>({ id: 'core.database' });
/**
* The service reference for the plugin scoped {@link DiscoveryService}.
* Service discovery for inter-plugin communication.
*
* See {@link DiscoveryService}
* and {@link https://backstage.io/docs/backend-system/core-services/discovery | the service docs}
* for more information.
*
* @public
*/
@@ -79,7 +103,11 @@ export namespace coreServices {
>({ id: 'core.discovery' });
/**
* The service reference for the plugin scoped {@link HttpAuthService}.
* Authentication of HTTP requests.
*
* See {@link HttpAuthService}
* and {@link https://backstage.io/docs/backend-system/core-services/http-auth | the service docs}
* for more information.
*
* @public
*/
@@ -88,7 +116,11 @@ export namespace coreServices {
>({ id: 'core.httpAuth' });
/**
* The service reference for the plugin scoped {@link HttpRouterService}.
* HTTP route registration for plugins.
*
* See {@link HttpRouterService}
* and {@link https://backstage.io/docs/backend-system/core-services/http-router | the service docs}
* for more information.
*
* @public
*/
@@ -97,7 +129,11 @@ export namespace coreServices {
>({ id: 'core.httpRouter' });
/**
* The service reference for the plugin scoped {@link LifecycleService}.
* Registration of plugin startup and shutdown lifecycle hooks.
*
* See {@link LifecycleService}
* and {@link https://backstage.io/docs/backend-system/core-services/lifecycle | the service docs}
* for more information.
*
* @public
*/
@@ -106,7 +142,11 @@ export namespace coreServices {
>({ id: 'core.lifecycle' });
/**
* The service reference for the plugin scoped {@link LoggerService}.
* Plugin-level logging.
*
* See {@link LoggerService}
* and {@link https://backstage.io/docs/backend-system/core-services/logger | the service docs}
* for more information.
*
* @public
*/
@@ -115,7 +155,11 @@ export namespace coreServices {
>({ id: 'core.logger' });
/**
* The service reference for the plugin scoped {@link PermissionsService}.
* Permission system integration for authorization of user actions.
*
* See {@link PermissionsService}
* and {@link https://backstage.io/docs/backend-system/core-services/permissions | the service docs}
* for more information.
*
* @public
*/
@@ -124,7 +168,11 @@ export namespace coreServices {
>({ id: 'core.permissions' });
/**
* The service reference for the plugin scoped {@link PluginMetadataService}.
* Built-in service for accessing metadata about the current plugin.
*
* See {@link PluginMetadataService}
* and {@link https://backstage.io/docs/backend-system/core-services/plugin-metadata | the service docs}
* for more information.
*
* @public
*/
@@ -133,7 +181,11 @@ export namespace coreServices {
>({ id: 'core.pluginMetadata' });
/**
* The service reference for the root scoped {@link RootHttpRouterService}.
* HTTP route registration for root services.
*
* See {@link RootHttpRouterService}
* and {@link https://backstage.io/docs/backend-system/core-services/root-http-router | the service docs}
* for more information.
*
* @public
*/
@@ -142,7 +194,11 @@ export namespace coreServices {
>({ id: 'core.rootHttpRouter', scope: 'root' });
/**
* The service reference for the root scoped {@link RootLifecycleService}.
* Registration of backend startup and shutdown lifecycle hooks.
*
* See {@link RootLifecycleService}
* and {@link https://backstage.io/docs/backend-system/core-services/root-lifecycle | the service docs}
* for more information.
*
* @public
*/
@@ -151,7 +207,11 @@ export namespace coreServices {
>({ id: 'core.rootLifecycle', scope: 'root' });
/**
* The service reference for the root scoped {@link RootLoggerService}.
* Root-level logging.
*
* See {@link RootLoggerService}
* and {@link https://backstage.io/docs/backend-system/core-services/root-logger | the service docs}
* for more information.
*
* @public
*/
@@ -160,7 +220,11 @@ export namespace coreServices {
>({ id: 'core.rootLogger', scope: 'root' });
/**
* The service reference for the plugin scoped {@link SchedulerService}.
* Scheduling of distributed background tasks.
*
* See {@link SchedulerService}
* and {@link https://backstage.io/docs/backend-system/core-services/scheduler | the service docs}
* for more information.
*
* @public
*/
@@ -169,7 +233,11 @@ export namespace coreServices {
>({ id: 'core.scheduler' });
/**
* The service reference for the plugin scoped {@link TokenManagerService}.
* Deprecated service authentication service, use the `auth` service instead.
*
* See {@link TokenManagerService}
* and {@link https://backstage.io/docs/backend-system/core-services/token-manager | the service docs}
* for more information.
*
* @public
* @deprecated Please migrate to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead
@@ -179,7 +247,11 @@ export namespace coreServices {
>({ id: 'core.tokenManager' });
/**
* The service reference for the plugin scoped {@link UrlReaderService}.
* Reading content from external systems.
*
* See {@link UrlReaderService}
* and {@link https://backstage.io/docs/backend-system/core-services/url-reader | the service docs}
* for more information.
*
* @public
*/
@@ -188,7 +260,11 @@ export namespace coreServices {
>({ id: 'core.urlReader' });
/**
* The service reference for the plugin scoped {@link IdentityService}.
* Deprecated user authentication service, use the `auth` service instead.
*
* See {@link IdentityService}
* and {@link https://backstage.io/docs/backend-system/core-services/identity | the service docs}
* for more information.
*
* @public
* @deprecated Please migrate to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead
+5
View File
@@ -3606,6 +3606,7 @@ __metadata:
"@aws-sdk/types": ^3.347.0
"@aws-sdk/util-stream-node": ^3.350.0
"@backstage/backend-app-api": "workspace:^"
"@backstage/backend-common": "workspace:^"
"@backstage/backend-dev-utils": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
@@ -3615,6 +3616,7 @@ __metadata:
"@backstage/errors": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/integration-aws-node": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-events-node": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
"@backstage/types": "workspace:^"
@@ -3628,10 +3630,13 @@ __metadata:
base64-stream: ^1.0.0
better-sqlite3: ^9.0.0
concat-stream: ^2.0.0
cookie: ^0.6.0
cron: ^3.0.0
express: ^4.17.1
fs-extra: ^11.2.0
git-url-parse: ^14.0.0
isomorphic-git: ^1.23.0
jose: ^5.0.0
keyv: ^4.5.2
knex: ^3.0.0
lodash: ^4.17.21