Merge pull request #23242 from backstage/rugvip/auth-limited
backend-{plugin,app}-api: updates for new cookie auth design
This commit is contained in:
@@ -35,7 +35,6 @@ export type InternalBackstageCredentials<TPrincipal = unknown> =
|
||||
BackstageCredentials<TPrincipal> & {
|
||||
version: string;
|
||||
token?: string;
|
||||
authMethod: 'token' | 'cookie' | 'none';
|
||||
};
|
||||
|
||||
export function createCredentialsWithServicePrincipal(
|
||||
@@ -48,24 +47,23 @@ export function createCredentialsWithServicePrincipal(
|
||||
type: 'service',
|
||||
subject: sub,
|
||||
},
|
||||
authMethod: 'token',
|
||||
};
|
||||
}
|
||||
|
||||
export function createCredentialsWithUserPrincipal(
|
||||
sub: string,
|
||||
token: string,
|
||||
authMethod: 'token' | 'cookie' = 'token',
|
||||
expiresAt?: Date,
|
||||
): InternalBackstageCredentials<BackstageUserPrincipal> {
|
||||
return {
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
version: 'v1',
|
||||
token,
|
||||
expiresAt,
|
||||
principal: {
|
||||
type: 'user',
|
||||
userEntityRef: sub,
|
||||
},
|
||||
authMethod,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,7 +74,6 @@ export function createCredentialsWithNonePrincipal(): InternalBackstageCredentia
|
||||
principal: {
|
||||
type: 'none',
|
||||
},
|
||||
authMethod: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,6 +108,7 @@ class DefaultAuthService implements AuthService {
|
||||
private readonly disableDefaultAuthPolicy: boolean,
|
||||
) {}
|
||||
|
||||
// allowLimitedAccess is currently ignored, since we currently always use the full user tokens
|
||||
async authenticate(token: string): Promise<BackstageCredentials> {
|
||||
const { sub, aud } = decodeJwt(token);
|
||||
|
||||
@@ -134,6 +132,7 @@ class DefaultAuthService implements AuthService {
|
||||
return createCredentialsWithUserPrincipal(
|
||||
identity.identity.userEntityRef,
|
||||
token,
|
||||
this.#getJwtExpiration(token),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,6 +155,12 @@ class DefaultAuthService implements AuthService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getNoneCredentials(): Promise<
|
||||
BackstageCredentials<BackstageNonePrincipal>
|
||||
> {
|
||||
return createCredentialsWithNonePrincipal();
|
||||
}
|
||||
|
||||
async getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
> {
|
||||
@@ -193,6 +198,30 @@ class DefaultAuthService implements AuthService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const internalCredentials = toInternalBackstageCredentials(credentials);
|
||||
|
||||
const { token } = internalCredentials;
|
||||
|
||||
if (!token) {
|
||||
throw new AuthenticationError(
|
||||
'User credentials is unexpectedly missing token',
|
||||
);
|
||||
}
|
||||
|
||||
return { token, expiresAt: this.#getJwtExpiration(token) };
|
||||
}
|
||||
|
||||
#getJwtExpiration(token: string) {
|
||||
const { exp } = decodeJwt(token);
|
||||
if (!exp) {
|
||||
throw new AuthenticationError('User token is missing expiration');
|
||||
}
|
||||
return new Date(exp * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
|
||||
+141
-61
@@ -18,6 +18,7 @@ import {
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
BackstagePrincipalTypes,
|
||||
BackstageUserPrincipal,
|
||||
DiscoveryService,
|
||||
HttpAuthService,
|
||||
coreServices,
|
||||
@@ -26,11 +27,8 @@ import {
|
||||
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
|
||||
import { parse as parseCookie } from 'cookie';
|
||||
import { Request, Response } from 'express';
|
||||
import { decodeJwt } from 'jose';
|
||||
import {
|
||||
createCredentialsWithNonePrincipal,
|
||||
toInternalBackstageCredentials,
|
||||
} from '../auth/authServiceFactory';
|
||||
|
||||
const FIVE_MINUTES_MS = 5 * 60 * 1000;
|
||||
|
||||
const BACKSTAGE_AUTH_COOKIE = 'backstage-auth';
|
||||
|
||||
@@ -41,54 +39,78 @@ function getTokenFromRequest(req: Request) {
|
||||
const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i);
|
||||
const token = matches?.[1];
|
||||
if (token) {
|
||||
return { token, isCookie: false };
|
||||
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, isCookie: true };
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return { token: undefined, isCookie: false };
|
||||
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(
|
||||
private readonly auth: AuthService,
|
||||
private readonly discovery: DiscoveryService,
|
||||
private readonly pluginId: string,
|
||||
) {}
|
||||
auth: AuthService,
|
||||
discovery: DiscoveryService,
|
||||
pluginId: string,
|
||||
) {
|
||||
this.#auth = auth;
|
||||
this.#discovery = discovery;
|
||||
this.#pluginId = pluginId;
|
||||
}
|
||||
|
||||
async #extractCredentialsFromRequest(req: Request) {
|
||||
const { token, isCookie } = getTokenFromRequest(req);
|
||||
const token = getTokenFromRequest(req);
|
||||
if (!token) {
|
||||
return createCredentialsWithNonePrincipal();
|
||||
return await this.#auth.getNoneCredentials();
|
||||
}
|
||||
|
||||
const credentials = toInternalBackstageCredentials(
|
||||
await this.auth.authenticate(token),
|
||||
);
|
||||
if (isCookie) {
|
||||
if (credentials.principal.type !== 'user') {
|
||||
throw new AuthenticationError(
|
||||
'Refusing to authenticate non-user principal with cookie auth',
|
||||
);
|
||||
}
|
||||
credentials.authMethod = 'cookie';
|
||||
return await this.#auth.authenticate(token);
|
||||
}
|
||||
|
||||
async #extractLimitedCredentialsFromRequest(req: Request) {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (token) {
|
||||
return await this.#auth.authenticate(token, {
|
||||
allowLimitedAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
return credentials;
|
||||
const cookie = getCookieFromRequest(req);
|
||||
if (cookie) {
|
||||
return await this.#auth.authenticate(cookie, {
|
||||
allowLimitedAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
return await this.#auth.getNoneCredentials();
|
||||
}
|
||||
|
||||
async #getCredentials(req: RequestWithCredentials) {
|
||||
@@ -96,73 +118,131 @@ class DefaultHttpAuthService implements HttpAuthService {
|
||||
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>;
|
||||
allowedAuthMethods?: Array<'token' | 'cookie'>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
|
||||
const credentials = toInternalBackstageCredentials(
|
||||
await this.#getCredentials(req),
|
||||
);
|
||||
// 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 allowedPrincipalTypes = options?.allow;
|
||||
const allowedAuthMethods: Array<'token' | 'cookie' | 'none'> =
|
||||
options?.allowedAuthMethods ?? ['token'];
|
||||
|
||||
if (
|
||||
credentials.authMethod !== 'none' &&
|
||||
!allowedAuthMethods.includes(credentials.authMethod)
|
||||
) {
|
||||
throw new NotAllowedError(
|
||||
`This endpoint does not allow the '${credentials.authMethod}' auth method`,
|
||||
);
|
||||
const allowed = options?.allow;
|
||||
if (!allowed) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
if (
|
||||
allowedPrincipalTypes &&
|
||||
!allowedPrincipalTypes.includes(credentials.principal.type as TAllowed)
|
||||
) {
|
||||
if (credentials.authMethod === 'none') {
|
||||
throw new AuthenticationError();
|
||||
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 '${credentials.principal.type}' credentials`,
|
||||
`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`,
|
||||
);
|
||||
}
|
||||
|
||||
return credentials as any;
|
||||
throw new NotAllowedError(
|
||||
'Unknown principal type, this should never happen',
|
||||
);
|
||||
}
|
||||
|
||||
async issueUserCookie(res: Response): Promise<void> {
|
||||
const credentials = await this.credentials(res.req, { allow: ['user'] });
|
||||
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, '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 originHeader = res.req.headers.origin;
|
||||
const origin =
|
||||
!originHeader || originHeader === 'null' ? undefined : originHeader;
|
||||
|
||||
// https://backstage.example.com/api/catalog
|
||||
const externalBaseUrlStr = await this.discovery.getExternalBaseUrl(
|
||||
this.pluginId,
|
||||
const externalBaseUrlStr = await this.#discovery.getExternalBaseUrl(
|
||||
this.#pluginId,
|
||||
);
|
||||
const externalBaseUrl = new URL(externalBaseUrlStr);
|
||||
const externalBaseUrl = new URL(origin ?? externalBaseUrlStr);
|
||||
|
||||
const { token } = toInternalBackstageCredentials(credentials);
|
||||
const { token, expiresAt } = await this.#auth.getLimitedUserToken(
|
||||
credentials,
|
||||
);
|
||||
if (!token) {
|
||||
throw new Error('User credentials is unexpectedly missing token');
|
||||
}
|
||||
|
||||
// TODO: Proper refresh and expiration handling
|
||||
const expires = decodeJwt(token).exp!;
|
||||
const secure =
|
||||
externalBaseUrl.protocol === 'https:' ||
|
||||
externalBaseUrl.hostname === 'localhost';
|
||||
|
||||
// TODO: refresh this thing
|
||||
res.cookie(BACKSTAGE_AUTH_COOKIE, token, {
|
||||
domain: externalBaseUrl.hostname,
|
||||
httpOnly: true,
|
||||
expires: new Date(expires * 1000),
|
||||
path: externalBaseUrl.pathname,
|
||||
expires: expiresAt,
|
||||
secure,
|
||||
priority: 'high',
|
||||
sameSite: 'lax', // TBD
|
||||
sameSite: secure ? 'none' : 'lax',
|
||||
});
|
||||
|
||||
throw new Error('Method not implemented.');
|
||||
return { expiresAt };
|
||||
}
|
||||
|
||||
async #existingCookieExpiration(req: Request): Promise<Date | undefined> {
|
||||
const existingCookie = getCookieFromRequest(req);
|
||||
if (!existingCookie) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const existingCredentials = await this.#auth.authenticate(existingCookie, {
|
||||
allowLimitedAccess: true,
|
||||
});
|
||||
if (!this.#auth.isPrincipal(existingCredentials, 'user')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return existingCredentials.expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+56
-3
@@ -53,7 +53,10 @@ describe('createCredentialsBarrier', () => {
|
||||
.expect(401)
|
||||
.expect(res =>
|
||||
expect(res.body).toMatchObject({
|
||||
error: { name: 'AuthenticationError', message: '' },
|
||||
error: {
|
||||
name: 'AuthenticationError',
|
||||
message: 'Missing credentials',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -98,7 +101,7 @@ describe('createCredentialsBarrier', () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should allow exceptions to the default auth policy to be made', async () => {
|
||||
it('should allow exceptions for unauthenticated access', async () => {
|
||||
const { app, barrier } = setup();
|
||||
|
||||
await request(app).get('/').send().expect(401);
|
||||
@@ -118,5 +121,55 @@ describe('createCredentialsBarrier', () => {
|
||||
await request(app).get('/other').send().expect(200);
|
||||
});
|
||||
|
||||
// TODO: cookie auth
|
||||
it('should allow exceptions for cookie access', async () => {
|
||||
const { app, barrier } = setup();
|
||||
|
||||
await request(app).get('/').send().expect(401);
|
||||
await request(app).get('/public').send().expect(401);
|
||||
await request(app).get('/other').send().expect(401);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('cookie', mockCredentials.limitedUser.cookie())
|
||||
.send()
|
||||
.expect(401);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('authorization', mockCredentials.user.header())
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
barrier.addAuthPolicy({ allow: 'user-cookie', path: '/static' });
|
||||
|
||||
await request(app).get('/').send().expect(401);
|
||||
await request(app).get('/static').send().expect(401);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('cookie', mockCredentials.limitedUser.cookie())
|
||||
.send()
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('authorization', mockCredentials.user.header())
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
await request(app).get('/other').send().expect(401);
|
||||
|
||||
// Unauthenticated access should take precedence
|
||||
barrier.addAuthPolicy({ allow: 'unauthenticated', path: '/' });
|
||||
|
||||
await request(app).get('/').send().expect(200);
|
||||
await request(app).get('/static').send().expect(200);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('cookie', mockCredentials.limitedUser.cookie())
|
||||
.send()
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.get('/static')
|
||||
.set('cookie', mockCredentials.limitedUser.invalidCookie())
|
||||
.send()
|
||||
.expect(200);
|
||||
await request(app).get('/other').send().expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export function createCredentialsBarrier(options: {
|
||||
httpAuth
|
||||
.credentials(req, {
|
||||
allow: ['user', 'service'],
|
||||
allowedAuthMethods: allowsCookie ? ['token', 'cookie'] : ['token'],
|
||||
allowLimitedAccess: allowsCookie,
|
||||
})
|
||||
.then(
|
||||
() => next(),
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import {
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
BackstageNonePrincipal,
|
||||
BackstagePrincipalTypes,
|
||||
BackstageServicePrincipal,
|
||||
BackstageUserInfo,
|
||||
@@ -65,6 +66,12 @@ class AuthCompat implements AuthService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getNoneCredentials(): Promise<
|
||||
BackstageCredentials<BackstageNonePrincipal>
|
||||
> {
|
||||
return createCredentialsWithNonePrincipal();
|
||||
}
|
||||
|
||||
async getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
> {
|
||||
@@ -89,6 +96,7 @@ class AuthCompat implements AuthService {
|
||||
return createCredentialsWithUserPrincipal(
|
||||
identity.identity.userEntityRef,
|
||||
token,
|
||||
this.#getJwtExpiration(token),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,6 +131,30 @@ class AuthCompat implements AuthService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const internalCredentials = toInternalBackstageCredentials(credentials);
|
||||
|
||||
const { token } = internalCredentials;
|
||||
|
||||
if (!token) {
|
||||
throw new AuthenticationError(
|
||||
'User credentials is unexpectedly missing token',
|
||||
);
|
||||
}
|
||||
|
||||
return { token, expiresAt: this.#getJwtExpiration(token) };
|
||||
}
|
||||
|
||||
#getJwtExpiration(token: string) {
|
||||
const { exp } = decodeJwt(token);
|
||||
if (!exp) {
|
||||
throw new AuthenticationError('User token is missing expiration');
|
||||
}
|
||||
return new Date(exp * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenFromRequest(req: Request) {
|
||||
@@ -146,7 +178,11 @@ type RequestWithCredentials = Request & {
|
||||
};
|
||||
|
||||
class HttpAuthCompat implements HttpAuthService {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
#auth: AuthService;
|
||||
|
||||
constructor(auth: AuthService) {
|
||||
this.#auth = auth;
|
||||
}
|
||||
|
||||
async #extractCredentialsFromRequest(req: Request) {
|
||||
const token = getTokenFromRequest(req);
|
||||
@@ -155,7 +191,7 @@ class HttpAuthCompat implements HttpAuthService {
|
||||
}
|
||||
|
||||
const credentials = toInternalBackstageCredentials(
|
||||
await this.auth.authenticate(token),
|
||||
await this.#auth.authenticate(token),
|
||||
);
|
||||
|
||||
return credentials;
|
||||
@@ -170,39 +206,50 @@ class HttpAuthCompat implements HttpAuthService {
|
||||
req: Request,
|
||||
options?: {
|
||||
allow?: Array<TAllowed>;
|
||||
allowedAuthMethods?: Array<'token' | 'cookie'>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
|
||||
const credentials = toInternalBackstageCredentials(
|
||||
await this.#getCredentials(req),
|
||||
);
|
||||
|
||||
const allowedPrincipalTypes = options?.allow;
|
||||
const allowedAuthMethods: Array<'token' | 'cookie' | 'none'> =
|
||||
options?.allowedAuthMethods ?? ['token'];
|
||||
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;
|
||||
}
|
||||
|
||||
if (
|
||||
credentials.authMethod !== 'none' &&
|
||||
!allowedAuthMethods.includes(credentials.authMethod)
|
||||
) {
|
||||
throw new NotAllowedError(
|
||||
`This endpoint does not allow the '${credentials.authMethod}' auth method`,
|
||||
`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`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
allowedPrincipalTypes &&
|
||||
!allowedPrincipalTypes.includes(credentials.principal.type as TAllowed)
|
||||
) {
|
||||
throw new NotAllowedError(
|
||||
`This endpoint does not allow '${credentials.principal.type}' credentials`,
|
||||
);
|
||||
}
|
||||
|
||||
return credentials as any;
|
||||
throw new NotAllowedError(
|
||||
'Unknown principal type, this should never happen',
|
||||
);
|
||||
}
|
||||
|
||||
async issueUserCookie(_res: Response): Promise<void> {}
|
||||
async issueUserCookie(_res: Response): Promise<{ expiresAt: Date }> {
|
||||
return { expiresAt: new Date(Date.now() + 3600_000) };
|
||||
}
|
||||
}
|
||||
|
||||
export class UserInfoCompat implements UserInfoService {
|
||||
|
||||
@@ -24,7 +24,21 @@ import { Response as Response_2 } from 'express';
|
||||
// @public (undocumented)
|
||||
export interface AuthService {
|
||||
// (undocumented)
|
||||
authenticate(token: string): Promise<BackstageCredentials>;
|
||||
authenticate(
|
||||
token: string,
|
||||
options?: {
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials>;
|
||||
// (undocumented)
|
||||
getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}>;
|
||||
// (undocumented)
|
||||
getNoneCredentials(): Promise<BackstageCredentials<BackstageNonePrincipal>>;
|
||||
// (undocumented)
|
||||
getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
@@ -107,6 +121,7 @@ export interface BackendPluginRegistrationPoints {
|
||||
// @public (undocumented)
|
||||
export type BackstageCredentials<TPrincipal = unknown> = {
|
||||
$$type: '@backstage/BackstageCredentials';
|
||||
expiresAt?: Date;
|
||||
principal: TPrincipal;
|
||||
};
|
||||
|
||||
@@ -299,11 +314,18 @@ export interface HttpAuthService {
|
||||
req: Request_2<any, any, any, any, any>,
|
||||
options?: {
|
||||
allow?: Array<TAllowed>;
|
||||
allowedAuthMethods?: Array<'token' | 'cookie'>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>>;
|
||||
// (undocumented)
|
||||
issueUserCookie(res: Response_2): Promise<void>;
|
||||
issueUserCookie(
|
||||
res: Response_2,
|
||||
options?: {
|
||||
credentials?: BackstageCredentials<BackstageUserPrincipal>;
|
||||
},
|
||||
): Promise<{
|
||||
expiresAt: Date;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -46,6 +46,8 @@ export type BackstageServicePrincipal = {
|
||||
export type BackstageCredentials<TPrincipal = unknown> = {
|
||||
$$type: '@backstage/BackstageCredentials';
|
||||
|
||||
expiresAt?: Date;
|
||||
|
||||
principal: TPrincipal;
|
||||
};
|
||||
|
||||
@@ -63,13 +65,20 @@ export type BackstagePrincipalTypes = {
|
||||
* @public
|
||||
*/
|
||||
export interface AuthService {
|
||||
authenticate(token: string): Promise<BackstageCredentials>;
|
||||
authenticate(
|
||||
token: string,
|
||||
options?: {
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials>;
|
||||
|
||||
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
|
||||
credentials: BackstageCredentials,
|
||||
type: TType,
|
||||
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]>;
|
||||
|
||||
getNoneCredentials(): Promise<BackstageCredentials<BackstageNonePrincipal>>;
|
||||
|
||||
getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
>;
|
||||
@@ -78,4 +87,8 @@ export interface AuthService {
|
||||
onBehalfOf: BackstageCredentials;
|
||||
targetPluginId: string;
|
||||
}): Promise<{ token: string }>;
|
||||
|
||||
getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{ token: string; expiresAt: Date }>;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { BackstageCredentials, BackstagePrincipalTypes } from './AuthService';
|
||||
import {
|
||||
BackstageCredentials,
|
||||
BackstagePrincipalTypes,
|
||||
BackstageUserPrincipal,
|
||||
} from './AuthService';
|
||||
|
||||
/** @public */
|
||||
export interface HttpAuthService {
|
||||
@@ -23,9 +27,14 @@ export interface HttpAuthService {
|
||||
req: Request<any, any, any, any, any>,
|
||||
options?: {
|
||||
allow?: Array<TAllowed>;
|
||||
allowedAuthMethods?: Array<'token' | 'cookie'>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>>;
|
||||
|
||||
issueUserCookie(res: Response): Promise<void>;
|
||||
issueUserCookie(
|
||||
res: Response,
|
||||
options?: {
|
||||
credentials?: BackstageCredentials<BackstageUserPrincipal>;
|
||||
},
|
||||
): Promise<{ expiresAt: Date }>;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,17 @@ export function isDockerDisabledForTests(): boolean;
|
||||
|
||||
// @public (undocumented)
|
||||
export namespace mockCredentials {
|
||||
export function limitedUser(
|
||||
userEntityRef?: string,
|
||||
): BackstageCredentials<BackstageUserPrincipal>;
|
||||
export namespace limitedUser {
|
||||
export function cookie(userEntityRef?: string): string;
|
||||
// (undocumented)
|
||||
export function invalidCookie(): string;
|
||||
// (undocumented)
|
||||
export function invalidToken(): string;
|
||||
export function token(userEntityRef?: string): string;
|
||||
}
|
||||
export function none(): BackstageCredentials<BackstageNonePrincipal>;
|
||||
export namespace none {
|
||||
export function header(): string;
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
{
|
||||
"name": "@backstage/backend-test-utils",
|
||||
"description": "Test helpers library for Backstage backends",
|
||||
"version": "0.3.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"description": "Test helpers library for Backstage backends",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage",
|
||||
"test"
|
||||
],
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/backend-test-utils"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"package.json": [
|
||||
@@ -18,28 +32,17 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/backend-test-utils"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage",
|
||||
"test"
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"clean": "backstage-cli package clean",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"clean": "backstage-cli package clean",
|
||||
"start": "backstage-cli package start"
|
||||
"start": "backstage-cli package start",
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-app-api": "workspace:^",
|
||||
@@ -50,6 +53,7 @@
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"better-sqlite3": "^9.0.0",
|
||||
"cookie": "^0.6.0",
|
||||
"express": "^4.17.1",
|
||||
"fs-extra": "^11.0.0",
|
||||
"knex": "^3.0.0",
|
||||
@@ -60,15 +64,12 @@
|
||||
"textextensions": "^5.16.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/jest": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
"peerDependencies": {
|
||||
"@types/jest": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ describe('MockAuthService', () => {
|
||||
auth.authenticate(mockCredentials.user.token()),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.user.token(), {
|
||||
allowLimitedAccess: true,
|
||||
}),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.user.token()),
|
||||
).resolves.toEqual(mockCredentials.user(DEFAULT_MOCK_USER_ENTITY_REF));
|
||||
@@ -66,6 +72,44 @@ describe('MockAuthService', () => {
|
||||
).rejects.toThrow('User token is invalid');
|
||||
});
|
||||
|
||||
it('should authenticate mock limited user tokens', async () => {
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.token()),
|
||||
).rejects.toThrow('Limited user token is not allowed');
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.token(), {}),
|
||||
).rejects.toThrow('Limited user token is not allowed');
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.token(), {
|
||||
allowLimitedAccess: false,
|
||||
}),
|
||||
).rejects.toThrow('Limited user token is not allowed');
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.token(), {
|
||||
allowLimitedAccess: true,
|
||||
}),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.token(), {
|
||||
allowLimitedAccess: true,
|
||||
}),
|
||||
).resolves.toEqual(mockCredentials.user(DEFAULT_MOCK_USER_ENTITY_REF));
|
||||
|
||||
await expect(
|
||||
auth.authenticate(
|
||||
mockCredentials.limitedUser.token('user:default/other'),
|
||||
{
|
||||
allowLimitedAccess: true,
|
||||
},
|
||||
),
|
||||
).resolves.toEqual(mockCredentials.user('user:default/other'));
|
||||
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.limitedUser.invalidToken()),
|
||||
).rejects.toThrow('Limited user token is invalid');
|
||||
});
|
||||
|
||||
it('should authenticate mock service tokens', async () => {
|
||||
await expect(
|
||||
auth.authenticate(mockCredentials.service.token()),
|
||||
@@ -113,6 +157,12 @@ describe('MockAuthService', () => {
|
||||
).rejects.toThrow('Service token is invalid');
|
||||
});
|
||||
|
||||
it('should return none credentials', async () => {
|
||||
await expect(auth.getNoneCredentials()).resolves.toEqual(
|
||||
mockCredentials.none(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return own service credentials', async () => {
|
||||
await expect(auth.getOwnServiceCredentials()).resolves.toEqual(
|
||||
mockCredentials.service('plugin:test'),
|
||||
@@ -211,4 +261,32 @@ describe('MockAuthService', () => {
|
||||
`Refused to issue service token for credential type 'none'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should issue limited user tokens', async () => {
|
||||
await expect(
|
||||
auth.getLimitedUserToken(mockCredentials.user()),
|
||||
).resolves.toEqual({
|
||||
token: mockCredentials.limitedUser.token(),
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
|
||||
await expect(
|
||||
auth.getLimitedUserToken(mockCredentials.user('user:default/other')),
|
||||
).resolves.toEqual({
|
||||
token: mockCredentials.limitedUser.token('user:default/other'),
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
|
||||
await expect(
|
||||
auth.getLimitedUserToken(mockCredentials.none() as any),
|
||||
).rejects.toThrow(
|
||||
"Refused to issue limited user token for credential type 'none'",
|
||||
);
|
||||
|
||||
await expect(
|
||||
auth.getLimitedUserToken(mockCredentials.service() as any),
|
||||
).rejects.toThrow(
|
||||
"Refused to issue limited user token for credential type 'service'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,9 +27,11 @@ import {
|
||||
mockCredentials,
|
||||
MOCK_USER_TOKEN,
|
||||
MOCK_USER_TOKEN_PREFIX,
|
||||
MOCK_INVALID_USER_TOKEN,
|
||||
MOCK_USER_LIMITED_TOKEN_PREFIX,
|
||||
MOCK_INVALID_USER_LIMITED_TOKEN,
|
||||
MOCK_SERVICE_TOKEN,
|
||||
MOCK_SERVICE_TOKEN_PREFIX,
|
||||
MOCK_INVALID_USER_TOKEN,
|
||||
MOCK_INVALID_SERVICE_TOKEN,
|
||||
UserTokenPayload,
|
||||
ServiceTokenPayload,
|
||||
@@ -48,7 +50,10 @@ export class MockAuthService implements AuthService {
|
||||
this.disableDefaultAuthPolicy = options.disableDefaultAuthPolicy;
|
||||
}
|
||||
|
||||
async authenticate(token: string): Promise<BackstageCredentials> {
|
||||
async authenticate(
|
||||
token: string,
|
||||
options?: { allowLimitedAccess?: boolean },
|
||||
): Promise<BackstageCredentials> {
|
||||
switch (token) {
|
||||
case MOCK_USER_TOKEN:
|
||||
return mockCredentials.user();
|
||||
@@ -56,6 +61,8 @@ export class MockAuthService implements AuthService {
|
||||
return mockCredentials.service();
|
||||
case MOCK_INVALID_USER_TOKEN:
|
||||
throw new AuthenticationError('User token is invalid');
|
||||
case MOCK_INVALID_USER_LIMITED_TOKEN:
|
||||
throw new AuthenticationError('Limited user token is invalid');
|
||||
case MOCK_INVALID_SERVICE_TOKEN:
|
||||
throw new AuthenticationError('Service token is invalid');
|
||||
case '':
|
||||
@@ -72,6 +79,18 @@ export class MockAuthService implements AuthService {
|
||||
return mockCredentials.user(userEntityRef);
|
||||
}
|
||||
|
||||
if (token.startsWith(MOCK_USER_LIMITED_TOKEN_PREFIX)) {
|
||||
if (!options?.allowLimitedAccess) {
|
||||
throw new AuthenticationError('Limited user token is not allowed');
|
||||
}
|
||||
|
||||
const { sub: userEntityRef }: UserTokenPayload = JSON.parse(
|
||||
token.slice(MOCK_USER_LIMITED_TOKEN_PREFIX.length),
|
||||
);
|
||||
|
||||
return mockCredentials.user(userEntityRef);
|
||||
}
|
||||
|
||||
if (token.startsWith(MOCK_SERVICE_TOKEN_PREFIX)) {
|
||||
const { sub, target, obo }: ServiceTokenPayload = JSON.parse(
|
||||
token.slice(MOCK_SERVICE_TOKEN_PREFIX.length),
|
||||
@@ -92,6 +111,10 @@ export class MockAuthService implements AuthService {
|
||||
throw new AuthenticationError(`Unknown mock token '${token}'`);
|
||||
}
|
||||
|
||||
async getNoneCredentials() {
|
||||
return mockCredentials.none();
|
||||
}
|
||||
|
||||
async getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
> {
|
||||
@@ -144,4 +167,21 @@ export class MockAuthService implements AuthService {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
if (credentials.principal.type !== 'user') {
|
||||
throw new AuthenticationError(
|
||||
`Refused to issue limited user token for credential type '${credentials.principal.type}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
token: mockCredentials.limitedUser.token(
|
||||
credentials.principal.userEntityRef,
|
||||
),
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,11 @@ import { AuthenticationError } from '@backstage/errors';
|
||||
describe('MockHttpAuthService', () => {
|
||||
const httpAuth = new MockHttpAuthService('test', mockCredentials.none());
|
||||
|
||||
function makeAuthReq(header?: string) {
|
||||
return { headers: { authorization: header } } as Request;
|
||||
function makeAuthReq(authorization?: string) {
|
||||
return { headers: { authorization } } as Request;
|
||||
}
|
||||
function makeCookieAuthReq(cookie?: string) {
|
||||
return { headers: { cookie } } as Request;
|
||||
}
|
||||
|
||||
it('should authenticate unauthenticated requests', async () => {
|
||||
@@ -68,6 +71,59 @@ describe('MockHttpAuthService', () => {
|
||||
).resolves.toEqual(mockCredentials.user('user:default/other'));
|
||||
});
|
||||
|
||||
it('should authenticate limited user requests', async () => {
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeCookieAuthReq(mockCredentials.limitedUser.cookie()),
|
||||
),
|
||||
).resolves.toEqual(mockCredentials.none());
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeCookieAuthReq(mockCredentials.limitedUser.cookie()),
|
||||
{ allowLimitedAccess: true },
|
||||
),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(makeAuthReq(mockCredentials.user.header()), {
|
||||
allowLimitedAccess: true,
|
||||
}),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeCookieAuthReq(mockCredentials.limitedUser.cookie()),
|
||||
{
|
||||
allow: ['user'],
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('Missing credentials');
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeCookieAuthReq(mockCredentials.limitedUser.cookie()),
|
||||
{
|
||||
allow: ['none', 'service'],
|
||||
allowLimitedAccess: true,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("This endpoint does not allow 'user' credentials");
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeAuthReq(`Bearer ${mockCredentials.limitedUser.token()}`),
|
||||
{ allowLimitedAccess: true },
|
||||
),
|
||||
).resolves.toEqual(mockCredentials.user());
|
||||
|
||||
await expect(
|
||||
httpAuth.credentials(
|
||||
makeAuthReq(`Bearer ${mockCredentials.limitedUser.token()}`),
|
||||
),
|
||||
).rejects.toThrow('Limited user token is not allowed');
|
||||
});
|
||||
|
||||
it('should authenticate service requests', async () => {
|
||||
await expect(
|
||||
httpAuth.credentials(makeAuthReq(mockCredentials.service.header())),
|
||||
@@ -161,9 +217,42 @@ describe('MockHttpAuthService', () => {
|
||||
).rejects.toThrow('Service token is invalid');
|
||||
});
|
||||
|
||||
it('does not implement .issueUserCookie', async () => {
|
||||
await expect(httpAuth.issueUserCookie({} as any)).rejects.toThrow(
|
||||
'Not implemented',
|
||||
it('should issue user cookie from request credentials', async () => {
|
||||
const setHeader = jest.fn();
|
||||
|
||||
await expect(
|
||||
httpAuth.issueUserCookie({
|
||||
req: makeAuthReq(mockCredentials.user.header()),
|
||||
setHeader,
|
||||
} as any),
|
||||
).resolves.toEqual({
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(setHeader).toHaveBeenCalledWith(
|
||||
'Set-Cookie',
|
||||
mockCredentials.limitedUser.cookie(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should issue user cookie from explicit credentials', async () => {
|
||||
const setHeader = jest.fn();
|
||||
|
||||
await expect(
|
||||
httpAuth.issueUserCookie(
|
||||
{
|
||||
req: makeAuthReq(mockCredentials.user.header()),
|
||||
setHeader,
|
||||
} as any,
|
||||
{ credentials: mockCredentials.user('user:default/other') },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(setHeader).toHaveBeenCalledWith(
|
||||
'Set-Cookie',
|
||||
mockCredentials.limitedUser.cookie('user:default/other'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,16 +18,18 @@ import {
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
BackstagePrincipalTypes,
|
||||
BackstageUserPrincipal,
|
||||
HttpAuthService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Request, Response } from 'express';
|
||||
import { parse as parseCookie } from 'cookie';
|
||||
import { MockAuthService } from './MockAuthService';
|
||||
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
|
||||
import {
|
||||
AuthenticationError,
|
||||
NotAllowedError,
|
||||
NotImplementedError,
|
||||
} from '@backstage/errors';
|
||||
import { mockCredentials } from './mockCredentials';
|
||||
MOCK_NONE_TOKEN,
|
||||
MOCK_AUTH_COOKIE,
|
||||
mockCredentials,
|
||||
} from './mockCredentials';
|
||||
|
||||
// TODO: support mock cookie auth?
|
||||
export class MockHttpAuthService implements HttpAuthService {
|
||||
@@ -42,33 +44,52 @@ export class MockHttpAuthService implements HttpAuthService {
|
||||
this.#defaultCredentials = defaultCredentials;
|
||||
}
|
||||
|
||||
async #getCredentials(req: Request) {
|
||||
async #getCredentials(req: Request, allowLimitedAccess: boolean) {
|
||||
const header = req.headers.authorization;
|
||||
|
||||
if (header === mockCredentials.none.header()) {
|
||||
return mockCredentials.none();
|
||||
}
|
||||
|
||||
const token =
|
||||
typeof header === 'string'
|
||||
? header.match(/^Bearer[ ]+(\S+)$/i)?.[1]
|
||||
: undefined;
|
||||
|
||||
if (!token) {
|
||||
return this.#defaultCredentials;
|
||||
if (token) {
|
||||
if (token === MOCK_NONE_TOKEN) {
|
||||
return this.#auth.getNoneCredentials();
|
||||
}
|
||||
|
||||
return await this.#auth.authenticate(token, {
|
||||
allowLimitedAccess,
|
||||
});
|
||||
}
|
||||
|
||||
return await this.#auth.authenticate(token);
|
||||
if (allowLimitedAccess) {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
|
||||
if (cookieHeader) {
|
||||
const cookies = parseCookie(cookieHeader);
|
||||
const cookie = cookies[MOCK_AUTH_COOKIE];
|
||||
|
||||
if (cookie) {
|
||||
return await this.#auth.authenticate(cookie, {
|
||||
allowLimitedAccess: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.#defaultCredentials;
|
||||
}
|
||||
|
||||
async credentials<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
|
||||
req: Request,
|
||||
options?: {
|
||||
allow?: Array<TAllowed>;
|
||||
allowedAuthMethods?: Array<'token' | 'cookie'>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
|
||||
const credentials = await this.#getCredentials(req);
|
||||
const credentials = await this.#getCredentials(
|
||||
req,
|
||||
options?.allowLimitedAccess ?? false,
|
||||
);
|
||||
|
||||
const allowedPrincipalTypes = options?.allow;
|
||||
if (!allowedPrincipalTypes) {
|
||||
@@ -80,7 +101,7 @@ export class MockHttpAuthService implements HttpAuthService {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
throw new AuthenticationError();
|
||||
throw new AuthenticationError('Missing credentials');
|
||||
} else if (this.#auth.isPrincipal(credentials, 'user')) {
|
||||
if (allowedPrincipalTypes.includes('user' as TAllowed)) {
|
||||
return credentials as any;
|
||||
@@ -104,7 +125,19 @@ export class MockHttpAuthService implements HttpAuthService {
|
||||
);
|
||||
}
|
||||
|
||||
async issueUserCookie(_res: Response): Promise<void> {
|
||||
throw new NotImplementedError('Not implemented');
|
||||
async issueUserCookie(
|
||||
res: Response,
|
||||
options?: { credentials?: BackstageCredentials<BackstageUserPrincipal> },
|
||||
): Promise<{ expiresAt: Date }> {
|
||||
const credentials =
|
||||
options?.credentials ??
|
||||
(await this.credentials(res.req, { allow: ['user'] }));
|
||||
|
||||
res.setHeader(
|
||||
'Set-Cookie',
|
||||
mockCredentials.limitedUser.cookie(credentials.principal.userEntityRef),
|
||||
);
|
||||
|
||||
return { expiresAt: new Date(Date.now() + 3600_000) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,18 @@ describe('mockCredentials', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a mocked credentials object for a limited user principal', () => {
|
||||
expect(mockCredentials.limitedUser()).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
principal: { type: 'user', userEntityRef: 'user:default/mock' },
|
||||
});
|
||||
|
||||
expect(mockCredentials.limitedUser('user:default/other')).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
principal: { type: 'user', userEntityRef: 'user:default/other' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a mocked credentials object for a service principal', () => {
|
||||
expect(mockCredentials.service()).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
@@ -68,6 +80,22 @@ describe('mockCredentials', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('creates limited user tokens and headers', () => {
|
||||
expect(mockCredentials.limitedUser.token('user:default/other')).toBe(
|
||||
'mock-limited-user-token:{"sub":"user:default/other"}',
|
||||
);
|
||||
expect(mockCredentials.limitedUser.invalidToken()).toBe(
|
||||
'mock-invalid-limited-user-token',
|
||||
);
|
||||
|
||||
expect(mockCredentials.limitedUser.cookie('user:default/other')).toBe(
|
||||
'backstage-auth=mock-limited-user-token:{"sub":"user:default/other"}',
|
||||
);
|
||||
expect(mockCredentials.limitedUser.invalidCookie()).toBe(
|
||||
'backstage-auth=mock-invalid-limited-user-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('creates service tokens and headers', () => {
|
||||
expect(mockCredentials.service.token()).toBe('mock-service-token');
|
||||
expect(
|
||||
|
||||
@@ -24,10 +24,18 @@ import {
|
||||
export const DEFAULT_MOCK_USER_ENTITY_REF = 'user:default/mock';
|
||||
export const DEFAULT_MOCK_SERVICE_SUBJECT = 'external:test-service';
|
||||
|
||||
export const MOCK_AUTH_COOKIE = 'backstage-auth';
|
||||
|
||||
export const MOCK_NONE_TOKEN = 'mock-none-token';
|
||||
|
||||
export const MOCK_USER_TOKEN = 'mock-user-token';
|
||||
export const MOCK_USER_TOKEN_PREFIX = 'mock-user-token:';
|
||||
export const MOCK_INVALID_USER_TOKEN = 'mock-invalid-user-token';
|
||||
|
||||
export const MOCK_USER_LIMITED_TOKEN_PREFIX = 'mock-limited-user-token:';
|
||||
export const MOCK_INVALID_USER_LIMITED_TOKEN =
|
||||
'mock-invalid-limited-user-token';
|
||||
|
||||
export const MOCK_SERVICE_TOKEN = 'mock-service-token';
|
||||
export const MOCK_SERVICE_TOKEN_PREFIX = 'mock-service-token:';
|
||||
export const MOCK_INVALID_SERVICE_TOKEN = 'mock-invalid-service-token';
|
||||
@@ -143,6 +151,54 @@ export namespace mockCredentials {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mocked credentials object for a user principal with limited
|
||||
* access.
|
||||
*
|
||||
* The default user entity reference is 'user:default/mock'.
|
||||
*/
|
||||
export function limitedUser(
|
||||
userEntityRef: string = DEFAULT_MOCK_USER_ENTITY_REF,
|
||||
): BackstageCredentials<BackstageUserPrincipal> {
|
||||
return user(userEntityRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilities related to limited user credentials.
|
||||
*/
|
||||
export namespace limitedUser {
|
||||
/**
|
||||
* Creates a mocked limited user token. If a payload is provided it will be
|
||||
* encoded into the token and forwarded to the credentials object when
|
||||
* authenticated by the mock auth service.
|
||||
*/
|
||||
export function token(
|
||||
userEntityRef: string = DEFAULT_MOCK_USER_ENTITY_REF,
|
||||
): string {
|
||||
validateUserEntityRef(userEntityRef);
|
||||
return `${MOCK_USER_LIMITED_TOKEN_PREFIX}${JSON.stringify({
|
||||
sub: userEntityRef,
|
||||
} satisfies UserTokenPayload)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an authorization header with a mocked limited user token. If a
|
||||
* payload is provided it will be encoded into the token and forwarded to
|
||||
* the credentials object when authenticated by the mock auth service.
|
||||
*/
|
||||
export function cookie(userEntityRef?: string): string {
|
||||
return `${MOCK_AUTH_COOKIE}=${token(userEntityRef)}`;
|
||||
}
|
||||
|
||||
export function invalidToken(): string {
|
||||
return MOCK_INVALID_USER_LIMITED_TOKEN;
|
||||
}
|
||||
|
||||
export function invalidCookie(): string {
|
||||
return `${MOCK_AUTH_COOKIE}=${invalidToken()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mocked credentials object for a service principal.
|
||||
*
|
||||
|
||||
@@ -202,9 +202,11 @@ export namespace mockServices {
|
||||
});
|
||||
export const mock = simpleMock(coreServices.auth, () => ({
|
||||
authenticate: jest.fn(),
|
||||
getNoneCredentials: jest.fn(),
|
||||
getOwnServiceCredentials: jest.fn(),
|
||||
isPrincipal: jest.fn() as any,
|
||||
getPluginRequestToken: jest.fn(),
|
||||
getLimitedUserToken: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ describe('resourcesRoutes', () => {
|
||||
.expect(401, {
|
||||
error: {
|
||||
name: 'AuthenticationError',
|
||||
message: '',
|
||||
message: 'Missing credentials',
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
@@ -508,7 +508,7 @@ describe('resourcesRoutes', () => {
|
||||
.expect(401, {
|
||||
error: {
|
||||
name: 'AuthenticationError',
|
||||
message: '',
|
||||
message: 'Missing credentials',
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user