Auth backend: add support for authenticating from additional app origins

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Samira Mokaram <samiram@spotify.com>
This commit is contained in:
Samira Mokaram
2021-08-10 12:49:43 +02:00
parent 0c58dd73d0
commit b3bf8967a4
6 changed files with 57 additions and 10 deletions
@@ -61,8 +61,7 @@ function defaultJoinScopes(scopes: Set<string>) {
* via the OAuthRequestApi.
*/
export class DefaultAuthConnector<AuthSession>
implements AuthConnector<AuthSession>
{
implements AuthConnector<AuthSession> {
private readonly discoveryApi: DiscoveryApi;
private readonly environment: string;
private readonly provider: AuthProvider & { id: string };
@@ -152,7 +151,10 @@ export class DefaultAuthConnector<AuthSession>
private async showPopup(scopes: Set<string>): Promise<AuthSession> {
const scope = this.joinScopesFunc(scopes);
const popupUrl = await this.buildUrl('/start', { scope });
const popupUrl = await this.buildUrl('/start', {
scope,
origin: location.origin,
});
const payload = await showLoginPopup({
url: popupUrl,
+1
View File
@@ -51,6 +51,7 @@
"jwt-decode": "^3.1.0",
"knex": "^0.95.1",
"luxon": "^1.25.0",
"minimatch": "^3.0.3",
"morgan": "^1.10.0",
"node-cache": "^5.1.2",
"openid-client": "^4.2.1",
@@ -22,9 +22,9 @@ import {
BackstageIdentity,
AuthProviderConfig,
} from '../../providers/types';
import { InputError } from '@backstage/errors';
import { InputError, NotAllowedError } from '@backstage/errors';
import { TokenIssuer } from '../../identity/types';
import { verifyNonce } from './helpers';
import { readState, verifyNonce } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
import { OAuthHandlers, OAuthStartRequest, OAuthRefreshRequest } from './types';
@@ -40,6 +40,7 @@ export type Options = {
cookiePath: string;
appOrigin: string;
tokenIssuer: TokenIssuer;
isOriginAllowed: (origin: string) => boolean;
};
export class OAuthAdapter implements AuthProviderRouteHandlers {
@@ -61,6 +62,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
cookieDomain: url.hostname,
cookiePath,
secure,
isOriginAllowed: config.isOriginAllowed,
});
}
@@ -73,6 +75,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
// retrieve scopes from request
const scope = req.query.scope?.toString() ?? '';
const env = req.query.env?.toString();
const origin = req.query.origin?.toString();
if (!env) {
throw new InputError('No env provided in request query parameters');
@@ -86,7 +89,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
// set a nonce cookie before redirecting to oauth provider
this.setNonceCookie(res, nonce);
const state = { nonce: nonce, env: env };
const state = { nonce, env, origin };
const forwardReq = Object.assign(req, { scope, state });
const { url, status } = await this.handlers.start(
@@ -103,7 +106,22 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
req: express.Request,
res: express.Response,
): Promise<void> {
let appOrigin = this.options.appOrigin;
try {
const state: any = readState(req.query.state?.toString() ?? '');
if (state.origin) {
try {
appOrigin = new URL(state.origin).origin;
} catch {
throw new NotAllowedError('App origin is invalid, failed to parse');
}
if (!this.options.isOriginAllowed(appOrigin)) {
throw new NotAllowedError(`Origin '${appOrigin}' is not allowed`);
}
}
// verify nonce cookie and state cookie on callback
verifyNonce(req, this.options.providerId);
@@ -129,13 +147,13 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
await this.populateIdentity(response.backstageIdentity);
// post message back to popup if successful
return postMessageResponse(res, this.options.appOrigin, {
return postMessageResponse(res, appOrigin, {
type: 'authorization_response',
response,
});
} catch (error) {
// post error message back to popup if failure
return postMessageResponse(res, this.options.appOrigin, {
return postMessageResponse(res, appOrigin, {
type: 'authorization_response',
error: {
name: error.name,
+4 -1
View File
@@ -77,6 +77,7 @@ export type OAuthState = {
*/
nonce: string;
env: string;
origin?: string;
};
export type OAuthStartRequest = express.Request<{}> & {
@@ -106,7 +107,9 @@ export interface OAuthHandlers {
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
*/
handler(req: express.Request): Promise<{
handler(
req: express.Request,
): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
@@ -34,6 +34,11 @@ export type AuthProviderConfig = {
* The base URL of the app as provided by app.baseUrl
*/
appUrl: string;
/**
* A function that is called to check whether an origin other than the apps default one is allowed.
*/
isOriginAllowed: (origin: string) => boolean;
};
export type RedirectInfo = {
+19 -1
View File
@@ -32,6 +32,7 @@ import { Config } from '@backstage/config';
import { createOidcRouter, DatabaseKeyStore, TokenFactory } from '../identity';
import session from 'express-session';
import passport from 'passport';
import { Minimatch } from 'minimatch';
type ProviderFactories = { [s: string]: AuthProviderFactory };
@@ -88,6 +89,8 @@ export async function createRouter({
const providersConfig = config.getConfig('auth.providers');
const configuredProviders = providersConfig.keys();
const isOriginAllowed = createOriginFilter(config);
for (const [providerId, providerFactory] of Object.entries(
allProviderFactories,
)) {
@@ -96,7 +99,7 @@ export async function createRouter({
try {
const provider = providerFactory({
providerId,
globalConfig: { baseUrl: authUrl, appUrl },
globalConfig: { baseUrl: authUrl, appUrl, isOriginAllowed },
config: providersConfig.getConfig(providerId),
logger,
tokenIssuer,
@@ -158,3 +161,18 @@ export async function createRouter({
return router;
}
function createOriginFilter(config: Config): (origin: string) => boolean {
const allowedOrigins = config.getOptionalStringArray('auth.allowedOrigins');
if (!allowedOrigins || allowedOrigins.length === 0) {
return () => false;
}
const allowedOriginPatterns = allowedOrigins.map(
pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }),
);
return origin => {
return allowedOriginPatterns.some(pattern => pattern.match(origin));
};
}