diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 5f297b16d5..31b3cd22fc 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -1,14 +1,30 @@ { "name": "@backstage/core-app-api", - "description": "Core app API used by Backstage apps", "version": "1.14.0", + "description": "Core app API used by Backstage apps", + "backstage": { + "role": "web-library" + }, "publishConfig": { "access": "public" }, + "keywords": [ + "backstage" + ], + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/core-app-api" + }, + "license": "Apache-2.0", + "sideEffects": false, "exports": { ".": "./src/index.ts", "./package.json": "./package.json" }, + "main": "src/index.ts", + "types": "src/index.ts", "typesVersions": { "*": { "package.json": [ @@ -16,34 +32,23 @@ ] } }, - "backstage": { - "role": "web-library" - }, - "homepage": "https://backstage.io", - "repository": { - "type": "git", - "url": "https://github.com/backstage/backstage", - "directory": "packages/core-app-api" - }, - "keywords": [ - "backstage" + "files": [ + "dist", + "config.d.ts" ], - "license": "Apache-2.0", - "main": "src/index.ts", - "types": "src/index.ts", - "sideEffects": false, "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/config": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "@types/prop-types": "^15.7.3", @@ -56,11 +61,6 @@ "zen-observable": "^0.10.0", "zod": "^3.22.4" }, - "peerDependencies": { - "react": "^16.13.1 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0", - "react-router-dom": "6.0.0-beta.0 || ^6.3.0" - }, "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/test-utils": "workspace:^", @@ -76,9 +76,10 @@ "react-router-dom-stable": "npm:react-router-dom@^6.3.0", "react-router-stable": "npm:react-router@^6.3.0" }, - "files": [ - "dist", - "config.d.ts" - ], + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-router-dom": "6.0.0-beta.0 || ^6.3.0" + }, "configSchema": "config.d.ts" } diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts index c57c65d830..049022702e 100644 --- a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -175,6 +175,10 @@ export default class MicrosoftAuth { return this.microsoftGraph().signOut(); } + getSignInAuthError() { + return this.microsoftGraph().getSignInAuthError(); + } + sessionState$() { return this.microsoftGraph().sessionState$(); } diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts index dbad031652..1ac5199b8f 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -31,10 +31,12 @@ import { SessionApi, BackstageIdentityApi, BackstageUserIdentity, + DiscoveryApi, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { OAuth2Session } from './types'; import { OAuthApiCreateOptions } from '../types'; +import { getSignInAuthError } from '../../../../lib'; /** * OAuth2 create options. @@ -152,18 +154,21 @@ export default class OAuth2 }, }); - return new OAuth2({ sessionManager, scopeTransform }); + return new OAuth2({ sessionManager, scopeTransform, discoveryApi }); } private readonly sessionManager: SessionManager; private readonly scopeTransform: (scopes: string[]) => string[]; + private readonly discoveryApi: DiscoveryApi; private constructor(options: { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; + discoveryApi: DiscoveryApi; }) { this.sessionManager = options.sessionManager; this.scopeTransform = options.scopeTransform; + this.discoveryApi = options.discoveryApi; } async signIn() { @@ -224,4 +229,8 @@ export default class OAuth2 return new Set(scopeTransform(scopeList)); } + + async getSignInAuthError() { + return getSignInAuthError(this.discoveryApi); + } } diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts index dad62f1d06..325be93c4c 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -22,6 +22,7 @@ import { SessionApi, SessionState, BackstageIdentityResponse, + DiscoveryApi, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { DirectAuthConnector } from '../../../../lib/AuthConnector'; @@ -32,6 +33,7 @@ import { import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { AuthApiCreateOptions } from '../types'; import { SamlSession, samlSessionSchema } from './types'; +import { getSignInAuthError } from '../../../../lib'; export type SamlAuthResponse = { profile: ProfileInfo; @@ -75,7 +77,7 @@ export default class SamlAuth schema: samlSessionSchema, }); - return new SamlAuth(authSessionStore); + return new SamlAuth(authSessionStore, discoveryApi); } sessionState$(): Observable { @@ -84,6 +86,7 @@ export default class SamlAuth private constructor( private readonly sessionManager: SessionManager, + private readonly discoveryApi: DiscoveryApi, ) {} async signIn() { @@ -104,4 +107,8 @@ export default class SamlAuth const session = await this.sessionManager.getSession(options); return session?.profile; } + + async getSignInAuthError() { + return getSignInAuthError(this.discoveryApi); + } } diff --git a/packages/core-app-api/src/lib/index.ts b/packages/core-app-api/src/lib/index.ts index 1327aab4c2..d5aa2b9e3b 100644 --- a/packages/core-app-api/src/lib/index.ts +++ b/packages/core-app-api/src/lib/index.ts @@ -16,5 +16,6 @@ export * from './subjects'; export * from './loginPopup'; +export * from './signInAuthError'; export * from './AuthConnector'; export * from './AuthSessionManager'; diff --git a/packages/core-app-api/src/lib/signInAuthError.ts b/packages/core-app-api/src/lib/signInAuthError.ts new file mode 100644 index 0000000000..a86a55ee4c --- /dev/null +++ b/packages/core-app-api/src/lib/signInAuthError.ts @@ -0,0 +1,29 @@ +/* + * 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 { DiscoveryApi } from '@backstage/core-plugin-api'; +import { deserializeError } from '@backstage/errors'; + +export async function getSignInAuthError( + discoveryApi: DiscoveryApi, +): Promise { + const baseUrl = await discoveryApi.getBaseUrl('auth'); + const response = await fetch(`${baseUrl}/.backstage/error`, { + credentials: 'include', + }); + const data = await response.json(); + + return data ? deserializeError(data) : undefined; +} diff --git a/packages/core-components/src/layout/SignInPage/SignInPage.tsx b/packages/core-components/src/layout/SignInPage/SignInPage.tsx index 95d96b701a..207d7e664e 100644 --- a/packages/core-components/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core-components/src/layout/SignInPage/SignInPage.tsx @@ -25,7 +25,7 @@ import Button from '@material-ui/core/Button'; import Grid from '@material-ui/core/Grid'; import Typography from '@material-ui/core/Typography'; import React, { useState } from 'react'; -import { useMountEffect } from '@react-hookz/web'; +import { useAsync, useMountEffect } from '@react-hookz/web'; import { Progress } from '../../components/Progress'; import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; @@ -37,6 +37,7 @@ import { GridItem, useStyles } from './styles'; import { IdentityProviders, SignInProviderConfig } from './types'; import { coreComponentsTranslationRef } from '../../translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { useSearchParams } from 'react-router-dom'; type MultiSignInPageProps = SignInPageProps & { providers: IdentityProviders; @@ -106,6 +107,8 @@ export const SingleSignInPage = ({ // displayed for a split second when the user is already logged-in. const [showLoginPage, setShowLoginPage] = useState(false); + const [searchParams, _setSearchParams] = useSearchParams(); + type LoginOpts = { checkExisting?: boolean; showPopup?: boolean }; const login = async ({ checkExisting, showPopup }: LoginOpts) => { try { @@ -152,7 +155,19 @@ export const SingleSignInPage = ({ } }; - useMountEffect(() => login({ checkExisting: true })); + const [_state, actions] = useAsync(async () => { + if (searchParams.get('error') !== 'false') { + const errorResponse = await authApi.getSignInAuthError(); + if (errorResponse) { + setError(errorResponse); + } + } + }); + + useMountEffect(() => { + actions.execute(); + login({ checkExisting: true }); + }); return showLoginPage ? ( diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index d89544cf68..c29d965890 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -291,6 +291,11 @@ export type SessionApi = { */ signOut(): Promise; + /** + * Get any caught errors during the auth redirect flow + */ + getSignInAuthError(): Promise; + /** * Observe the current state of the auth session. Emits the current state on subscription. */ diff --git a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts new file mode 100644 index 0000000000..9b1d20431d --- /dev/null +++ b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts @@ -0,0 +1,44 @@ +/* + * 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 Router from 'express-promise-router'; + +const AUTH_ERROR_COOKIE = 'auth-error'; + +/** + * @public + * Creates a middleware that can be used to get auth errors in redirect flow + */ +export function createCookieAuthErrorMiddleware(authUrl: string) { + const router = Router(); + + router.get('/.backstage/error', async (req, res) => { + const error = req.cookies[AUTH_ERROR_COOKIE]; + if (error) { + const { hostname: domain } = new URL(authUrl); + + res.clearCookie('auth-error', { + path: '/api/auth/.backstage/error', + domain, + }); + res.status(200).json(error); + } else { + res.status(404); + } + }); + + return router; +} diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 4bb42c3abd..0f6e7d201a 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -48,6 +48,7 @@ import { StaticTokenIssuer } from '../identity/StaticTokenIssuer'; import { StaticKeyStore } from '../identity/StaticKeyStore'; import { Config } from '@backstage/config'; import { bindProviderRouters, ProviderFactories } from '../providers/router'; +import { createCookieAuthErrorMiddleware } from './createCookieAuthErrorMiddleware'; /** @public */ export interface RouterOptions { @@ -170,10 +171,15 @@ export async function createRouter( }); // Gives a more helpful error message than a plain 404 - router.use('/:provider/', req => { + router.use('/:provider/', (req, _, next) => { const { provider } = req.params; + if (provider.startsWith('.backstage')) { + return next('route'); + } throw new NotFoundError(`Unknown auth provider '${provider}'`); }); + router.use(createCookieAuthErrorMiddleware(authUrl)); + return router; } diff --git a/plugins/auth-node/src/oauth/createAuthErrorCookie.ts b/plugins/auth-node/src/oauth/createAuthErrorCookie.ts new file mode 100644 index 0000000000..05b1c99cee --- /dev/null +++ b/plugins/auth-node/src/oauth/createAuthErrorCookie.ts @@ -0,0 +1,56 @@ +/* + * 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 { Response } from 'express'; +import { CookieConfigurer } from '../types'; +import { serializeError } from '@backstage/errors'; + +const ONE_MINUTE_MS = 60 * 1000; + +const AUTH_ERROR_COOKIE = 'auth-error'; + +function configureAuthErrorCookie(redirectUrl: string, appOrigin: string) { + const { hostname: domain, pathname: path, protocol } = new URL(redirectUrl); + const secure = protocol === 'https:'; + + // For situations where the auth-backend is running on a + // different domain than the app, we set the SameSite attribute + // to 'none' to allow third-party access to the cookie, but + // only if it's in a secure context (https). + let sameSite: ReturnType['sameSite'] = 'lax'; + if (new URL(appOrigin).hostname !== domain && secure) { + sameSite = 'none'; + } + + return { domain, path, secure, sameSite }; +} + +export function createAuthErrorCookie( + res: Response, + origin: string, + options: { + error: Error; + redirectUrl: string; + }, +) { + const { error, redirectUrl } = options; + const jsonData = serializeError(error); + + res.cookie(AUTH_ERROR_COOKIE, jsonData, { + maxAge: ONE_MINUTE_MS, + httpOnly: true, + ...configureAuthErrorCookie(redirectUrl, origin), + }); +} diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index 82bfa2f02c..649c094dba 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -42,6 +42,7 @@ import { import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types'; import { Config } from '@backstage/config'; import { CookieScopeManager } from './CookieScopeManager'; +import { createAuthErrorCookie } from './createAuthErrorCookie'; /** @public */ export interface OAuthRouteHandlersOptions { @@ -164,9 +165,10 @@ export function createOAuthRouteHandlers( res: express.Response, ): Promise { let origin = defaultAppOrigin; + let state; try { - const state = decodeOAuthState(req.query.state?.toString() ?? ''); + state = decodeOAuthState(req.query.state?.toString() ?? ''); if (state.origin) { try { @@ -248,11 +250,25 @@ export function createOAuthRouteHandlers( const { name, message } = isError(error) ? error : new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value - // post error message back to popup if failure - sendWebMessageResponse(res, origin, { - type: 'authorization_response', - error: { name, message }, - }); + + if (state?.flow === 'redirect' && state?.redirectUrl) { + createAuthErrorCookie(res, state?.redirectUrl, { + error: { name, message }, + redirectUrl: `${baseUrl}/.backstage/error`, + }); + + const redirectUrl = new URL(state.redirectUrl); + redirectUrl.searchParams.set('error', 'true'); + + // set the error in a cookie and redirect user back to sign in where the error can be rendered + res.redirect(redirectUrl.toString()); + } else { + // post error message back to popup if failure + sendWebMessageResponse(res, origin, { + type: 'authorization_response', + error: { name, message }, + }); + } } },