From ce753c36d91c91c67b39ef06cdfbe4ab9b2ac731 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 2 Dec 2024 13:21:11 +0000 Subject: [PATCH] remove(auth-react): Remove auth-react library in favour of bff --- libs/auth/react/.babelrc | 4 - libs/auth/react/.eslintrc.json | 10 - libs/auth/react/README.md | 106 ------ libs/auth/react/babel-jest.config.json | 15 - libs/auth/react/jest.config.ts | 16 - libs/auth/react/project.json | 19 - libs/auth/react/src/index.ts | 14 - libs/auth/react/src/lib/AuthSettings.spec.ts | 61 ---- libs/auth/react/src/lib/AuthSettings.ts | 84 ----- libs/auth/react/src/lib/auth/Auth.css.ts | 5 - libs/auth/react/src/lib/auth/Auth.state.ts | 86 ----- libs/auth/react/src/lib/auth/AuthContext.tsx | 64 ---- .../react/src/lib/auth/AuthErrorScreen.tsx | 38 -- .../src/lib/auth/AuthProvider.mocked.spec.tsx | 44 --- .../react/src/lib/auth/AuthProvider.spec.tsx | 241 ------------- libs/auth/react/src/lib/auth/AuthProvider.tsx | 333 ------------------ .../react/src/lib/auth/CheckIdpSession.tsx | 189 ---------- .../src/lib/auth/MockedAuthProvider.spec.tsx | 39 -- .../react/src/lib/auth/MockedAuthProvider.tsx | 27 -- libs/auth/react/src/lib/authLink.ts | 12 - libs/auth/react/src/lib/createMockUser.ts | 19 - .../auth/react/src/lib/getAccessToken.spec.ts | 152 -------- libs/auth/react/src/lib/getAccessToken.ts | 44 --- libs/auth/react/src/lib/userManager.ts | 64 ---- .../react/src/lib/utils/tinyMemoize.spec.ts | 57 --- libs/auth/react/src/lib/utils/tinyMemoize.ts | 17 - .../react/src/lib/utils/toStringScope.spec.ts | 12 - .../auth/react/src/lib/utils/toStringScope.ts | 2 - libs/auth/react/test/setup.ts | 1 - libs/auth/react/tsconfig.json | 17 - libs/auth/react/tsconfig.lib.json | 19 - libs/auth/react/tsconfig.spec.json | 20 -- libs/island-ui/storybook/config/main.ts | 1 - tsconfig.base.json | 1 - 34 files changed, 1833 deletions(-) delete mode 100644 libs/auth/react/.babelrc delete mode 100644 libs/auth/react/.eslintrc.json delete mode 100644 libs/auth/react/README.md delete mode 100644 libs/auth/react/babel-jest.config.json delete mode 100644 libs/auth/react/jest.config.ts delete mode 100644 libs/auth/react/project.json delete mode 100644 libs/auth/react/src/index.ts delete mode 100644 libs/auth/react/src/lib/AuthSettings.spec.ts delete mode 100644 libs/auth/react/src/lib/AuthSettings.ts delete mode 100644 libs/auth/react/src/lib/auth/Auth.css.ts delete mode 100644 libs/auth/react/src/lib/auth/Auth.state.ts delete mode 100644 libs/auth/react/src/lib/auth/AuthContext.tsx delete mode 100644 libs/auth/react/src/lib/auth/AuthErrorScreen.tsx delete mode 100644 libs/auth/react/src/lib/auth/AuthProvider.mocked.spec.tsx delete mode 100644 libs/auth/react/src/lib/auth/AuthProvider.spec.tsx delete mode 100644 libs/auth/react/src/lib/auth/AuthProvider.tsx delete mode 100644 libs/auth/react/src/lib/auth/CheckIdpSession.tsx delete mode 100644 libs/auth/react/src/lib/auth/MockedAuthProvider.spec.tsx delete mode 100644 libs/auth/react/src/lib/auth/MockedAuthProvider.tsx delete mode 100644 libs/auth/react/src/lib/authLink.ts delete mode 100644 libs/auth/react/src/lib/createMockUser.ts delete mode 100644 libs/auth/react/src/lib/getAccessToken.spec.ts delete mode 100644 libs/auth/react/src/lib/getAccessToken.ts delete mode 100644 libs/auth/react/src/lib/userManager.ts delete mode 100644 libs/auth/react/src/lib/utils/tinyMemoize.spec.ts delete mode 100644 libs/auth/react/src/lib/utils/tinyMemoize.ts delete mode 100644 libs/auth/react/src/lib/utils/toStringScope.spec.ts delete mode 100644 libs/auth/react/src/lib/utils/toStringScope.ts delete mode 100644 libs/auth/react/test/setup.ts delete mode 100644 libs/auth/react/tsconfig.json delete mode 100644 libs/auth/react/tsconfig.lib.json delete mode 100644 libs/auth/react/tsconfig.spec.json diff --git a/libs/auth/react/.babelrc b/libs/auth/react/.babelrc deleted file mode 100644 index e05c199e361e..000000000000 --- a/libs/auth/react/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "presets": ["@nx/react/babel"], - "plugins": [] -} diff --git a/libs/auth/react/.eslintrc.json b/libs/auth/react/.eslintrc.json deleted file mode 100644 index 4f027ee445be..000000000000 --- a/libs/auth/react/.eslintrc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], - "ignorePatterns": ["!**/*"], - "rules": {}, - "overrides": [ - { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": {} }, - { "files": ["*.ts", "*.tsx"], "rules": {} }, - { "files": ["*.js", "*.jsx"], "rules": {} } - ] -} diff --git a/libs/auth/react/README.md b/libs/auth/react/README.md deleted file mode 100644 index 039c3d156ae7..000000000000 --- a/libs/auth/react/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# @island.is/auth/react - -Manage authentication in React (non-next) single page applications. - -- Handles oidc-client and callback routes. -- Handles authentication flow with loading screen. -- Manages user context. -- Renews access tokens on demand (when calling APIs) instead of continuously. This helps us support an (eg) 1-8 hour IDS session, depending on how long the user is active. -- Preloads a new access token some time before it expires (when calling APIs). -- Monitor the IDS session and restart login flow if the user is not logged in anymore. - -## Usage - -### Configure - -In the startup of your app (e.g. `Main.tsx`) you need to configure some authentication parameters: - -```typescript -import { configure } from '@island.is/auth/react' -import { environment } from './environments' - -configure({ - // You should usually configure these: - authority: environment.identityServer.authority, - client_id: '@island.is/web', - scope: [ - 'openid', - 'profile', - 'api_resource.scope', - '@island.is/applications:read', - ], - // These can be overridden to control callback urls. - // These are the default values: - baseUrl: `${window.location.origin}`, - redirectPath: '/auth/callback', - redirectPathSilent: '/auth/callback-silent', -}) -``` - -### Authenticate - -The configure function also accepts all oidc-client UserManager settings. - -Then you can render the Authenticator component around your application to wrap it with user authentication. - -```typescript jsx -ReactDOM.render( - - - - - , - document.getElementById('root'), -) -``` - -By default, it only renders its children after signing the user in. It will render a loading screen in the meantime. - -{% hint style="info" %} -Note: Authenticator must be rendered inside React Router to set up callback routes. -{% endhint %} - -### Get access token - -You can configure authentication for your GraphQL client like this: - -```typescript -import { - ApolloClient, - InMemoryCache, - HttpLink, - ApolloLink, -} from '@apollo/client' -import { authLink } from '@island.is/auth/react' - -const httpLink = new HttpLink(/* snip */) - -export const client = new ApolloClient({ - link: ApolloLink.from([authLink, httpLink]), - cache: new InMemoryCache(), -}) -``` - -You can also manually get the access token like this: - -```typescript -import { getAccessToken } from '@island.is/auth/react' - -const accessToken = await getAccessToken() -``` - -### Token renew and IDS session - -When you call `getAccessToken` or make requests with `authLink`, we renew the access token on demand if it has expired. We also preload a new access token if you are actively requesting the access token before it expires. - -Note that if the user has been inactive, they might experience a delay when they come back and call an API, while we renew the access token. - -Every time we renew the access token, the IDS session is extended. When this is written, the IDS maintains a 1 hour session that can be extended up to 8 hours. - -Be careful not to do continuous API requests on an interval when the user might not be active. - -Later we may implement an "updateActive" function that can be called to extend the IDS session in case the user is active but not calling any APIs. - -## Running unit tests - -Run `nx test auth-react` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/auth/react/babel-jest.config.json b/libs/auth/react/babel-jest.config.json deleted file mode 100644 index f83bce0d90ea..000000000000 --- a/libs/auth/react/babel-jest.config.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "presets": [ - [ - "@babel/preset-env", - { - "targets": { - "node": "current" - } - } - ], - "@babel/preset-typescript", - "@babel/preset-react" - ], - "plugins": ["@vanilla-extract/babel-plugin"] -} diff --git a/libs/auth/react/jest.config.ts b/libs/auth/react/jest.config.ts deleted file mode 100644 index 336f7f818324..000000000000 --- a/libs/auth/react/jest.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable */ -export default { - displayName: 'auth-react', - preset: './jest.preset.js', - rootDir: '../../..', - roots: [__dirname], - setupFilesAfterEnv: [`${__dirname}/test/setup.ts`], - transform: { - '^.+\\.[tj]sx?$': [ - 'babel-jest', - { cwd: __dirname, configFile: `${__dirname}/babel-jest.config.json` }, - ], - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], - coverageDirectory: '/coverage/libs/auth/react', -} diff --git a/libs/auth/react/project.json b/libs/auth/react/project.json deleted file mode 100644 index 51a8e03a6a39..000000000000 --- a/libs/auth/react/project.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "auth-react", - "$schema": "../../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "libs/auth/react/src", - "projectType": "library", - "tags": ["lib:react-spa", "scope:react-spa"], - "targets": { - "lint": { - "executor": "@nx/eslint:lint" - }, - "test": { - "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/libs/auth/react"], - "options": { - "jestConfig": "libs/auth/react/jest.config.ts" - } - } - } -} diff --git a/libs/auth/react/src/index.ts b/libs/auth/react/src/index.ts deleted file mode 100644 index 99c01c660442..000000000000 --- a/libs/auth/react/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Components -export * from './lib/auth/AuthProvider' -export * from './lib/auth/MockedAuthProvider' -export * from './lib/auth/AuthContext' - -// Lib -export * from './lib/userManager' -export * from './lib/authLink' -export { getAccessToken } from './lib/getAccessToken' - -// Types -export type { MockUser } from './lib/createMockUser' -export type { AuthSettings } from './lib/AuthSettings' -export * from './lib/createMockUser' diff --git a/libs/auth/react/src/lib/AuthSettings.spec.ts b/libs/auth/react/src/lib/AuthSettings.spec.ts deleted file mode 100644 index a2edf3423518..000000000000 --- a/libs/auth/react/src/lib/AuthSettings.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { mergeAuthSettings } from './AuthSettings' - -describe('mergeAuthSettings', () => { - it('provides good defaults', () => { - // act - const settings = mergeAuthSettings({ - client_id: 'test-client', - authority: 'https://innskra.island.is', - }) - - // assert - expect(settings).toMatchInlineSnapshot(` - Object { - "authority": "https://innskra.island.is", - "automaticSilentRenew": false, - "baseUrl": "http://localhost", - "checkSessionPath": "/connect/sessioninfo", - "client_id": "test-client", - "loadUserInfo": true, - "mergeClaims": true, - "monitorSession": false, - "post_logout_redirect_uri": "http://localhost", - "redirectPath": "/auth/callback", - "redirectPathSilent": "/auth/callback-silent", - "response_type": "code", - "revokeTokenTypes": Array [ - "refresh_token", - ], - "revokeTokensOnSignout": true, - "silent_redirect_uri": "http://localhost/auth/callback-silent", - "userStore": WebStorageStateStore { - "_logger": Logger { - "_name": "WebStorageStateStore", - }, - "_prefix": "oidc.", - "_store": Storage {}, - }, - } - `) - }) - - it('creates uris from baseUrl and redirect paths', () => { - // act - const settings = mergeAuthSettings({ - authority: 'https://innskra.island.is', - client_id: 'test-client', - baseUrl: 'https://island.is', - redirectPath: '/auth', - redirectPathSilent: '/auth-silent', - }) - - // assert - expect(settings).toMatchObject({ - baseUrl: 'https://island.is', - post_logout_redirect_uri: 'https://island.is', - redirectPath: '/auth', - redirectPathSilent: '/auth-silent', - silent_redirect_uri: 'https://island.is/auth-silent', - }) - }) -}) diff --git a/libs/auth/react/src/lib/AuthSettings.ts b/libs/auth/react/src/lib/AuthSettings.ts deleted file mode 100644 index c35c7ee8c65a..000000000000 --- a/libs/auth/react/src/lib/AuthSettings.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { storageFactory } from '@island.is/shared/utils' -import { UserManagerSettings, WebStorageStateStore } from 'oidc-client-ts' - -export interface AuthSettings - extends Omit { - /* - * Used to create redirect uris. Should not end with slash. - * Default: window.location.origin - */ - baseUrl?: string - - /* - * Used to handle login callback and to build a default value for `redirect_uri` with baseUrl. Should be - * relative from baseUrl and start with a "/". - * Default: "/auth/callback" - */ - redirectPath?: string - - /** - * Used to handle login callback and to build a default value for `silent_redirect_uri` with baseUrl. - * Should be relative from baseUrl and start with a "/". - * Default: "/auth/callback-silent" - */ - redirectPathSilent?: string - - /** - * Used to support login flow triggered by the authorisation server or another party. Should be relative from baseUrl - * and start with a "/". - * More information: https://openid.net/specs/openid-connect-standard-1_0-21.html#client_Initiate_login - * Default: undefined - */ - initiateLoginPath?: string - - /** - * Prefix for storing user access tokens in session storage. - */ - userStorePrefix?: string - - /** - * Allow to pass the scope as an array. - */ - scope?: string[] - - /** - * Which URL to send the user to after switching users. - */ - switchUserRedirectUrl?: string - - /** - * Which PATH on the AUTHORITY to use for checking the session expiry. - */ - checkSessionPath?: string -} - -export const mergeAuthSettings = (settings: AuthSettings): AuthSettings => { - const baseUrl = settings.baseUrl ?? window.location.origin - const redirectPath = settings.redirectPath ?? '/auth/callback' - const redirectPathSilent = - settings.redirectPathSilent ?? '/auth/callback-silent' - - // Many Open ID Connect features only work when on the same domain as the IDS (with first party cookies) - const onIdsDomain = /(is|dev)land.is$/.test(window.location.origin) - - return { - baseUrl, - redirectPath, - redirectPathSilent, - automaticSilentRenew: false, - checkSessionPath: '/connect/sessioninfo', - silent_redirect_uri: `${baseUrl}${redirectPathSilent}`, - post_logout_redirect_uri: baseUrl, - response_type: 'code', - revokeTokenTypes: ['refresh_token'], - revokeTokensOnSignout: true, - loadUserInfo: true, - monitorSession: onIdsDomain, - userStore: new WebStorageStateStore({ - store: storageFactory(() => sessionStorage), - prefix: settings.userStorePrefix, - }), - mergeClaims: true, - ...settings, - } -} diff --git a/libs/auth/react/src/lib/auth/Auth.css.ts b/libs/auth/react/src/lib/auth/Auth.css.ts deleted file mode 100644 index 62d600b0be1b..000000000000 --- a/libs/auth/react/src/lib/auth/Auth.css.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { style } from '@vanilla-extract/css' - -export const fullScreen = style({ - height: '100vh', -}) diff --git a/libs/auth/react/src/lib/auth/Auth.state.ts b/libs/auth/react/src/lib/auth/Auth.state.ts deleted file mode 100644 index 74a1b1f9b785..000000000000 --- a/libs/auth/react/src/lib/auth/Auth.state.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { User } from '@island.is/shared/types' - -export type AuthState = - | 'logged-out' - | 'loading' - | 'logged-in' - | 'failed' - | 'switching' - | 'logging-out' - -export interface AuthReducerState { - userInfo: User | null - authState: AuthState - isAuthenticated: boolean -} - -export enum ActionType { - SIGNIN_START = 'SIGNIN_START', - SIGNIN_SUCCESS = 'SIGNIN_SUCCESS', - SIGNIN_FAILURE = 'SIGNIN_FAILURE', - LOGGING_OUT = 'LOGGING_OUT', - LOGGED_OUT = 'LOGGED_OUT', - USER_LOADED = 'USER_LOADED', - SWITCH_USER = 'SWITCH_USER', -} - -export interface Action { - type: ActionType - // eslint-disable-next-line @typescript-eslint/no-explicit-any - payload?: any -} - -export const initialState: AuthReducerState = { - userInfo: null, - authState: 'logged-out', - isAuthenticated: false, -} - -export const reducer = ( - state: AuthReducerState, - action: Action, -): AuthReducerState => { - switch (action.type) { - case ActionType.SIGNIN_START: - return { - ...state, - authState: 'loading', - } - case ActionType.SIGNIN_SUCCESS: - return { - ...state, - userInfo: action.payload, - - authState: 'logged-in', - isAuthenticated: true, - } - case ActionType.USER_LOADED: - return state.isAuthenticated - ? { - ...state, - userInfo: action.payload, - } - : state - case ActionType.SIGNIN_FAILURE: - return { - ...state, - authState: 'failed', - } - case ActionType.LOGGING_OUT: - return { - ...state, - authState: 'logging-out', - } - case ActionType.SWITCH_USER: - return { - ...state, - authState: 'switching', - } - case ActionType.LOGGED_OUT: - return { - ...initialState, - } - default: - return state - } -} diff --git a/libs/auth/react/src/lib/auth/AuthContext.tsx b/libs/auth/react/src/lib/auth/AuthContext.tsx deleted file mode 100644 index e6d65ea5d94a..000000000000 --- a/libs/auth/react/src/lib/auth/AuthContext.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { createContext, useContext } from 'react' - -import { AuthReducerState, initialState } from './Auth.state' - -export interface AuthContextType extends AuthReducerState { - signIn(): void - signInSilent(): void - switchUser(nationalId?: string): void - signOut(): void - authority?: string -} - -export const defaultAuthContext = { - ...initialState, - signIn() { - // Intentionally empty - }, - signInSilent() { - // Intentionally empty - }, - switchUser(_nationalId?: string) { - // Intentionally empty - }, - signOut() { - // Intentionally empty - }, -} - -export const AuthContext = createContext(defaultAuthContext) - -const warnDeprecated = (hookName: string, alternative: string) => { - console.warn( - `[Deprecation Warning] "${hookName}" is being replaced by BFF auth pattern Please use "${alternative}" from "libs/react-spa/bff".`, - ) -} - -/** - * @deprecated Use useBff from `libs/react-spa/bff` instead. - */ -export const useAuth = () => { - warnDeprecated('useAuth', 'useBff') - - const context = useContext(AuthContext) - - if (!context) { - throw new Error('useAuth must be used within a AuthProvider') - } - - return context -} - -/** - * @deprecated Use useUserInfo from `libs/react-spa/bff` instead. - */ -export const useUserInfo = () => { - warnDeprecated('useUserInfo', 'useUserInfo') - const { userInfo } = useAuth() - - if (!userInfo) { - throw new Error('User info is not available. Is the user authenticated?') - } - - return userInfo -} diff --git a/libs/auth/react/src/lib/auth/AuthErrorScreen.tsx b/libs/auth/react/src/lib/auth/AuthErrorScreen.tsx deleted file mode 100644 index 27bcfe5aeb03..000000000000 --- a/libs/auth/react/src/lib/auth/AuthErrorScreen.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react' -import { Box, Button, ProblemTemplate } from '@island.is/island-ui/core' - -import * as styles from './Auth.css' - -type AuthenticatorErrorScreenProps = { - /** - * Retry callback - */ - onRetry(): void -} - -// This screen is unfortunately not translated because at this point we don't -// have a user locale, nor an access token to fetch translations. -export const AuthErrorScreen = ({ onRetry }: AuthenticatorErrorScreenProps) => ( - - - Vinsamlegast reyndu aftur síðar.{' '} - - - } - /> - -) diff --git a/libs/auth/react/src/lib/auth/AuthProvider.mocked.spec.tsx b/libs/auth/react/src/lib/auth/AuthProvider.mocked.spec.tsx deleted file mode 100644 index 3cc638999238..000000000000 --- a/libs/auth/react/src/lib/auth/AuthProvider.mocked.spec.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { FC } from 'react' -import { render, screen } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' - -import { configureMock } from '../userManager' -import { useAuth } from './AuthContext' -import { AuthProvider } from './AuthProvider' - -const Wrapper: FC> = ({ children }) => ( - {children} -) -const Greeting = () => { - const { userInfo } = useAuth() - return <>Hello {userInfo?.profile.name} -} -const renderAuthenticator = () => - render( - -

- -

-
, - { wrapper: Wrapper }, - ) - -describe('AuthProvider', () => { - const expectAuthenticated = (name: string) => - screen.findByText(`Hello ${name}`) - - it('authenticates with non-expired user', async () => { - // Arrange - configureMock({ - profile: { - name: 'John Doe', - }, - }) - - // Act - renderAuthenticator() - - // Assert - await expectAuthenticated('John Doe') - }) -}) diff --git a/libs/auth/react/src/lib/auth/AuthProvider.spec.tsx b/libs/auth/react/src/lib/auth/AuthProvider.spec.tsx deleted file mode 100644 index ab1d4a03049f..000000000000 --- a/libs/auth/react/src/lib/auth/AuthProvider.spec.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { act, render, screen, waitFor } from '@testing-library/react' -import { UserManagerEvents } from 'oidc-client-ts' -import { BrowserRouter } from 'react-router-dom' - -import { getAuthSettings, getUserManager } from '../userManager' -import { useAuth } from './AuthContext' -import { AuthProvider } from './AuthProvider' -import { createNationalId } from '@island.is/testing/fixtures' - -const BASE_PATH = '/basepath' -const INITIATE_LOGIN_PATH = '/login' - -jest.mock('../userManager') -const mockedGetUserManager = getUserManager as jest.Mock -const mockedGetAuthSettings = getAuthSettings as jest.Mock - -const Greeting = () => { - const { userInfo } = useAuth() - return <>Hello {userInfo?.profile.name} -} - -const renderAuthenticator = (route = BASE_PATH) => { - window.history.pushState({}, 'Test page', route) - - return render( - -

- -

-
, - { wrapper: BrowserRouter }, - ) -} - -type MinimalUser = { - expired?: boolean - profile?: { - name: string - } -} -type MinimalUserManager = { - events: { - addUserLoaded: (cb: UserManagerEvents) => void - addUserSignedOut: jest.Mock - removeUserLoaded: () => void - removeUserSignedOut: () => void - } - getUser: jest.Mock> - signinRedirect: jest.Mock - signinSilent: jest.Mock - signinRedirectCallback: jest.Mock - removeUser: jest.Mock -} - -describe('AuthProvider', () => { - let userManager: MinimalUserManager - - const expectSignin = () => - waitFor(() => { - if (userManager.signinRedirect.mock.calls.length === 0) { - throw new Error('... wait') - } - }) - - const expectAuthenticated = (name: string) => - screen.findByText(`Hello ${name}`) - - beforeEach(() => { - userManager = { - events: { - addUserLoaded: jest.fn(), - addUserSignedOut: jest.fn(), - removeUserLoaded: jest.fn(), - removeUserSignedOut: jest.fn(), - }, - getUser: jest.fn(), - signinRedirect: jest.fn(), - signinSilent: jest.fn(), - signinRedirectCallback: jest.fn(), - removeUser: jest.fn(), - } - mockedGetUserManager.mockReturnValue(userManager) - mockedGetAuthSettings.mockReturnValue({ - baseUrl: BASE_PATH, - initiateLoginPath: INITIATE_LOGIN_PATH, - redirectPath: '/callback', - redirectPathSilent: '/callback-silent', - }) - }) - - it('starts signin flow when no stored user', async () => { - // Act - renderAuthenticator() - - // Assert - await expectSignin() - }) - - it('should show a progress bar while authenticating', async () => { - // Act - const { getByRole } = renderAuthenticator() - - // Assert - getByRole('progressbar') - await expectSignin() - }) - - it('authenticates with non-expired user', async () => { - // Arrange - userManager.getUser.mockResolvedValue({ - expired: false, - profile: { - name: 'John', - }, - }) - - // Act - renderAuthenticator() - - // Assert - await expectAuthenticated('John') - }) - - it('removes user and starts signin flow if user is logged out', async () => { - // Arrange - userManager.getUser.mockResolvedValue({ - expired: false, - profile: { - name: 'John', - }, - }) - renderAuthenticator() - await expectAuthenticated('John') - expect(userManager.events.addUserSignedOut).toHaveBeenCalled() - const handler = userManager.events.addUserSignedOut.mock.calls[0][0] - - // Act - await act(async () => { - await handler() - }) - - // Assert - await expectSignin() - expect(userManager.removeUser).toHaveBeenCalled() - }) - - it('performs silent signin with expired user', async () => { - // Arrange - userManager.getUser.mockResolvedValue({ - expired: true, - }) - userManager.signinSilent.mockResolvedValue({ - profile: { - name: 'Doe', - }, - }) - - // Act - renderAuthenticator() - - // Assert - await expectAuthenticated('Doe') - expect(userManager.signinSilent).toHaveBeenCalled() - }) - - it('starts signin flow if silent signin fails', async () => { - // Arrange - userManager.getUser.mockResolvedValue({ - expired: true, - }) - userManager.signinSilent.mockRejectedValue(new Error('Not signed in')) - - // Act - renderAuthenticator() - - // Assert - await expectSignin() - expect(userManager.signinSilent).toHaveBeenCalled() - }) - - // prettier-ignore - it.each` - params - ${{ prompt: 'login' }} - ${{ prompt: 'select_account' }} - ${{ - login_hint: createNationalId('company'), - target_link_uri: `${BASE_PATH}/test`, - }} - `( - 'starts 3rd party initiated login flow with params $params', - async (params: { prompt?: string, login_hint?: string, target_link_uri?: string }) => { - // Arrange - const searchParams = new URLSearchParams(params) - - // Act - renderAuthenticator( - `${BASE_PATH}${INITIATE_LOGIN_PATH}?${searchParams.toString()}`, - ) - - // Assert - await expectSignin() - expect(userManager.signinRedirect).toHaveBeenCalledWith({ - state: params.target_link_uri?.slice(BASE_PATH.length) ?? '/', - login_hint: params.login_hint, - prompt: params.prompt, - }) - }, - ) - - it('shows error screen if signin has an error', async () => { - // Arrange - const testRoute = `${BASE_PATH}${mockedGetAuthSettings().redirectPath}` - userManager.signinRedirectCallback.mockRejectedValue( - new Error('Test error'), - ) - - // Act - renderAuthenticator(testRoute) - - // Assert - await screen.findByText('Innskráning mistókst') - await screen.findByRole('button', { name: 'Reyna aftur' }) - }) - - it('shows error screen if IDS is unavailable', async () => { - // Arrange - // When the OIDC client library fails to load the /.well-known/openid-configuration - // the signinRedirect methods rejects the Promise with an Error. - userManager.signinRedirect.mockRejectedValueOnce( - new Error('Internal Server Error'), - ) - - // Act - renderAuthenticator() - - // Assert - await screen.findByText('Innskráning mistókst') - await screen.findByRole('button', { name: 'Reyna aftur' }) - }) -}) diff --git a/libs/auth/react/src/lib/auth/AuthProvider.tsx b/libs/auth/react/src/lib/auth/AuthProvider.tsx deleted file mode 100644 index 8243291478b5..000000000000 --- a/libs/auth/react/src/lib/auth/AuthProvider.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import React, { - useCallback, - useEffect, - useMemo, - useReducer, - ReactNode, - useState, -} from 'react' -import type { SigninRedirectArgs, User } from 'oidc-client-ts' - -import { useEffectOnce } from '@island.is/react-spa/shared' -import { isDefined } from '@island.is/shared/utils' -import { LoadingScreen } from '@island.is/react/components' - -import { getAuthSettings, getUserManager } from '../userManager' -import { ActionType, initialState, reducer } from './Auth.state' -import { AuthSettings } from '../AuthSettings' -import { AuthContext } from './AuthContext' -import { AuthErrorScreen } from './AuthErrorScreen' -import { CheckIdpSession } from './CheckIdpSession' - -interface AuthProviderProps { - /** - * If true, Authenticator automatically starts login flow and does not render children until user is fully logged in. - * If false, children are responsible for rendering a login button and loading indicator. - * Default: true - */ - autoLogin?: boolean - /** - * The base path of the application. - */ - basePath: string - children: ReactNode -} - -type GetReturnUrl = { - returnUrl: string -} & Pick - -const isCurrentRoute = (url: string, path?: string) => - isDefined(path) && url.startsWith(path) - -const getReturnUrl = ({ redirectPath, returnUrl }: GetReturnUrl) => { - if (redirectPath && returnUrl.startsWith(redirectPath)) { - return '/' - } - - return returnUrl -} - -const getCurrentUrl = (basePath: string) => { - const url = `${window.location.pathname}${window.location.search}${window.location.hash}` - - if (url.startsWith(basePath)) { - return url.slice(basePath.length) - } - - return '/' -} - -export const AuthProvider = ({ - children, - autoLogin = true, - basePath, -}: AuthProviderProps) => { - const [state, dispatch] = useReducer(reducer, initialState) - const [error, setError] = useState() - const userManager = getUserManager() - const authSettings = getAuthSettings() - const monitorUserSession = !authSettings.scope?.includes('offline_access') - - const signinRedirect = useCallback( - async (args: SigninRedirectArgs) => { - try { - await userManager.signinRedirect(args) - // On success Nothing more happens here since browser will redirect to IDS. - } catch (error) { - // On error we set the error state to show the error screen which provides the users with a retry button. - console.error(error) - setError(error) - } - }, - [userManager, setError], - ) - - const signIn = useCallback( - async function signIn() { - dispatch({ - type: ActionType.SIGNIN_START, - }) - - return signinRedirect({ - state: getReturnUrl({ - returnUrl: getCurrentUrl(basePath), - redirectPath: authSettings.redirectPath, - }), - }) - }, - [dispatch, authSettings, basePath], - ) - - const signInSilent = useCallback( - async function signInSilent() { - let user = null - dispatch({ - type: ActionType.SIGNIN_START, - }) - try { - user = await userManager.signinSilent() - dispatch({ type: ActionType.SIGNIN_SUCCESS, payload: user }) - } catch (error) { - console.error('AuthProvider: Silent signin failed', error) - dispatch({ type: ActionType.SIGNIN_FAILURE }) - } - - return user - }, - [userManager, dispatch], - ) - - const switchUser = useCallback( - async function switchUser(nationalId?: string) { - const args = - nationalId !== undefined - ? { - login_hint: nationalId, - /** - * TODO: remove this. - * It is currently required to switch delegations, but we'd like - * the IDS to handle login_required and other potential road - * blocks. Now OidcSignIn is handling login_required. - */ - prompt: 'none', - } - : { - prompt: 'select_account', - } - - dispatch({ - type: ActionType.SWITCH_USER, - }) - - return signinRedirect({ - state: - authSettings.switchUserRedirectUrl ?? - getReturnUrl({ - returnUrl: getCurrentUrl(basePath), - redirectPath: authSettings.redirectPath, - }), - ...args, - }) - // Nothing more happens here since browser will redirect to IDS. - }, - [userManager, dispatch, authSettings, basePath], - ) - - const signOut = useCallback( - async function signOut() { - dispatch({ - type: ActionType.LOGGING_OUT, - }) - await userManager.signoutRedirect() - }, - [userManager, dispatch], - ) - - const checkLogin = useCallback( - async function checkLogin() { - dispatch({ - type: ActionType.SIGNIN_START, - }) - const storedUser = await userManager.getUser() - - // Check expiry. - if (storedUser && !storedUser.expired) { - dispatch({ - type: ActionType.SIGNIN_SUCCESS, - payload: storedUser, - }) - } else if (autoLogin) { - // If we find a user in SessionStorage, there's a fine chance that - // it's just an expired token, and we can silently log in. - if (storedUser && (await signInSilent())) { - return - } - - // If all else fails, redirect to the login page. - await signIn() - } else { - // When not performing autologin, silently check if there's an IDP session. - await signInSilent() - } - }, - [userManager, dispatch, signIn, signInSilent, autoLogin], - ) - - const hasUserInfo = state.userInfo !== null - useEffect(() => { - // Only add events when we have userInfo, to avoid race conditions with - // oidc hooks. - if (!hasUserInfo) { - return - } - - // This is raised when a new user state has been loaded with a silent login. - const userLoaded = (user: User) => { - dispatch({ - type: ActionType.USER_LOADED, - payload: user, - }) - } - - // This is raised when the user is signed out of the IDP. - const userSignedOut = async () => { - dispatch({ - type: ActionType.LOGGED_OUT, - }) - await userManager.removeUser() - - if (autoLogin) { - signIn() - } - } - - userManager.events.addUserLoaded(userLoaded) - userManager.events.addUserSignedOut(userSignedOut) - return () => { - userManager.events.removeUserLoaded(userLoaded) - userManager.events.removeUserSignedOut(userSignedOut) - } - }, [dispatch, userManager, signIn, autoLogin, hasUserInfo]) - - const init = async () => { - const currentUrl = getCurrentUrl(basePath) - - if (isCurrentRoute(currentUrl, authSettings.redirectPath)) { - try { - const user = await userManager.signinRedirectCallback( - window.location.href, - ) - - const url = typeof user.state === 'string' ? user.state : '/' - window.history.replaceState(null, '', basePath + url) - - dispatch({ - type: ActionType.SIGNIN_SUCCESS, - payload: user, - }) - } catch (e) { - if (e.error === 'login_required') { - // If trying to switch delegations and the IDS session is expired, we'll - // see this error. So we'll try a proper signin. - return signinRedirect({ state: e.state }) - } - console.error('Error in oidc callback', e) - setError(e) - } - } else if (isCurrentRoute(currentUrl, authSettings.redirectPathSilent)) { - const userManager = getUserManager() - userManager.signinSilentCallback().catch((e) => { - console.log(e) - setError(e) - }) - } else if (isCurrentRoute(currentUrl, authSettings.initiateLoginPath)) { - const userManager = getUserManager() - const searchParams = new URL(window.location.href).searchParams - - const loginHint = searchParams.get('login_hint') - const targetLinkUri = searchParams.get('target_link_uri') - const path = - targetLinkUri && - authSettings.baseUrl && - targetLinkUri.startsWith(authSettings.baseUrl) - ? targetLinkUri.slice(authSettings.baseUrl.length) - : '/' - let prompt = searchParams.get('prompt') - prompt = - prompt && ['login', 'select_account'].includes(prompt) ? prompt : null - - const args = { - state: path, - prompt: prompt ?? undefined, - login_hint: loginHint ?? undefined, - } - return signinRedirect(args) - } else { - checkLogin() - } - } - - useEffectOnce(() => { - init() - }) - - const context = useMemo( - () => ({ - ...state, - signIn, - signInSilent, - switchUser, - signOut, - authority: authSettings.authority, - }), - [state, signIn, signInSilent, switchUser, signOut, authSettings.authority], - ) - - const url = getCurrentUrl(basePath) - const isLoading = - !state.userInfo || - // We need to display loading screen if current route is the redirectPath or redirectPathSilent. - // This is because these paths are not part of our React Router routes. - isCurrentRoute(url, authSettings?.redirectPath) || - isCurrentRoute(url, authSettings?.redirectPathSilent) - - const onRetry = () => { - window.location.href = basePath - } - - return ( - - {error ? ( - - ) : isLoading ? ( - - ) : ( - <> - {monitorUserSession && } - {children} - - )} - - ) -} diff --git a/libs/auth/react/src/lib/auth/CheckIdpSession.tsx b/libs/auth/react/src/lib/auth/CheckIdpSession.tsx deleted file mode 100644 index 1890dbd1efd6..000000000000 --- a/libs/auth/react/src/lib/auth/CheckIdpSession.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import addSeconds from 'date-fns/addSeconds' -import { useCallback, useEffect, useReducer, useRef } from 'react' -import { getAuthSettings, getUserManager } from '../userManager' - -const UserSessionMessageType = 'SessionInfo' - -interface UserSessionMessage { - // Type to use to filter postMessage messages - type: typeof UserSessionMessageType - - // Status of the message received from IDP. - status: 'Ok' | 'No Session' | 'Failure' - - // The time when the authenticated session expires. - expiresUtc?: string - - // Number of seconds until the session expires. - expiresIn?: number - - // Boolean flag to indicated if the Expires time is passed. - isExpired?: boolean -} - -interface UserSessionState { - /* The expected time when the user session is ending. */ - sessionEnd: Date | null - - /** - * An interval function that checks if the expected sessionEnd has passed. - * When set this indicates that the user has an active session. - */ - intervalHandle: ReturnType | null - - /* The number of times we have tried to load the iframe to receive a new session info message. */ - retryCount: number -} - -const MAX_RETRIES = 2 -const ACTIVE_SESSION_DELAY = 5 * 1000 -const CHECK_SESSION_INTERVAL = 2 * 1000 - -const EMPTY_SESSION: UserSessionState = { - retryCount: 0, - sessionEnd: null, - intervalHandle: null, -} - -/** - * This component monitors if the user session is active on the Identity Provider (IDP). - * When it detects that the user session is expired it redirects to the sign-in page on the IDP. - * - * It loads a script from the IDP's 'connect/sessioninfo' endpoint into an iframe. - * The script uses the postMessage API to post UserSessionMessage, which contains - * details if the session is expired or after how many seconds it will expire. - * We use these details to register an interval to monitor the session expiration. - */ -export const CheckIdpSession = () => { - const userManager = getUserManager() - const authSettings = getAuthSettings() - const iframeSrc = `${authSettings.authority}${authSettings.checkSessionPath}` - const [iframeId, reloadIframe] = useReducer((id) => id + 1, 0) - const userSession = useRef({ ...EMPTY_SESSION }) - - const isActive = useCallback(() => { - // When intervalHandle is set it means we have registered - // a setInterval to monitor an active user session. - return !!userSession.current.intervalHandle - }, []) - - const hasBeenActive = useCallback(() => { - // When sessionEnd is set it means the has been active - // as we have an earlier UserSessionMessage. - return !!userSession.current.sessionEnd - }, []) - - const resetUserSession = useCallback(() => { - if (userSession.current.intervalHandle) { - clearInterval(userSession.current.intervalHandle) - } - userSession.current.intervalHandle = null - userSession.current.retryCount = 0 - // Intentionally not resetting sessionEnd as it - // indicates that the user has had session before. - }, []) - - const signInRedirect = useCallback(async () => { - await userManager.removeUser() - return window.location.reload() - }, [userManager]) - - const checkActiveSession = useCallback(() => { - setTimeout(() => { - const { retryCount } = userSession.current - - if (!isActive() && retryCount > MAX_RETRIES && hasBeenActive()) { - // We were unable to retrieve a message from the IDP after max retries and have a reason - // to believe that the session is expired (an earlier UserSessionMessage has expired). - // So we reload the window just to be safe. This causes one of three things to happen: - // - If the iframe is broken and the user does have a valid IDP session, they'll generally reload where they were. - // - If the iframe is broken and the user does not have a valid IDP session, they're sent to the login page. - // - If the user has a network problem, then they'll see a browser error screen, but at least any sensitive information is not visible any more. - window.location.reload() - } else if (!isActive() && retryCount < MAX_RETRIES) { - userSession.current.retryCount += 1 - // We are unable to retrieve a message from the IDP, - // so we reload the iframe to retry without reloading the window. - reloadIframe() - } - }, ACTIVE_SESSION_DELAY) - }, [isActive, hasBeenActive]) - - const messageHandler = useCallback( - async ({ data, origin }: MessageEvent): Promise => { - const sessionInfo = data as UserSessionMessage - - // Check if the postMessage is meant for us - if ( - origin !== authSettings.authority || - sessionInfo.type !== UserSessionMessageType - ) { - return - } - - if (sessionInfo && sessionInfo.status === 'Ok') { - // SessionInfo was found, check if it is valid or expired - if (sessionInfo.isExpired) { - return signInRedirect() - } else if (!isActive() && sessionInfo.expiresIn !== undefined) { - userSession.current.sessionEnd = addSeconds( - new Date(), - sessionInfo.expiresIn, - ) - - userSession.current.intervalHandle = setInterval(() => { - const now = new Date() - - if ( - userSession.current.sessionEnd && - now > userSession.current.sessionEnd - ) { - // The expected session end has passed but the user might have extended their session. - // So we reset the session state and reload the iframe to query new session info from the IDP. - resetUserSession() - reloadIframe() - } - }, CHECK_SESSION_INTERVAL) - } - } else if ( - sessionInfo && - sessionInfo.status === 'No Session' && - hasBeenActive() - ) { - return signInRedirect() - } - - // Silent failure as we have failed to get sessionInfo but the user still might have valid session. - // So we only trigger the signInRedirect flow when we get definite response about expired session. - }, - [ - authSettings.authority, - signInRedirect, - isActive, - hasBeenActive, - resetUserSession, - ], - ) - - useEffect(() => { - window.addEventListener('message', messageHandler) - - return () => { - window.removeEventListener('message', messageHandler) - } - }, [messageHandler]) - - return ( -