fix errors with auth redirect flow

Signed-off-by: Stephen Glass <stephen@stephen.glass>
This commit is contained in:
Stephen Glass
2024-07-28 03:15:00 -04:00
parent 16cc26c0a2
commit 8542af998a
12 changed files with 232 additions and 39 deletions
+29 -28
View File
@@ -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"
}
@@ -175,6 +175,10 @@ export default class MicrosoftAuth {
return this.microsoftGraph().signOut();
}
getSignInAuthError() {
return this.microsoftGraph().getSignInAuthError();
}
sessionState$() {
return this.microsoftGraph().sessionState$();
}
@@ -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<OAuth2Session>;
private readonly scopeTransform: (scopes: string[]) => string[];
private readonly discoveryApi: DiscoveryApi;
private constructor(options: {
sessionManager: SessionManager<OAuth2Session>;
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);
}
}
@@ -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<SessionState> {
@@ -84,6 +86,7 @@ export default class SamlAuth
private constructor(
private readonly sessionManager: SessionManager<SamlSession>,
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);
}
}
+1
View File
@@ -16,5 +16,6 @@
export * from './subjects';
export * from './loginPopup';
export * from './signInAuthError';
export * from './AuthConnector';
export * from './AuthSessionManager';
@@ -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<Error | undefined> {
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;
}
@@ -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<boolean>(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 ? (
<Page themeId="home">
@@ -291,6 +291,11 @@ export type SessionApi = {
*/
signOut(): Promise<void>;
/**
* Get any caught errors during the auth redirect flow
*/
getSignInAuthError(): Promise<Error | undefined>;
/**
* Observe the current state of the auth session. Emits the current state on subscription.
*/
@@ -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;
}
+7 -1
View File
@@ -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;
}
@@ -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<CookieConfigurer>['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),
});
}
@@ -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<TProfile> {
@@ -164,9 +165,10 @@ export function createOAuthRouteHandlers<TProfile>(
res: express.Response,
): Promise<void> {
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<TProfile>(
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 },
});
}
}
},