fix errors with auth redirect flow
Signed-off-by: Stephen Glass <stephen@stephen.glass>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user