change api auth utility to hook

Signed-off-by: Stephen Glass <stephen@stephen.glass>
This commit is contained in:
Stephen Glass
2024-08-13 23:43:41 -04:00
parent 32fe26a57f
commit d8e6b69bae
15 changed files with 86 additions and 155 deletions
@@ -35,7 +35,6 @@ import {
createFetchApi,
FetchMiddlewares,
VMwareCloudAuth,
SignInAuthErrorApi,
} from '@backstage/core-app-api';
import {
@@ -59,7 +58,6 @@ import {
bitbucketServerAuthApiRef,
atlassianAuthApiRef,
vmwareCloudAuthApiRef,
authErrorApiRef,
} from '@backstage/core-plugin-api';
import {
permissionApiRef,
@@ -289,11 +287,4 @@ export const apis = [
factory: ({ config, discovery, identity }) =>
IdentityPermissionApi.create({ config, discovery, identity }),
}),
createApiFactory({
api: authErrorApiRef,
deps: {
discovery: discoveryApiRef,
},
factory: ({ discovery }) => SignInAuthErrorApi.create({ discovery }),
}),
];
-9
View File
@@ -16,7 +16,6 @@ import { AppConfig } from '@backstage/config';
import { AppTheme } from '@backstage/core-plugin-api';
import { AppThemeApi } from '@backstage/core-plugin-api';
import { atlassianAuthApiRef } from '@backstage/core-plugin-api';
import { AuthErrorApi } from '@backstage/core-plugin-api';
import { AuthProviderInfo } from '@backstage/core-plugin-api';
import { AuthRequestOptions } from '@backstage/core-plugin-api';
import { BackstageIdentityApi } from '@backstage/core-plugin-api';
@@ -636,14 +635,6 @@ export class SamlAuth
signOut(): Promise<void>;
}
// @public
export class SignInAuthErrorApi implements AuthErrorApi {
// (undocumented)
static create(options: { discovery: DiscoveryApi }): SignInAuthErrorApi;
// (undocumented)
getSignInAuthError(): Promise<Error | undefined>;
}
// @public
export type SignInPageProps = PropsWithChildren<{
onSignInSuccess(identityApi: IdentityApi): void;
-1
View File
@@ -44,7 +44,6 @@
"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",
@@ -30,4 +30,3 @@ export * from './FeatureFlagsApi';
export * from './FetchApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
export * from './AuthErrorApi';
+1
View File
@@ -57,6 +57,7 @@
"@backstage/config": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@date-io/core": "^1.3.13",
@@ -19,14 +19,13 @@ import {
configApiRef,
SignInPageProps,
useApi,
authErrorApiRef,
} from '@backstage/core-plugin-api';
import { UserIdentity } from './UserIdentity';
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 { useAsync, useMountEffect } from '@react-hookz/web';
import React, { useEffect, useState } from 'react';
import { useMountEffect } from '@react-hookz/web';
import { Progress } from '../../components/Progress';
import { Content } from '../Content/Content';
import { ContentHeader } from '../ContentHeader/ContentHeader';
@@ -39,6 +38,7 @@ import { IdentityProviders, SignInProviderConfig } from './types';
import { coreComponentsTranslationRef } from '../../translation';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { useSearchParams } from 'react-router-dom';
import { useSignInAuthError } from '@backstage/plugin-auth-react';
type MultiSignInPageProps = SignInPageProps & {
providers: IdentityProviders;
@@ -99,7 +99,7 @@ export const SingleSignInPage = ({
const classes = useStyles();
const authApi = useApi(provider.apiRef);
const configApi = useApi(configApiRef);
const authErrorApi = useApi(authErrorApiRef);
const { error: signInError, checkAuthError } = useSignInAuthError();
const { t } = useTranslationRef(coreComponentsTranslationRef);
const [error, setError] = useState<Error>();
@@ -160,20 +160,19 @@ export const SingleSignInPage = ({
}
};
const [_, { execute: checkAuthErrors }] = useAsync(async () => {
if (hasErrorSearchParam) {
const errorResponse = await authErrorApi.getSignInAuthError();
if (errorResponse) {
setError(errorResponse);
}
}
});
useMountEffect(() => {
checkAuthErrors();
if (hasErrorSearchParam) {
checkAuthError();
}
login({ checkExisting: true });
});
useEffect(() => {
if (signInError) {
setError(signInError);
}
}, [signInError]);
return showLoginPage ? (
<Page themeId="home">
<Header title={configApi.getString('app.title')} />
@@ -14,14 +14,19 @@
* limitations under the License.
*/
import React, { useLayoutEffect, useState, useMemo, useCallback } from 'react';
import React, {
useLayoutEffect,
useState,
useMemo,
useCallback,
useEffect,
} from 'react';
import {
SignInPageProps,
useApi,
useApiHolder,
errorApiRef,
IdentityApi,
authErrorApiRef,
} from '@backstage/core-plugin-api';
import {
IdentityProviders,
@@ -33,7 +38,8 @@ import { guestProvider } from './guestProvider';
import { customProvider } from './customProvider';
import { IdentityApiSignOutProxy } from './IdentityApiSignOutProxy';
import { useSearchParams } from 'react-router-dom';
import { useMountEffect, useAsync } from '@react-hookz/web';
import { useMountEffect } from '@react-hookz/web';
import { useSignInAuthError } from '@backstage/plugin-auth-react';
const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider';
@@ -89,7 +95,7 @@ export const useSignInProviders = (
) => {
const errorApi = useApi(errorApiRef);
const apiHolder = useApiHolder();
const authErrorApi = useApi(authErrorApiRef);
const { error: signInError, checkAuthError } = useSignInAuthError();
const [loading, setLoading] = useState(true);
// User was redirected back to sign in page with error from auth redirect flow
@@ -97,16 +103,13 @@ export const useSignInProviders = (
const errorParam = searchParams.get('error');
const hasErrorSearchParam = errorParam !== 'false' && errorParam !== null;
const [_, { execute: checkAuthErrors }] = useAsync(async () => {
if (hasErrorSearchParam) {
const errorResponse = await authErrorApi.getSignInAuthError();
if (errorResponse) {
errorApi.post(errorResponse);
}
}
});
useMountEffect(() => hasErrorSearchParam && checkAuthError());
useMountEffect(checkAuthErrors);
useEffect(() => {
if (signInError) {
errorApi.post(signInError);
}
}, [errorApi, signInError]);
// This decorates the result with sign out logic from this hook
const handleWrappedResult = useCallback(
-8
View File
@@ -194,14 +194,6 @@ export function attachComponentData<P>(
data: unknown,
): void;
// @public
export type AuthErrorApi = {
getSignInAuthError(): Promise<Error | undefined>;
};
// @public
export const authErrorApiRef: ApiRef<AuthErrorApi>;
// @public
export type AuthProviderInfo = {
id: string;
@@ -1,45 +0,0 @@
/*
* 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 { ApiRef, createApiRef } from '../system';
/**
* A wrapper for retrieving any errors caught in authentication redirect flow
*
* @public
*/
export type AuthErrorApi = {
/**
* Get any caught errors during the auth redirect flow
*/
getSignInAuthError(): Promise<Error | undefined>;
};
/**
* The {@link ApiRef} of {@link AuthErrorApi}.
*
* @remarks
*
* This is a wrapper that uses fetch to retrieve any caught errors
* in the authentication redirect flow process. This API calls a
* Backstage endpoint to read any error stored in authentication error
* cookie and returns it to the user.
*
* @public
*/
export const authErrorApiRef: ApiRef<AuthErrorApi> = createApiRef({
id: 'core.auth-error',
});
@@ -33,4 +33,3 @@ export * from './FetchApi';
export * from './IdentityApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
export * from './AuthErrorApi';
+1
View File
@@ -18,3 +18,4 @@
// which hooks are public API and should be exported from the package.
export * from './useCookieAuthRefresh';
export * from './useSignInAuthError';
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { SignInAuthErrorApi } from './SignInAuthErrorApi';
export { useSignInAuthError } from './useSignInAuthError';
@@ -14,29 +14,20 @@
* limitations under the License.
*/
import { DiscoveryApi } from '@backstage/core-plugin-api';
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { discoveryApiRef } from '@backstage/core-plugin-api';
import { TestApiProvider } from '@backstage/test-utils';
import { useSignInAuthError } from './useSignInAuthError';
import { serializeError } from '@backstage/errors';
import { SignInAuthErrorApi } from './SignInAuthErrorApi';
describe('SignInAuthErrorApi', () => {
const mockDiscoveryApi = {
getBaseUrl: jest.fn(),
} as unknown as jest.Mocked<DiscoveryApi>;
describe('useCookieAuthRefresh', () => {
const discoveryApiMock = {
getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/api/auth'),
};
beforeEach(() => {
jest.resetAllMocks();
});
it('should create an instance of SignInAuthErrorApi', async () => {
const api = SignInAuthErrorApi.create({
discovery: mockDiscoveryApi,
});
mockDiscoveryApi.getBaseUrl.mockResolvedValue(
'http://localhost:7007/api/auth',
);
expect(api).toBeInstanceOf(SignInAuthErrorApi);
jest.clearAllMocks();
});
it('should return an error when the cookie returns an error object', async () => {
@@ -45,50 +36,63 @@ describe('SignInAuthErrorApi', () => {
message: 'This is a test error',
};
const serializedError = serializeError(errorObject);
mockDiscoveryApi.getBaseUrl.mockResolvedValue(
'http://localhost:7000/api/auth',
);
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(serializedError),
} as unknown as Response);
const api = SignInAuthErrorApi.create({ discovery: mockDiscoveryApi });
const error = await api.getSignInAuthError();
const { result } = renderHook(() => useSignInAuthError(), {
wrapper: ({ children }) => (
<TestApiProvider apis={[[discoveryApiRef, discoveryApiMock]]}>
{children}
</TestApiProvider>
),
});
expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('auth');
await act(async () => {
result.current.checkAuthError();
});
expect(discoveryApiMock.getBaseUrl).toHaveBeenCalledWith('auth');
expect(fetch).toHaveBeenCalledWith(
'http://localhost:7000/api/auth/.backstage/error',
{
credentials: 'include',
},
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).name).toEqual(errorObject.name);
expect((error as Error).message).toEqual(errorObject.message);
expect(result.current.error).toBeInstanceOf(Error);
expect((result.current.error as Error).name).toEqual(errorObject.name);
expect((result.current.error as Error).message).toEqual(
errorObject.message,
);
});
it('should return undefined when the backend does not return an error object', async () => {
mockDiscoveryApi.getBaseUrl.mockResolvedValue(
'http://localhost:7000/api/auth',
);
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(undefined),
} as unknown as Response);
const api = SignInAuthErrorApi.create({ discovery: mockDiscoveryApi });
const error = await api.getSignInAuthError();
const { result } = renderHook(() => useSignInAuthError(), {
wrapper: ({ children }) => (
<TestApiProvider apis={[[discoveryApiRef, discoveryApiMock]]}>
{children}
</TestApiProvider>
),
});
expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('auth');
await act(async () => {
result.current.checkAuthError();
});
expect(discoveryApiMock.getBaseUrl).toHaveBeenCalledWith('auth');
expect(fetch).toHaveBeenCalledWith(
'http://localhost:7000/api/auth/.backstage/error',
{
credentials: 'include',
},
);
expect(error).toBeUndefined();
expect(result.current.error).toBeUndefined();
});
});
@@ -14,25 +14,20 @@
* limitations under the License.
*/
import { DiscoveryApi, AuthErrorApi } from '@backstage/core-plugin-api';
import { discoveryApiRef, useApi } from '@backstage/core-plugin-api';
import { useAsync } from '@react-hookz/web';
import { deserializeError } from '@backstage/errors';
/**
* The default implementation of the AuthErrorApi, which simply calls auth error endpoint.
* @public
*/
export class SignInAuthErrorApi implements AuthErrorApi {
private constructor(private readonly discoveryApi: DiscoveryApi) {}
export function useSignInAuthError(): {
error: Error | undefined;
checkAuthError: () => void;
} {
const discoveryApi = useApi(discoveryApiRef);
static create(options: { discovery: DiscoveryApi }) {
const { discovery } = options;
return new SignInAuthErrorApi(discovery);
}
const [state, { execute: checkAuthError }] = useAsync(async () => {
const baseUrl = await discoveryApi.getBaseUrl('auth');
async getSignInAuthError(): Promise<Error | undefined> {
const baseUrl = await this.discoveryApi.getBaseUrl('auth');
// use native fetch instead of depending on fetchApi because
// use native fetch instead of fetchApi because
// we are not signed in and are calling an unauthenticated endpoint
const response = await fetch(`${baseUrl}/.backstage/error`, {
credentials: 'include',
@@ -40,5 +35,7 @@ export class SignInAuthErrorApi implements AuthErrorApi {
const data = await response.json();
return data ? deserializeError(data) : undefined;
}
});
return { error: state.result, checkAuthError };
}
+1 -1
View File
@@ -4123,7 +4123,6 @@ __metadata:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
@@ -4288,6 +4287,7 @@ __metadata:
"@backstage/core-app-api": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/version-bridge": "workspace:^"