diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts index d707acdc9b..88156fedad 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -61,8 +61,7 @@ function defaultJoinScopes(scopes: Set) { * via the OAuthRequestApi. */ export class DefaultAuthConnector - implements AuthConnector -{ + implements AuthConnector { private readonly discoveryApi: DiscoveryApi; private readonly environment: string; private readonly provider: AuthProvider & { id: string }; @@ -152,7 +151,10 @@ export class DefaultAuthConnector private async showPopup(scopes: Set): Promise { 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, diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index c9219ddfae..4ea799c37c 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -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", diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index d9a49e17e8..6397c09233 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -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 { + 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, diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index c5ece2b921..989b76c0a4 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -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; refreshToken?: string; }>; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index c838005c00..66928ab4a7 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -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 = { diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 8173efcbc1..b314f2fd2f 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -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)); + }; +}