move the auth services to backend-defaults
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Import utility functions from `backend-defaults` instead of `backend-app-api`
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
---
|
||||
|
||||
Improved `coreServices` doc comments
|
||||
@@ -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`.
|
||||
@@ -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'
|
||||
@@ -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';
|
||||
|
||||
+7
-269
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
+7
-86
@@ -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)
|
||||
```
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
+1
-1
@@ -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,89 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Token authentication and credentials management.
|
||||
*
|
||||
* @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';
|
||||
+2
-2
@@ -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({
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
* @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';
|
||||
@@ -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,103 @@
|
||||
/*
|
||||
* 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,
|
||||
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';
|
||||
|
||||
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 });
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user