diff --git a/__tests__/src/actions/auth.test.js b/__tests__/src/actions/auth.test.js index 039e1853a5e1f753e5d2e7836ffbe7d4e31c123b..a37062cc09152676dd0a3092f5af9966dc250a81 100644 --- a/__tests__/src/actions/auth.test.js +++ b/__tests__/src/actions/auth.test.js @@ -1,90 +1,67 @@ -import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; - import * as actions from '../../../src/state/actions'; import ActionTypes from '../../../src/state/actions/action-types'; -const middlewares = [thunk]; -const mockStore = configureMockStore(middlewares); - describe('auth actions', () => { describe('addAuthenticationRequest', () => { it('requests an authentication attempt from given a url', () => { const id = 'abc123'; const windowId = 'windowId'; - const infoId = 'infoId'; const expectedAction = { id, - infoId, type: ActionTypes.ADD_AUTHENTICATION_REQUEST, windowId, }; - expect(actions.addAuthenticationRequest(windowId, infoId, id)).toEqual(expectedAction); + expect(actions.addAuthenticationRequest(windowId, id)).toEqual(expectedAction); }); }); describe('resolveAuthenticationRequest', () => { - let store = null; - beforeEach(() => { - store = mockStore({}); - }); - - it('triggers an access token fetch', () => { + it('markes the auth request as resolved (pending fetching access tokens to mark it a success)', () => { const authId = 'abc123'; - const infoId = 'x'; - const serviceId = 'xyz'; + const tokenServiceId = 'xyz'; - store = mockStore({ - auth: { - [authId]: { - infoId: [infoId], - }, - }, - infoResponses: { - [infoId]: { - json: { - service: { - '@id': authId, - service: { - '@id': serviceId, - profile: 'http://iiif.io/api/auth/1/token', - }, - }, - }, - }, - }, - }); + const expectedAction = { + id: authId, + tokenServiceId, + type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST, + }; + + expect(actions.resolveAuthenticationRequest(authId, tokenServiceId)).toEqual(expectedAction); + }); + it('can be marked as failed', () => { + const authId = 'abc123'; + const tokenServiceId = 'xyz'; const expectedAction = { - authId, - infoIds: [infoId], - serviceId, - type: ActionTypes.REQUEST_ACCESS_TOKEN, + id: authId, + ok: false, + tokenServiceId, + type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST, }; - store.dispatch(actions.resolveAuthenticationRequest(authId)); - expect(store.getActions()).toEqual([expectedAction]); + expect( + actions.resolveAuthenticationRequest(authId, tokenServiceId, { ok: false }), + ).toEqual(expectedAction); }); }); describe('requestAccessToken', () => { it('requests an infoResponse from given a url', () => { const authId = 'abc123'; - const infoIds = ['x']; const serviceId = 'xyz'; const expectedAction = { authId, - infoIds, serviceId, type: ActionTypes.REQUEST_ACCESS_TOKEN, }; - expect(actions.requestAccessToken(serviceId, authId, infoIds)).toEqual(expectedAction); + expect(actions.requestAccessToken(serviceId, authId)).toEqual(expectedAction); }); }); describe('receiveAccessToken', () => { it('recieves an access token', () => { + const authId = 'auth'; const serviceId = 'abc123'; const json = { content: 'image information request', @@ -92,76 +69,54 @@ describe('auth actions', () => { }; const expectedAction = { + authId, json, serviceId, type: ActionTypes.RECEIVE_ACCESS_TOKEN, }; - expect(actions.receiveAccessToken(serviceId, json)).toEqual(expectedAction); + expect(actions.receiveAccessToken(authId, serviceId, json)).toEqual(expectedAction); }); }); describe('receiveAccessTokenFailure', () => { it('fails to receive an access token', () => { + const authId = 'auth'; const serviceId = 'abc123'; const error = 'some error'; const expectedAction = { + authId, error, serviceId, type: ActionTypes.RECEIVE_ACCESS_TOKEN_FAILURE, }; - expect(actions.receiveAccessTokenFailure(serviceId, error)).toEqual(expectedAction); + expect(actions.receiveAccessTokenFailure(authId, serviceId, error)).toEqual(expectedAction); }); }); describe('resolveAccessTokenRequest', () => { - let store = null; - beforeEach(() => { - store = mockStore({}); - }); - it('resolves the auth request, receives the access token, and re-dispatches fetching info responses', () => { - const authId = 'abc123'; - const infoId = 'x'; - const messageId = 'xyz'; + const authId = 'auth'; + const serviceId = 'abc123'; const json = { accessToken: 1 }; - store = mockStore({ - accessTokens: { - [messageId]: { - authId, - infoIds: [infoId], - }, + expect(actions.resolveAccessTokenRequest(authId, serviceId, json)).toEqual( + { + authId, json, serviceId, type: ActionTypes.RECEIVE_ACCESS_TOKEN, }, - }); - - store.dispatch(actions.resolveAccessTokenRequest({ messageId, ...json })); - expect(store.getActions()).toEqual([ - { id: authId, ok: true, type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST }, - { json, serviceId: messageId, type: ActionTypes.RECEIVE_ACCESS_TOKEN }, - ]); + ); }); it('without an access token, resolves the auth request unsuccessfully', () => { - const authId = 'abc123'; - const infoId = 'x'; - const messageId = 'xyz'; + const authId = 'auth'; + const serviceId = 'abc123'; const json = { error: 'xyz' }; - store = mockStore({ - accessTokens: { - [messageId]: { - authId, - infoIds: [infoId], - }, + expect(actions.resolveAccessTokenRequest(authId, serviceId, json)).toEqual( + { + authId, error: json, serviceId, type: ActionTypes.RECEIVE_ACCESS_TOKEN_FAILURE, }, - }); - - store.dispatch(actions.resolveAccessTokenRequest({ messageId, ...json })); - expect(store.getActions()).toEqual([ - { id: authId, ok: false, type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST }, - { error: json, serviceId: messageId, type: ActionTypes.RECEIVE_ACCESS_TOKEN_FAILURE }, - ]); + ); }); }); }); diff --git a/__tests__/src/components/App.test.js b/__tests__/src/components/App.test.js index e67b7edf3203f1aa1304b2293d192969d9a743c8..5046765124baa2018ddb8769162a0b42a03b0fd9 100644 --- a/__tests__/src/components/App.test.js +++ b/__tests__/src/components/App.test.js @@ -2,8 +2,6 @@ import React from 'react'; import { shallow } from 'enzyme'; import PluginProvider from '../../../src/extend/PluginProvider'; import AppProviders from '../../../src/containers/AppProviders'; -import AccessTokenSender from '../../../src/containers/AccessTokenSender'; -import AuthenticationSender from '../../../src/containers/AuthenticationSender'; import { App } from '../../../src/components/App'; /** */ @@ -21,7 +19,5 @@ describe('App', () => { expect(wrapper.find(PluginProvider).length).toBe(1); expect(wrapper.find(AppProviders).length).toBe(1); expect(wrapper.find('Suspense').length).toBe(1); - expect(wrapper.find(AuthenticationSender).length).toBe(1); - expect(wrapper.find(AccessTokenSender).length).toBe(1); }); }); diff --git a/__tests__/src/components/AuthenticationLogout.test.js b/__tests__/src/components/AuthenticationLogout.test.js deleted file mode 100644 index e120d6e9188e17a7cbcacfb18993dad313de7de4..0000000000000000000000000000000000000000 --- a/__tests__/src/components/AuthenticationLogout.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import Fab from '@material-ui/core/Fab'; -import { AuthenticationLogout } from '../../../src/components/AuthenticationLogout'; - -/** - * Helper function to create a shallow wrapper around AuthenticationLogout - */ -function createWrapper(props) { - return shallow( - <AuthenticationLogout - authServiceId="http://example.com/auth" - label="Log out now!" - logoutServiceId="http://example.com/logout" - status="ok" - t={key => key} - windowId="w" - {...props} - />, - ); -} - -describe('AuthenticationLogout', () => { - it('when status is not ok, render fragment', () => { - const wrapper = createWrapper({ status: 'fail' }); - expect(wrapper.matchesElement(<></>)).toBe(true); - }); - it('renders Fab with logout label', () => { - const wrapper = createWrapper(); - expect(wrapper.find(Fab).length).toBe(1); - expect(wrapper.find(Fab).text()).toBe('Log out now!'); - }); - it('click opens a new window to logout and resets state', () => { - const mockWindow = {}; - const open = jest.fn(() => mockWindow); - const reset = jest.fn(() => {}); - const wrapper = createWrapper({ depWindow: { open }, resetAuthenticationState: reset }); - expect(wrapper.find(Fab).props().onClick()); - expect(open).toHaveBeenCalledWith('http://example.com/logout'); - expect(reset).toHaveBeenCalledWith({ authServiceId: 'http://example.com/auth' }); - }); -}); diff --git a/__tests__/src/components/AuthenticationSender.test.js b/__tests__/src/components/AuthenticationSender.test.js deleted file mode 100644 index 4b18215e28673b93297a1e61bc9791b392c941a9..0000000000000000000000000000000000000000 --- a/__tests__/src/components/AuthenticationSender.test.js +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import { NewWindow } from '../../../src/components/NewWindow'; -import { AuthenticationSender } from '../../../src/components/AuthenticationSender'; - -/** - * Helper function to create a shallow wrapper around ErrorDialog - */ -function createWrapper(props) { - return shallow( - <AuthenticationSender - t={key => key} - handleInteraction={() => {}} - {...props} - />, - ); -} - -describe('AuthenticationSender', () => { - let wrapper; - - it('renders nothing if there is no url', () => { - wrapper = createWrapper({}); - expect(wrapper.matchesElement(<></>)).toBe(true); - }); - - it('renders properly', () => { - Object.defineProperty(window, 'origin', { - value: 'http://localhost', - writable: true, - }); - wrapper = createWrapper({ url: 'http://example.com' }); - expect(wrapper.find(NewWindow).length).toBe(1); - expect(wrapper.find(NewWindow).props().url).toBe('http://example.com?origin=http://localhost'); - }); - - it('triggers an action when the window is unloaded', () => { - const handleInteraction = jest.fn(); - wrapper = createWrapper({ handleInteraction, url: 'http://example.com' }); - wrapper.find(NewWindow).simulate('close'); - - expect(handleInteraction).toHaveBeenCalledWith('http://example.com'); - }); -}); diff --git a/__tests__/src/components/IIIFAuthentication.test.js b/__tests__/src/components/IIIFAuthentication.test.js new file mode 100644 index 0000000000000000000000000000000000000000..cbf6a80230363383c7a3a4d91d54b5a89909018e --- /dev/null +++ b/__tests__/src/components/IIIFAuthentication.test.js @@ -0,0 +1,95 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import WindowAuthenticationBar from '../../../src/containers/WindowAuthenticationBar'; +import { NewWindow } from '../../../src/components/NewWindow'; +import { AccessTokenSender } from '../../../src/components/AccessTokenSender'; +import { IIIFAuthentication } from '../../../src/components/IIIFAuthentication'; + +/** + * Helper function to create a shallow wrapper around IIIFAuthentication + */ +function createWrapper(props) { + return shallow( + <IIIFAuthentication + accessTokenServiceId="http://example.com/token" + authServiceId="http://example.com/auth" + failureDescription="... and this is why." + failureHeader="Login failed" + handleAuthInteraction={() => {}} + isInteractive + logoutServiceId="http://example.com/logout" + resetAuthenticationState={() => {}} + resolveAccessTokenRequest={() => {}} + resolveAuthenticationRequest={() => {}} + t={key => key} + windowId="w" + {...props} + />, + ); +} + +describe('IIIFAuthentication', () => { + describe('without an auth service', () => { + it('renders nothing', () => { + const wrapper = createWrapper({ authServiceId: null }); + expect(wrapper.isEmptyRender()).toBe(true); + }); + }); + describe('with an available auth service', () => { + it('renders a login bar', () => { + const handleAuthInteraction = jest.fn(); + const wrapper = createWrapper({ handleAuthInteraction }); + expect(wrapper.find(WindowAuthenticationBar).length).toBe(1); + expect(wrapper.find(WindowAuthenticationBar).simulate('confirm')); + expect(handleAuthInteraction).toHaveBeenCalledWith('w', 'http://example.com/auth'); + }); + it('renders nothing for a non-interactive login', () => { + const wrapper = createWrapper({ isInteractive: false }); + expect(wrapper.isEmptyRender()).toBe(true); + }); + }); + describe('with a failed authentication', () => { + it('renders with an error message', () => { + const handleAuthInteraction = jest.fn(); + const wrapper = createWrapper({ handleAuthInteraction, status: 'failed' }); + expect(wrapper.find(WindowAuthenticationBar).length).toBe(1); + expect(wrapper.find(WindowAuthenticationBar).prop('confirmButton')).toEqual('retry'); + expect(wrapper.find(WindowAuthenticationBar).prop('status')).toEqual('failed'); + expect(wrapper.find(WindowAuthenticationBar).prop('header')).toEqual('Login failed'); + expect(wrapper.find(WindowAuthenticationBar).prop('description')).toEqual('... and this is why.'); + expect(wrapper.find(WindowAuthenticationBar).simulate('confirm')); + expect(handleAuthInteraction).toHaveBeenCalledWith('w', 'http://example.com/auth'); + }); + }); + describe('in the middle of authenicating', () => { + it('does the IIIF access cookie behavior', () => { + const wrapper = createWrapper({ status: 'cookie' }); + expect(wrapper.find(WindowAuthenticationBar).length).toBe(1); + expect(wrapper.find(NewWindow).length).toBe(1); + expect(wrapper.find(NewWindow).prop('url')).toContain('http://example.com/auth?origin='); + }); + it('does the IIIF access token behavior', () => { + const wrapper = createWrapper({ status: 'token' }); + expect(wrapper.find(WindowAuthenticationBar).length).toBe(1); + expect(wrapper.find(AccessTokenSender).length).toBe(1); + expect(wrapper.find(AccessTokenSender).prop('url')).toEqual('http://example.com/token'); + }); + }); + describe('when logged in', () => { + it('renders a logout button', () => { + const openWindow = jest.fn(); + const resetAuthenticationState = jest.fn(); + const wrapper = createWrapper({ openWindow, resetAuthenticationState, status: 'ok' }); + expect(wrapper.find(WindowAuthenticationBar).length).toBe(1); + expect(wrapper.find(WindowAuthenticationBar).prop('confirmButton')).toEqual('logout'); + expect(wrapper.find(WindowAuthenticationBar).prop('hasLogoutService')).toEqual(true); + + wrapper.find(WindowAuthenticationBar).simulate('confirm'); + + expect(openWindow).toHaveBeenCalledWith('http://example.com/logout', undefined, 'centerscreen'); + expect(resetAuthenticationState).toHaveBeenCalledWith({ + authServiceId: 'http://example.com/auth', tokenServiceId: 'http://example.com/token', + }); + }); + }); +}); diff --git a/__tests__/src/components/Window.test.js b/__tests__/src/components/Window.test.js index e21220fa6b7efbf35eb33b892a992e702016db7a..c16b361fef230d10765295efaccef0786e4ec971 100644 --- a/__tests__/src/components/Window.test.js +++ b/__tests__/src/components/Window.test.js @@ -3,7 +3,7 @@ import { shallow } from 'enzyme'; import { Window } from '../../../src/components/Window'; import WindowTopBar from '../../../src/containers/WindowTopBar'; import PrimaryWindow from '../../../src/containers/PrimaryWindow'; -import WindowAuthenticationControl from '../../../src/containers/WindowAuthenticationControl'; +import IIIFAuthentication from '../../../src/containers/IIIFAuthentication'; import ErrorContent from '../../../src/containers/ErrorContent'; /** create wrapper */ @@ -34,9 +34,9 @@ describe('Window', () => { wrapper = createWrapper(); expect(wrapper.find(PrimaryWindow)).toHaveLength(1); }); - it('renders <WindowAuthenticationControl>', () => { + it('renders <WindowAuthenticationBar>', () => { wrapper = createWrapper(); - expect(wrapper.find(WindowAuthenticationControl)).toHaveLength(1); + expect(wrapper.find(IIIFAuthentication)).toHaveLength(1); }); it('renders manifest error', () => { wrapper = createWrapper({ manifestError: 'Invalid JSON' }); diff --git a/__tests__/src/components/WindowAuthenticationBar.test.js b/__tests__/src/components/WindowAuthenticationBar.test.js new file mode 100644 index 0000000000000000000000000000000000000000..72e9b029922cac5e935d21f347103759ec7e3d80 --- /dev/null +++ b/__tests__/src/components/WindowAuthenticationBar.test.js @@ -0,0 +1,73 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Button from '@material-ui/core/Button'; +import Collapse from '@material-ui/core/Collapse'; +import DialogActions from '@material-ui/core/DialogActions'; +import SanitizedHtml from '../../../src/containers/SanitizedHtml'; +import { WindowAuthenticationBar } from '../../../src/components/WindowAuthenticationBar'; + +/** + * Helper function to create a shallow wrapper around AuthenticationLogout + */ +function createWrapper(props) { + return shallow( + <WindowAuthenticationBar + classes={{}} + hasLogoutService + confirmButton="Click here" + label="Log in to see more" + onConfirm={() => {}} + status="ok" + t={key => key} + windowId="w" + {...props} + />, + ); +} + +describe('AuthenticationControl', () => { + it('renders nothing if the user is logged in and there is no logout service', () => { + const wrapper = createWrapper({ hasLogoutService: false }); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders a non-collapsing version if there is no description', () => { + const wrapper = createWrapper({ description: undefined, header: undefined }); + expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('Log in to see more'); + expect(wrapper.find(Button).children().text()).toEqual('Click here'); + }); + + it('renders a collapsable version if there is a description', () => { + const onConfirm = jest.fn(); + const wrapper = createWrapper({ description: 'long description', header: 'header', onConfirm }); + expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('Log in to see more'); + expect(wrapper.find(Button).at(0).find('span').text()).toEqual('continue'); + // is expandable + expect(wrapper.find(Collapse).prop('in')).toEqual(false); + wrapper.find(Button).at(0).simulate('click'); + expect(wrapper.find(Collapse).prop('in')).toEqual(true); + + // has more information + expect(wrapper.find(Collapse).find(SanitizedHtml).at(0).props().htmlString).toEqual('header'); + expect(wrapper.find(Collapse).find(SanitizedHtml).at(1).props().htmlString).toEqual('long description'); + + // is recollapsable + wrapper.find(DialogActions).find(Button).at(0).simulate('click'); + expect(wrapper.find(Collapse).prop('in')).toEqual(false); + wrapper.find(Button).at(0).simulate('click'); + + // starts the auth process + wrapper.find(DialogActions).find(Button).at(1).simulate('click'); + expect(onConfirm).toHaveBeenCalled(); + }); + + it('triggers an action when the confirm button is clicked', () => { + const onConfirm = jest.fn(); + const wrapper = createWrapper({ + onConfirm, + }); + + wrapper.find(Button).simulate('click'); + expect(onConfirm).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/src/components/WindowAuthenticationControl.test.js b/__tests__/src/components/WindowAuthenticationControl.test.js deleted file mode 100644 index ffa31741da69f6c345cc8ee1de67ac0ba56e12d3..0000000000000000000000000000000000000000 --- a/__tests__/src/components/WindowAuthenticationControl.test.js +++ /dev/null @@ -1,122 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import Button from '@material-ui/core/Button'; -import Collapse from '@material-ui/core/Collapse'; -import DialogActions from '@material-ui/core/DialogActions'; -import SanitizedHtml from '../../../src/containers/SanitizedHtml'; -import { WindowAuthenticationControl } from '../../../src/components/WindowAuthenticationControl'; -import AuthenticationLogout from '../../../src/containers/AuthenticationLogout'; - -/** - * Helper function to create a shallow wrapper around WindowAuthenticationControl - */ -function createWrapper(props) { - return shallow( - <WindowAuthenticationControl - t={key => key} - classes={{}} - degraded - handleAuthInteraction={() => {}} - label="authenticate" - windowId="w" - profile="http://iiif.io/api/auth/1/login" - {...props} - />, - ); -} - -describe('WindowAuthenticationControl', () => { - let wrapper; - - it('renders AuthenticationLogout if it is not degraded', () => { - wrapper = createWrapper({ degraded: false }); - expect(wrapper.find(AuthenticationLogout).length).toBe(1); - }); - - describe('with a non-interactive login', () => { - it('renders failure messages', () => { - wrapper = createWrapper({ - degraded: true, - failureDescription: 'failure description', - failureHeader: 'failure header', - profile: 'http://iiif.io/api/auth/1/external', - status: 'failed', - }); - expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('failure header'); - expect(wrapper.find(SanitizedHtml).at(1).props().htmlString).toEqual('failure description'); - expect(wrapper.find(DialogActions)).toHaveLength(1); - expect(wrapper.find(DialogActions).find(Button)).toHaveLength(2); - expect(wrapper.find(DialogActions).find(Button).at(1).children() - .text()).toEqual('retry'); - }); - }); - - it('renders properly', () => { - wrapper = createWrapper({ confirmLabel: 'some confirm label', description: 'some description' }); - expect(wrapper.find(SanitizedHtml).at(2).props().htmlString).toEqual('some description'); - expect(wrapper.find(DialogActions).find(Button)).toHaveLength(2); - expect(wrapper.find(DialogActions).find(Button).at(1).children() - .text()).toEqual('some confirm label'); - }); - - it('hides the cancel button if there is nothing to collapose', () => { - wrapper = createWrapper({ classes: { topBar: 'topBar' }, confirmLabel: 'some confirm label' }); - expect(wrapper.find('.topBar').children().find(Button).at(0) - .children() - .text()).toEqual('some confirm label'); - - expect(wrapper.find(DialogActions).find(Button)).toHaveLength(0); - }); - - it('shows the auth dialog when the login button is clicked', () => { - wrapper = createWrapper({ classes: { topBar: 'topBar' }, description: 'some description' }); - wrapper.find('.topBar').props().onClick(); - expect(wrapper.find(Collapse).props().in).toEqual(true); - }); - - it('triggers an action when the confirm button is clicked', () => { - const handleAuthInteraction = jest.fn(); - wrapper = createWrapper({ - confirmLabel: 'some confirm label', - description: 'some description', - handleAuthInteraction, - infoId: 'i', - serviceId: 's', - }); - wrapper.instance().setState({ open: true }); - expect(wrapper.find(Collapse).props().in).toEqual(true); - - wrapper.find(DialogActions).find(Button).at(1).simulate('click'); - expect(handleAuthInteraction).toHaveBeenCalledWith('w', 'i', 's'); - }); - - it('displays a failure message if the login has failed', () => { - wrapper = createWrapper({ - failureDescription: 'failure description', - failureHeader: 'failure header', - status: 'failed', - }); - - expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('failure header'); - expect(wrapper.find(SanitizedHtml).at(1).props().htmlString).toEqual('failure description'); - - expect(wrapper.find(DialogActions).find(Button)).toHaveLength(2); - expect(wrapper.find(DialogActions).find(Button).at(1).children() - .text()).toEqual('login'); - }); - - it('displays the login messages if the user dismisses the failure messages', () => { - wrapper = createWrapper({ - failureDescription: 'failure description', - failureHeader: 'failure header', - status: 'failed', - }); - - expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('failure header'); - expect(wrapper.find(SanitizedHtml).at(1).props().htmlString).toEqual('failure description'); - - wrapper.find(DialogActions).find(Button).at(0).simulate('click'); - - expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('authenticate'); - }); -}); diff --git a/__tests__/src/reducers/accessTokens.test.js b/__tests__/src/reducers/accessTokens.test.js index 58211cc86f8eeeb0b587e57153ee767141f49277..020c450f649cf460bc7a896d52b455c60ef4a342 100644 --- a/__tests__/src/reducers/accessTokens.test.js +++ b/__tests__/src/reducers/accessTokens.test.js @@ -2,83 +2,15 @@ import { accessTokensReducer } from '../../../src/state/reducers/accessTokens'; import ActionTypes from '../../../src/state/actions/action-types'; describe('access tokens response reducer', () => { - describe('should handle RECEIVE_DEGRADED_INFO_RESPONSE', () => { - it('does nothing for a kiosk service', () => { - expect(accessTokensReducer({}, { - infoId: 'http://example.com', - infoJson: { - '@id': 'http://example.com', - service: { - profile: 'http://iiif.io/api/auth/1/kiosk', - }, - }, - type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE, - })).toEqual({}); - }); - it('does nothing if a external request for that token service is in flight', () => { - expect(accessTokensReducer( - { - token: { - isFetching: true, - }, - }, - { - infoId: 'http://example.com', - infoJson: { - '@id': 'http://example.com', - service: { - '@id': 'auth', - profile: 'http://iiif.io/api/auth/1/external', - service: { - '@id': 'token', - profile: 'http://iiif.io/api/auth/1/token', - }, - }, - }, - type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE, - }, - )).toEqual({ token: { isFetching: true } }); - }); - it('adds an entry for an external auth token service', () => { - expect(accessTokensReducer( - {}, - { - infoId: 'http://example.com', - infoJson: { - '@id': 'http://example.com', - service: { - '@id': 'auth', - profile: 'http://iiif.io/api/auth/1/external', - service: { - '@id': 'token', - profile: 'http://iiif.io/api/auth/1/token', - }, - }, - }, - ok: false, - type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE, - }, - )).toEqual({ - token: { - authId: 'auth', - id: 'token', - infoIds: ['http://example.com'], - isFetching: true, - }, - }); - }); - }); it('should handle REQUEST_ACCESS_TOKEN', () => { expect(accessTokensReducer({}, { authId: 'auth123', - infoIds: [1, 2, 3], serviceId: 'abc123', type: ActionTypes.REQUEST_ACCESS_TOKEN, })).toEqual({ abc123: { authId: 'auth123', id: 'abc123', - infoIds: [1, 2, 3], isFetching: true, }, }); diff --git a/__tests__/src/reducers/auth.test.js b/__tests__/src/reducers/auth.test.js index 6f3438b4bba157ee521cebe399574c9d8272c0a8..9817c15c160d95b62f481d45c555027f0d919007 100644 --- a/__tests__/src/reducers/auth.test.js +++ b/__tests__/src/reducers/auth.test.js @@ -2,74 +2,18 @@ import { authReducer } from '../../../src/state/reducers/auth'; import ActionTypes from '../../../src/state/actions/action-types'; describe('auth response reducer', () => { - describe('should handle RECEIVE_DEGRADED_INFO_RESPONSE', () => { - it('does nothing for a login service', () => { - expect(authReducer({}, { - infoId: 'http://example.com', - infoJson: { - '@id': 'http://example.com', - service: { - profile: 'http://iiif.io/api/auth/1/login', - }, - }, - type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE, - })).toEqual({}); - }); - it('does nothing if a kiosk/external request for that service is in flight', () => { - expect(authReducer( - { - auth: { - isFetching: true, - }, - }, - { - infoId: 'http://example.com', - infoJson: { - '@id': 'http://example.com', - service: { - '@id': 'auth', - profile: 'http://iiif.io/api/auth/1/kiosk', - }, - }, - type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE, - }, - )).toEqual({ auth: { isFetching: true } }); - }); - it('adds an entry for a kiosk/external service', () => { - expect(authReducer( - {}, - { - infoId: 'http://example.com', - infoJson: { - '@id': 'http://example.com', - service: { - '@id': 'auth', - profile: 'http://iiif.io/api/auth/1/kiosk', - }, - }, - ok: false, - type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE, - }, - )).toEqual({ - auth: { - id: 'auth', - infoId: ['http://example.com'], - isFetching: true, - profile: 'http://iiif.io/api/auth/1/kiosk', - }, - }); - }); - }); it('should handle ADD_AUTHENTICATION_REQUEST', () => { expect(authReducer({}, { id: 'abc123', - infoId: 1, + profile: 'iiif/login', type: ActionTypes.ADD_AUTHENTICATION_REQUEST, + windowId: 'main', })).toEqual({ abc123: { id: 'abc123', - infoId: [1], isFetching: true, + profile: 'iiif/login', + windowId: 'main', }, }); }); @@ -94,6 +38,14 @@ describe('auth response reducer', () => { }, }); }); + describe('should handle RECEIVE_ACCESS_TOKEN', () => { + it('does nothing if id is not present', () => { + expect(authReducer({ foo: {} }, { + authId: 'foo', + type: ActionTypes.RECEIVE_ACCESS_TOKEN, + })).toMatchObject({ foo: { ok: true } }); + }); + }); describe('should handle RESET_AUTHENTICATION_STATE', () => { it('does nothing if id is not present', () => { expect(authReducer({}, { diff --git a/__tests__/src/reducers/infoResponse.test.js b/__tests__/src/reducers/infoResponse.test.js index c340e72599e8f554bcf3b1aa21381be6b69ecbfc..73237ebac25302f43afd307ba3e3db85b95f26d4 100644 --- a/__tests__/src/reducers/infoResponse.test.js +++ b/__tests__/src/reducers/infoResponse.test.js @@ -92,27 +92,4 @@ describe('info response reducer', () => { type: ActionTypes.IMPORT_MIRADOR_STATE, })).toEqual({}); }); - it('should handle RESET_AUTHENTICATION_STATE', () => { - expect(infoResponsesReducer( - { - abc123: { - stuff: 'foo', - tokenServiceId: 'abc123', - }, - def456: { - stuff: 'foo', - tokenServiceId: 'def456', - }, - }, - { - tokenServiceId: 'abc123', - type: ActionTypes.RESET_AUTHENTICATION_STATE, - }, - )).toEqual({ - def456: { - stuff: 'foo', - tokenServiceId: 'def456', - }, - }); - }); }); diff --git a/__tests__/src/sagas/auth.test.js b/__tests__/src/sagas/auth.test.js new file mode 100644 index 0000000000000000000000000000000000000000..229e9956e3a166de4ab843e322029fb1a05b4e12 --- /dev/null +++ b/__tests__/src/sagas/auth.test.js @@ -0,0 +1,391 @@ +import { call, select } from 'redux-saga/effects'; +import { expectSaga } from 'redux-saga-test-plan'; +import { Utils } from 'manifesto.js/dist-esmodule/Utils'; +import serviceFixture from '../../fixtures/version-2/canvasService.json'; +import settings from '../../../src/config/settings'; +import ActionTypes from '../../../src/state/actions/action-types'; +import { + refetchInfoResponses, + refetchInfoResponsesOnLogout, + doAuthWorkflow, + rerequestOnAccessTokenFailure, + invalidateInvalidAuth, +} from '../../../src/state/sagas/auth'; +import { + fetchInfoResponse, +} from '../../../src/state/sagas/iiif'; +import { + getAccessTokens, + getWindows, + selectInfoResponses, + getVisibleCanvases, + getAuth, + getConfig, +} from '../../../src/state/selectors'; + +describe('IIIF Authentication sagas', () => { + describe('refetchInfoResponsesOnLogout', () => { + it('delays and then refetches info responses', () => { + const tokenServiceId = 'whatever'; + /** stub out delay... ugh. */ + const provideDelay = ({ fn }, next) => ((fn.name === 'delayP') ? null : next()); + + return expectSaga(refetchInfoResponsesOnLogout, { tokenServiceId }) + .provide([ + { call: provideDelay }, + [call(refetchInfoResponses, { serviceId: tokenServiceId }), {}], + ]) + .call(refetchInfoResponses, { serviceId: tokenServiceId }) + .run(); + }); + }); + describe('refetchInfoResponses', () => { + it('discards info responses that could hvae used the new access token', () => { + const serviceId = 'https://authentication.example.org/token'; + const tokenService = { id: serviceId }; + + const authStanza = { + service: [{ + '@context': 'http://iiif.io/api/auth/1/context.json', + '@id': 'https://authentication.example.org/login', + profile: 'http://iiif.io/api/auth/1/login', + service: [ + { + '@id': serviceId, + profile: 'http://iiif.io/api/auth/1/token', + }, + ], + }], + }; + + const x = { + id: 'x', + json: { + ...authStanza, + }, + }; + + const y = { + id: 'y', + json: { + ...authStanza, + }, + }; + + return expectSaga(refetchInfoResponses, { serviceId }) + .provide([ + [select(getAccessTokens), { [serviceId]: tokenService }], + [select(getWindows), {}], + [select(selectInfoResponses), { x, y }], + ]) + .put({ infoId: 'x', type: ActionTypes.REMOVE_INFO_RESPONSE }) + .put({ infoId: 'y', type: ActionTypes.REMOVE_INFO_RESPONSE }) + .run(); + }); + + it('ignores info responses that would not use the token', () => { + const serviceId = 'https://authentication.example.org/token'; + const tokenService = { id: serviceId }; + + const authStanza = { + service: [{ + '@context': 'http://iiif.io/api/auth/1/context.json', + '@id': 'https://authentication.example.org/login', + profile: 'http://iiif.io/api/auth/1/login', + service: [ + { + '@id': 'https://authentication.example.org/some-other-token-service', + profile: 'http://iiif.io/api/auth/1/token', + }, + ], + }], + }; + + const wrongService = { + id: 'wrongService', + json: { + ...authStanza, + }, + }; + + const noAuth = { + id: 'noAuth', + json: {}, + }; + + const noJson = { + id: 'noJson', + }; + + return expectSaga(refetchInfoResponses, { serviceId }) + .provide([ + [select(getAccessTokens), { [serviceId]: tokenService }], + [select(getWindows), {}], + [select(selectInfoResponses), { noAuth, noJson, wrongService }], + ]) + .not.put({ infoId: 'noAuth', type: ActionTypes.REMOVE_INFO_RESPONSE }) + .not.put({ infoId: 'noJson', type: ActionTypes.REMOVE_INFO_RESPONSE }) + .not.put({ infoId: 'wrongService', type: ActionTypes.REMOVE_INFO_RESPONSE }) + .run(); + }); + + it('re-requests info responses for visible canvases', () => { + const serviceId = 'https://authentication.example.org/token'; + const tokenService = { id: serviceId }; + + const authStanza = { + service: [{ + '@context': 'http://iiif.io/api/auth/1/context.json', + '@id': 'https://authentication.example.org/login', + profile: 'http://iiif.io/api/auth/1/login', + service: [ + { + '@id': serviceId, + profile: 'http://iiif.io/api/auth/1/token', + }, + ], + }], + }; + + const window = {}; + const canvases = [ + Utils.parseManifest(serviceFixture).getSequences()[0].getCanvases()[0], + ]; + + const iiifInfoId = 'https://api.digitale-sammlungen.de/iiif/image/v2/bsb00122140_00001'; + const infoResponse = { + id: iiifInfoId, + json: { + ...authStanza, + }, + }; + + return expectSaga(refetchInfoResponses, { serviceId }) + .provide([ + [select(getAccessTokens), { [serviceId]: tokenService }], + [select(getWindows), { window }], + [select(getVisibleCanvases, { windowId: 'window' }), canvases], + [select(selectInfoResponses), { [iiifInfoId]: infoResponse }], + [call(fetchInfoResponse, { infoId: iiifInfoId }), {}], + ]) + .call(fetchInfoResponse, { infoId: iiifInfoId }) + .run(); + }); + }); + describe('doAuthWorkflow', () => { + it('kicks off the first external auth from the info.json', () => { + const infoJson = { + service: [{ + '@context': 'http://iiif.io/api/auth/1/context.json', + '@id': 'https://authentication.example.com/external', + profile: 'http://iiif.io/api/auth/1/external', + service: [ + { + '@id': 'https://authentication.example.com/token', + profile: 'http://iiif.io/api/auth/1/token', + }, + ], + }], + }; + const windowId = 'window'; + return expectSaga(doAuthWorkflow, { infoJson, windowId }) + .provide([ + [select(getAuth), {}], + [select(getConfig), { auth: settings.auth }], + ]) + .put({ + id: 'https://authentication.example.com/external', + tokenServiceId: 'https://authentication.example.com/token', + type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST, + }) + .put({ + authId: 'https://authentication.example.com/external', + serviceId: 'https://authentication.example.com/token', + type: ActionTypes.REQUEST_ACCESS_TOKEN, + }) + .run(); + }); + + it('does nothing if the auth service has been tried already', () => { + const infoJson = { + service: [{ + '@context': 'http://iiif.io/api/auth/1/context.json', + '@id': 'https://authentication.example.com/external', + profile: 'http://iiif.io/api/auth/1/external', + service: [ + { + '@id': 'https://authentication.example.com/token', + profile: 'http://iiif.io/api/auth/1/token', + }, + ], + }], + }; + const windowId = 'window'; + return expectSaga(doAuthWorkflow, { infoJson, windowId }) + .provide([ + [select(getAuth), { 'https://authentication.example.com/external': { ok: false } }], + [select(getConfig), { auth: settings.auth }], + ]) + .not.put.like({ type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST }) + .not.put.like({ type: ActionTypes.REQUEST_ACCESS_TOKEN }) + .run(); + }); + + it('does nothing if the auth service is "interactive"', () => { + const infoJson = { + service: [{ + '@context': 'http://iiif.io/api/auth/1/context.json', + '@id': 'https://authentication.example.com/login', + profile: 'http://iiif.io/api/auth/1/login', + service: [ + { + '@id': 'https://authentication.example.com/token', + profile: 'http://iiif.io/api/auth/1/token', + }, + ], + }], + }; + const windowId = 'window'; + return expectSaga(doAuthWorkflow, { infoJson, windowId }) + .provide([ + [select(getAuth), {}], + [select(getConfig), { auth: settings.auth }], + ]) + .not.put.like({ type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST }) + .not.put.like({ type: ActionTypes.REQUEST_ACCESS_TOKEN }) + .run(); + }); + + it('kicks off the kiosk auth from the info.json', () => { + const infoJson = { + service: [{ + '@context': 'http://iiif.io/api/auth/1/context.json', + '@id': 'https://authentication.example.com/kiosk', + profile: 'http://iiif.io/api/auth/1/kiosk', + service: [ + { + '@id': 'https://authentication.example.com/token', + profile: 'http://iiif.io/api/auth/1/token', + }, + ], + }], + }; + const windowId = 'window'; + return expectSaga(doAuthWorkflow, { infoJson, windowId }) + .provide([ + [select(getAuth), {}], + [select(getConfig), { auth: settings.auth }], + ]) + .put({ + id: 'https://authentication.example.com/kiosk', + profile: 'http://iiif.io/api/auth/1/kiosk', + type: ActionTypes.ADD_AUTHENTICATION_REQUEST, + windowId, + }) + .run(); + }); + }); + + describe('rerequestOnAccessTokenFailure', () => { + it('does nothing if no access token was used', () => { + const infoJson = {}; + const windowId = 'window'; + const tokenServiceId = undefined; + return expectSaga(rerequestOnAccessTokenFailure, { infoJson, tokenServiceId, windowId }) + .provide([ + [select(getAccessTokens), {}], + ]) + .not.put.like({ type: ActionTypes.REQUEST_ACCESS_TOKEN }) + .run(); + }); + + it('does nothing if the access token has never worked', () => { + const infoJson = { + service: [{ + '@context': 'http://iiif.io/api/auth/1/context.json', + '@id': 'https://authentication.example.com/kiosk', + profile: 'http://iiif.io/api/auth/1/kiosk', + service: [ + { + '@id': 'https://authentication.example.com/token', + profile: 'http://iiif.io/api/auth/1/token', + }, + ], + }], + }; + const windowId = 'window'; + const tokenServiceId = 'https://authentication.example.com/token'; + return expectSaga(rerequestOnAccessTokenFailure, { infoJson, tokenServiceId, windowId }) + .provide([ + [select(getAccessTokens), { [tokenServiceId]: { success: false } }], + ]) + .not.put.like({ type: ActionTypes.REQUEST_ACCESS_TOKEN }) + .run(); + }); + + it('re-requests the access token if it might be reneweable', () => { + const infoJson = { + service: [{ + '@context': 'http://iiif.io/api/auth/1/context.json', + '@id': 'https://authentication.example.com/kiosk', + profile: 'http://iiif.io/api/auth/1/kiosk', + service: [ + { + '@id': 'https://authentication.example.com/token', + profile: 'http://iiif.io/api/auth/1/token', + }, + ], + }], + }; + const windowId = 'window'; + const tokenServiceId = 'https://authentication.example.com/token'; + return expectSaga(rerequestOnAccessTokenFailure, { infoJson, tokenServiceId, windowId }) + .provide([ + [select(getAccessTokens), { [tokenServiceId]: { success: true } }], + ]) + .put({ + authId: 'https://authentication.example.com/kiosk', + serviceId: 'https://authentication.example.com/token', + type: ActionTypes.REQUEST_ACCESS_TOKEN, + }) + .run(); + }); + }); + + describe('invalidateInvalidAuth', () => { + it('resets the auth service if the auth cookie might have expired', () => { + const authId = 'authId'; + const serviceId = 'serviceId'; + + return expectSaga(invalidateInvalidAuth, { serviceId }) + .provide([ + [select(getAccessTokens), { [serviceId]: { authId, id: serviceId, success: true } }], + [select(getAuth), { [authId]: { id: authId } }], + ]) + .put({ + id: authId, + tokenServiceId: serviceId, + type: ActionTypes.RESET_AUTHENTICATION_STATE, + }) + .run(); + }); + + it('marks the auth service as failed if the auth token was not successfully used', () => { + const authId = 'authId'; + const serviceId = 'serviceId'; + + return expectSaga(invalidateInvalidAuth, { serviceId }) + .provide([ + [select(getAccessTokens), { [serviceId]: { authId, id: serviceId } }], + [select(getAuth), { [authId]: { id: authId } }], + ]) + .put({ + id: authId, + ok: false, + tokenServiceId: serviceId, + type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST, + }) + .run(); + }); + }); +}); diff --git a/__tests__/src/sagas/iiif.test.js b/__tests__/src/sagas/iiif.test.js index c1dea0d21865d0a0f725bded7a001dc63045557d..2172dd663b97a4b032cd67070c1c2ace918e6c1f 100644 --- a/__tests__/src/sagas/iiif.test.js +++ b/__tests__/src/sagas/iiif.test.js @@ -1,11 +1,10 @@ -import { call, select } from 'redux-saga/effects'; -import { expectSaga, testSaga } from 'redux-saga-test-plan'; +import { select } from 'redux-saga/effects'; +import { expectSaga } from 'redux-saga-test-plan'; import { fetchAnnotation, fetchManifest, fetchSearchResponse, fetchInfoResponse, - refetchInfoResponses, fetchResourceManifest, } from '../../../src/state/sagas/iiif'; import { @@ -167,6 +166,7 @@ describe('IIIF sagas', () => { const action = { imageResource: {}, infoId: 'infoId', + windowId: 'window', }; return expectSaga(fetchInfoResponse, action) @@ -176,6 +176,7 @@ describe('IIIF sagas', () => { ok: true, tokenServiceId: undefined, type: 'mirador/RECEIVE_DEGRADED_INFO_RESPONSE', + windowId: 'window', }) .run(); }); @@ -217,24 +218,6 @@ describe('IIIF sagas', () => { }); }); - describe('refetchInfoResponses', () => { - it('refetches info responses when a new access token is available', () => { - const serviceId = 'serviceId'; - const tokenService = { id: serviceId, infoIds: ['x', 'y'] }; - - testSaga(refetchInfoResponses, { serviceId }) - .next() - .select(getAccessTokens) - .next({ serviceId: tokenService }) - .all([ - call(fetchInfoResponse, { infoId: 'x', tokenService }), - call(fetchInfoResponse, { infoId: 'y', tokenService }), - ]) - .next() - .put({ serviceId, type: 'mirador/CLEAR_ACCESS_TOKEN_QUEUE' }); - }); - }); - describe('fetchSearchResponse', () => { it('fetches a IIIF search', () => { fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); diff --git a/__tests__/src/selectors/auth.test.js b/__tests__/src/selectors/auth.test.js index 698b32115d1c2f8986e10b482660c19dcef7624f..2071e993177a68d8b536f9aade9d58971914c2d3 100644 --- a/__tests__/src/selectors/auth.test.js +++ b/__tests__/src/selectors/auth.test.js @@ -1,5 +1,9 @@ +import manifestFixture001 from '../../fixtures/version-2/001.json'; +import manifestFixture019 from '../../fixtures/version-2/019.json'; +import settings from '../../../src/config/settings'; import { getAccessTokens, + selectCurrentAuthServices, } from '../../../src/state/selectors/auth'; describe('getAccessTokens', () => { @@ -16,3 +20,178 @@ describe('getAccessTokens', () => { expect(accessTokens).toEqual(state.accessTokens); }); }); + +describe('selectCurrentAuthServices', () => { + const resource = { + service: [ + { + '@id': 'external', + profile: 'http://iiif.io/api/auth/1/external', + }, + { + '@id': 'login', + profile: 'http://iiif.io/api/auth/1/login', + }, + ], + }; + const externalOnly = { + service: [ + { + '@id': 'external', + profile: 'http://iiif.io/api/auth/1/external', + }, + ], + }; + + const state = { + auth: {}, + config: { auth: settings.auth }, + infoResponses: { + 'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': { + json: resource, + }, + 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44': { + json: externalOnly, + }, + }, + manifests: { + a: { + json: manifestFixture001, + }, + b: { + json: manifestFixture019, + }, + }, + windows: { + noCanvas: { + manifestId: 'a', + }, + w: { + manifestId: 'a', + visibleCanvases: [ + 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json', + ], + }, + x: { + manifestId: 'b', + visibleCanvases: [ + 'http://iiif.io/api/presentation/2.0/example/fixtures/canvas/24/c1.json', + ], + }, + y: { + manifestId: 'b', + visibleCanvases: [ + 'https://purl.stanford.edu/fr426cg9537/iiif/canvas/fr426cg9537_1', + ], + }, + }, + }; + + it('returns undefined if there is no current canvas', () => { + expect(selectCurrentAuthServices({ config: { auth: settings.auth }, manifests: {} }, { windowId: 'noCanvas' })[0]).toBeUndefined(); + }); + + it('returns the next auth service to try', () => { + expect(selectCurrentAuthServices(state, { windowId: 'w' })[0].id).toEqual('external'); + }); + + it('returns the service if the next auth service is interactive', () => { + const auth = { external: { isFetching: false, ok: false } }; + expect(selectCurrentAuthServices({ ...state, auth }, { windowId: 'w' })[0].id).toEqual('login'); + }); + + it('returns the last attempted auth service if all of them have been tried', () => { + const auth = { + external: { isFetching: false, ok: false }, + login: { isFetching: false, ok: false }, + }; + expect(selectCurrentAuthServices({ ...state, auth }, { windowId: 'w' })[0].id).toEqual('login'); + expect(selectCurrentAuthServices({ ...state, auth }, { windowId: 'x' })[0].id).toEqual('external'); + expect(selectCurrentAuthServices({ ...state, auth }, { windowId: 'y' })[0]).toBeUndefined(); + }); + + describe('proscribed order', () => { + let auth = {}; + const orderedState = { + config: { auth: settings.auth }, + infoResponses: { + 'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': { + json: { + service: [ + { + '@id': 'external', + profile: 'http://iiif.io/api/auth/1/external', + }, + { + '@id': 'kiosk', + profile: 'http://iiif.io/api/auth/1/kiosk', + }, + { + '@id': 'clickthrough', + profile: 'http://iiif.io/api/auth/1/clickthrough', + }, + { + '@id': 'login', + profile: 'http://iiif.io/api/auth/1/login', + }, + { + '@id': 'login2', + profile: 'http://iiif.io/api/auth/1/login', + }, + ], + }, + }, + }, + manifests: { + a: { + json: manifestFixture001, + }, + }, + windows: { + w: { + manifestId: 'a', + visibleCanvases: [ + 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json', + ], + }, + }, + }; + + it('returns external first', () => { + auth = {}; + expect(selectCurrentAuthServices({ ...orderedState, auth }, { windowId: 'w' })[0].id).toEqual('external'); + }); + + it('returns kiosk next', () => { + auth = { external: { isFetching: false, ok: false } }; + expect(selectCurrentAuthServices({ ...orderedState, auth }, { windowId: 'w' })[0].id).toEqual('kiosk'); + }); + + it('returns clickthrough next', () => { + auth = { + external: { isFetching: false, ok: false }, + kiosk: { isFetching: false, ok: false }, + }; + expect(selectCurrentAuthServices({ ...orderedState, auth }, { windowId: 'w' })[0].id).toEqual('clickthrough'); + }); + + it('returns logins last', () => { + auth = { + clickthrough: { isFetching: false, ok: false }, + external: { isFetching: false, ok: false }, + kiosk: { isFetching: false, ok: false }, + }; + expect(selectCurrentAuthServices({ ...orderedState, auth }, { windowId: 'w' })[0].id).toEqual('login'); + }); + + it('returns services within a given type using the order from the manifest', () => { + auth = { + clickthrough: { isFetching: false, ok: false }, + external: { isFetching: false, ok: false }, + kiosk: { isFetching: false, ok: false }, + login: { isFetching: false, ok: false }, + }; + expect(selectCurrentAuthServices({ ...orderedState, auth }, { windowId: 'w' })[0].id).toEqual('login2'); + }); + }); +}); diff --git a/__tests__/src/selectors/canvases.test.js b/__tests__/src/selectors/canvases.test.js index 50cd4530b77f625412a91b93c766aedddda7ca63..c6dcf8145bbf416670ddda930b6dcffe04cd8a93 100644 --- a/__tests__/src/selectors/canvases.test.js +++ b/__tests__/src/selectors/canvases.test.js @@ -2,6 +2,7 @@ import manifestFixture001 from '../../fixtures/version-2/001.json'; import manifestFixture019 from '../../fixtures/version-2/019.json'; import minimumRequired from '../../fixtures/version-2/minimumRequired.json'; import minimumRequired3 from '../../fixtures/version-3/minimumRequired.json'; +import settings from '../../../src/config/settings'; import { getVisibleCanvases, @@ -9,11 +10,8 @@ import { getPreviousCanvasGrouping, getCanvas, getCanvasLabel, - selectCanvasAuthService, - selectNextAuthService, selectInfoResponse, getVisibleCanvasNonTiledResources, - selectLogoutAuthService, getVisibleCanvasIds, } from '../../../src/state/selectors/canvases'; @@ -251,185 +249,13 @@ describe('getCanvasLabel', () => { }); }); -describe('selectNextAuthService', () => { - const auth = {}; - const resource = { - service: [ - { - '@id': 'external', - profile: 'http://iiif.io/api/auth/1/external', - }, - { - '@id': 'kiosk', - profile: 'http://iiif.io/api/auth/1/kiosk', - }, - { - '@id': 'clickthrough', - profile: 'http://iiif.io/api/auth/1/clickthrough', - }, - { - '@id': 'login', - profile: 'http://iiif.io/api/auth/1/login', - }, - { - '@id': 'login2', - profile: 'http://iiif.io/api/auth/1/login', - }, - ], - }; - - const noAuthResource = {}; - - it('returns external first', () => { - expect(selectNextAuthService({ auth }, resource).id).toEqual('external'); - }); - - it('returns kiosk next', () => { - auth.external = { isFetching: false, ok: false }; - expect(selectNextAuthService({ auth }, resource).id).toEqual('kiosk'); - }); - - it('returns clickthrough next', () => { - auth.external = { isFetching: false, ok: false }; - auth.kiosk = { isFetching: false, ok: false }; - expect(selectNextAuthService({ auth }, resource).id).toEqual('clickthrough'); - }); - - it('returns logins last', () => { - auth.external = { isFetching: false, ok: false }; - auth.kiosk = { isFetching: false, ok: false }; - auth.clickthrough = { isFetching: false, ok: false }; - expect(selectNextAuthService({ auth }, resource).id).toEqual('login'); - auth.login = { isFetching: false, ok: false }; - expect(selectNextAuthService({ auth }, resource).id).toEqual('login2'); - }); - - it('returns null if there are no services', () => { - expect(selectNextAuthService({ auth }, noAuthResource)).toBeNull(); - }); - - it('returns null if a service is currently in-flight', () => { - auth.external = { isFetching: true }; - expect(selectNextAuthService({ auth }, resource)).toBeNull(); - }); -}); - -describe('selectCanvasAuthService', () => { - const resource = { - service: [ - { - '@id': 'external', - profile: 'http://iiif.io/api/auth/1/external', - }, - { - '@id': 'login', - profile: 'http://iiif.io/api/auth/1/login', - }, - ], - }; - const externalOnly = { - service: [ - { - '@id': 'external', - profile: 'http://iiif.io/api/auth/1/external', - }, - ], - }; - - const state = { - auth: {}, - infoResponses: { - 'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': { - json: resource, - }, - 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44': { - json: externalOnly, - }, - }, - manifests: { - a: { - json: manifestFixture001, - }, - b: { - json: manifestFixture019, - }, - }, - }; - - it('returns undefined if there is no current canvas', () => { - expect(selectCanvasAuthService({ manifests: {} }, { manifestId: 'a' })).toBeUndefined(); - }); - - it('returns the next auth service to try', () => { - expect(selectCanvasAuthService(state, { canvasId: 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json', manifestId: 'a' }).id).toEqual('external'); - }); - - it('returns the service if the next auth service is interactive', () => { - const auth = { external: { isFetching: false, ok: false } }; - expect(selectCanvasAuthService({ ...state, auth }, { canvasId: 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json', manifestId: 'a' }).id).toEqual('login'); - }); - - it('returns the last attempted auth service if all of them have been tried', () => { - const auth = { - external: { isFetching: false, ok: false }, - login: { isFetching: false, ok: false }, - }; - expect(selectCanvasAuthService({ ...state, auth }, { canvasId: 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json', manifestId: 'a' }).id).toEqual('login'); - expect(selectCanvasAuthService({ ...state, auth }, { canvasId: 'http://iiif.io/api/presentation/2.0/example/fixtures/canvas/24/c1.json', manifestId: 'b' }).id).toEqual('external'); - expect(selectCanvasAuthService({ ...state, auth }, { canvasId: 'https://purl.stanford.edu/fr426cg9537/iiif/canvas/fr426cg9537_1', manifestId: 'b' })).toBeUndefined(); - }); -}); - -describe('selectLogoutAuthService', () => { - it('returns a logout auth service if one exists', () => { - const logout = { - '@id': 'http://foo/logout', - profile: 'http://iiif.io/api/auth/1/logout', - }; - const resource = { - service: [ - { - '@id': 'login', - profile: 'http://iiif.io/api/auth/1/login', - service: [ - logout, - ], - }, - ], - }; - const state = { - auth: { - login: { - ok: true, - }, - }, - infoResponses: { - 'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': { - json: resource, - }, - }, - manifests: { - a: { - json: manifestFixture001, - }, - }, - }; - expect( - selectLogoutAuthService( - state, - { canvasId: 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json', manifestId: 'a' }, - ).id, - ) - .toBe(logout['@id']); - }); -}); - describe('selectInfoResponse', () => { it('returns in the info response for the first canvas resource', () => { const resource = { some: 'resource' }; const state = { auth: {}, + config: { auth: settings.auth }, infoResponses: { 'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': { json: resource, diff --git a/src/components/AccessTokenSender.js b/src/components/AccessTokenSender.js index ef2a58f73f206379668399ae1af266e1b1898df4..2760b012f1a06e992e9d802da45e47c6ce2c779e 100644 --- a/src/components/AccessTokenSender.js +++ b/src/components/AccessTokenSender.js @@ -30,7 +30,13 @@ export class AccessTokenSender extends Component { */ return ( <IComCom - attributes={{ src: `${url}?origin=${window.origin}&messageId=${url}` }} + attributes={{ + 'aria-hidden': true, + height: 1, + src: `${url}?origin=${window.origin}&messageId=${url}`, + style: { visibility: 'hidden' }, + width: 1, + }} handleReceiveMessage={this.onReceiveAccessTokenMessage} /> ); diff --git a/src/components/App.js b/src/components/App.js index a415d6ff823e2b900aa84345f993ccf874858530..ed42b7aabf312e6b8df56865f8bb23311b8d06c8 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -2,8 +2,6 @@ import React, { Component, lazy, Suspense } from 'react'; import PropTypes from 'prop-types'; import PluginProvider from '../extend/PluginProvider'; import AppProviders from '../containers/AppProviders'; -import AuthenticationSender from '../containers/AuthenticationSender'; -import AccessTokenSender from '../containers/AccessTokenSender'; const WorkspaceArea = lazy(() => import('../containers/WorkspaceArea')); @@ -22,8 +20,6 @@ export class App extends Component { return ( <PluginProvider plugins={plugins}> <AppProviders dndManager={dndManager}> - <AuthenticationSender /> - <AccessTokenSender /> <Suspense fallback={<div />} > diff --git a/src/components/AuthenticationLogout.js b/src/components/AuthenticationLogout.js deleted file mode 100644 index 82c37de15f562c0053fa00a740d31ada12b1bfbb..0000000000000000000000000000000000000000 --- a/src/components/AuthenticationLogout.js +++ /dev/null @@ -1,57 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import Fab from '@material-ui/core/Fab'; - -/** - * - */ -export class AuthenticationLogout extends Component { - /** */ - constructor(props) { - super(props); - - this.handleLogout = this.handleLogout.bind(this); - } - - /** */ - handleLogout() { - const { - authServiceId, depWindow, logoutServiceId, resetAuthenticationState, - } = this.props; - (depWindow || window).open(logoutServiceId); - - resetAuthenticationState({ authServiceId }); - } - - /** */ - render() { - const { - label, status, t, - } = this.props; - if (status !== 'ok') return <></>; - return ( - <Fab color="primary" variant="extended" onClick={this.handleLogout}> - {label || t('logout')} - </Fab> - ); - } -} - -AuthenticationLogout.propTypes = { - authServiceId: PropTypes.string, - depWindow: PropTypes.object, // eslint-disable-line react/forbid-prop-types - label: PropTypes.string, - logoutServiceId: PropTypes.string, - resetAuthenticationState: PropTypes.func.isRequired, - status: PropTypes.string, - t: PropTypes.func, -}; - -AuthenticationLogout.defaultProps = { - authServiceId: undefined, - depWindow: undefined, - label: undefined, - logoutServiceId: undefined, - status: undefined, - t: () => {}, -}; diff --git a/src/components/AuthenticationSender.js b/src/components/AuthenticationSender.js deleted file mode 100644 index e277838069e5f7a88ed2d34f0609a4edec046962..0000000000000000000000000000000000000000 --- a/src/components/AuthenticationSender.js +++ /dev/null @@ -1,40 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { NewWindow } from './NewWindow'; - -/** - * Opens a new window for click - */ -export class AuthenticationSender extends Component { - /** */ - constructor(props) { - super(props); - - this.onClose = this.onClose.bind(this); - } - - /** @private */ - onClose() { - const { handleInteraction, url } = this.props; - - handleInteraction(url); - } - - /** */ - render() { - const { url } = this.props; - - if (!url) return <></>; - - return <NewWindow name="IiifAuthenticationSender" url={`${url}?origin=${window.origin}`} features="centerscreen" onClose={this.onClose} />; - } -} - -AuthenticationSender.propTypes = { - handleInteraction: PropTypes.func.isRequired, - url: PropTypes.string, -}; - -AuthenticationSender.defaultProps = { - url: undefined, -}; diff --git a/src/components/IIIFAuthentication.js b/src/components/IIIFAuthentication.js new file mode 100644 index 0000000000000000000000000000000000000000..b3ed603f2878c1b4cb48f1427b496bf9211cc443 --- /dev/null +++ b/src/components/IIIFAuthentication.js @@ -0,0 +1,190 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { AccessTokenSender } from './AccessTokenSender'; +import { NewWindow } from './NewWindow'; +import WindowAuthenticationBar from '../containers/WindowAuthenticationBar'; + +/** + * Opens a new window for click + */ +export class IIIFAuthentication extends Component { + /** */ + constructor(props) { + super(props); + + this.performLogout = this.performLogout.bind(this); + this.onReceiveAccessTokenMessage = this.onReceiveAccessTokenMessage.bind(this); + } + + /** */ + onReceiveAccessTokenMessage(payload) { + const { + authServiceId, accessTokenServiceId, resolveAccessTokenRequest, + } = this.props; + + resolveAccessTokenRequest(authServiceId, accessTokenServiceId, payload); + } + + /** */ + defaultAuthBarProps() { + const { + authServiceId, windowId, status, logoutServiceId, + } = this.props; + + return { + authServiceId, + hasLogoutService: !!logoutServiceId, + status, + windowId, + }; + } + + /** handle the IIIF logout workflow */ + performLogout() { + const { + accessTokenServiceId, authServiceId, features, + logoutServiceId, resetAuthenticationState, openWindow, + } = this.props; + openWindow(logoutServiceId, undefined, features); + + resetAuthenticationState({ authServiceId, tokenServiceId: accessTokenServiceId }); + } + + /** Render the auth bar for logged in users */ + renderLoggedIn() { + const { + isInteractive, t, + } = this.props; + + if (!isInteractive) return null; + + return ( + <WindowAuthenticationBar + confirmButton={t('logout')} + onConfirm={this.performLogout} + {...this.defaultAuthBarProps()} + /> + ); + } + + /** Render whatever shows up after the interactive login attempt fails */ + renderFailure() { + const { + handleAuthInteraction, failureHeader: header, failureDescription: description, t, + authServiceId, windowId, + } = this.props; + + return ( + <WindowAuthenticationBar + header={header} + description={description} + confirmButton={t('retry')} + onConfirm={() => handleAuthInteraction(windowId, authServiceId)} + {...this.defaultAuthBarProps()} + /> + ); + } + + /** Render the login bar after we're logging in */ + renderLoggingInCookie() { + const { + accessTokenServiceId, authServiceId, resolveAuthenticationRequest, features, + } = this.props; + + return ( + <> + {this.renderLogin()} + <NewWindow name="IiifLoginSender" url={`${authServiceId}?origin=${window.origin}`} features={features} onClose={() => resolveAuthenticationRequest(authServiceId, accessTokenServiceId)} /> + </> + ); + } + + /** Render the login bar after we're logging in */ + renderLoggingInToken() { + const { + accessTokenServiceId, + } = this.props; + + return ( + <> + {this.renderLogin()} + <AccessTokenSender + handleAccessTokenMessage={this.onReceiveAccessTokenMessage} + url={accessTokenServiceId} + /> + </> + ); + } + + /** Render a login bar */ + renderLogin() { + const { + confirm, description, handleAuthInteraction, header, isInteractive, label, + authServiceId, windowId, + } = this.props; + if (!isInteractive) return null; + + return ( + <WindowAuthenticationBar + header={header} + description={description} + label={label} + confirmButton={confirm} + onConfirm={() => handleAuthInteraction(windowId, authServiceId)} + {...this.defaultAuthBarProps()} + /> + ); + } + + /** */ + render() { + const { authServiceId, status } = this.props; + + if (!authServiceId) return null; + + if (status === null) return this.renderLogin(); + if (status === 'cookie') return this.renderLoggingInCookie(); + if (status === 'token') return this.renderLoggingInToken(); + if (status === 'failed') return this.renderFailure(); + if (status === 'ok') return this.renderLoggedIn(); + + return null; + } +} + +IIIFAuthentication.propTypes = { + accessTokenServiceId: PropTypes.string.isRequired, + authServiceId: PropTypes.string.isRequired, + confirm: PropTypes.string, + description: PropTypes.string, + failureDescription: PropTypes.string, + failureHeader: PropTypes.string, + features: PropTypes.string, + handleAuthInteraction: PropTypes.func.isRequired, + header: PropTypes.string, + isInteractive: PropTypes.bool, + label: PropTypes.string, + logoutServiceId: PropTypes.string, + openWindow: PropTypes.func, + resetAuthenticationState: PropTypes.func.isRequired, + resolveAccessTokenRequest: PropTypes.func.isRequired, + resolveAuthenticationRequest: PropTypes.func.isRequired, + status: PropTypes.oneOf(['logout', 'ok', 'token', 'cookie', 'failed', null]), + t: PropTypes.func, + windowId: PropTypes.string.isRequired, +}; + +IIIFAuthentication.defaultProps = { + confirm: undefined, + description: undefined, + failureDescription: undefined, + failureHeader: undefined, + features: 'centerscreen', + header: undefined, + isInteractive: true, + label: undefined, + logoutServiceId: undefined, + openWindow: window.open, + status: null, + t: k => k, +}; diff --git a/src/components/OpenSeadragonViewer.js b/src/components/OpenSeadragonViewer.js index 60cf942939fd315d7260304bc21159ce4c8fb343..7c3244bf072bfc8ebead0d147e3939a5f8ec3ab3 100644 --- a/src/components/OpenSeadragonViewer.js +++ b/src/components/OpenSeadragonViewer.js @@ -286,6 +286,10 @@ export class OpenSeadragonViewer extends Component { return false; } + if (infoResponse.tokenServiceId !== prevInfoResponses[index].tokenServiceId) { + return false; + } + if (infoResponse.json['@id'] && infoResponse.json['@id'] === prevInfoResponses[index].json['@id']) { return true; diff --git a/src/components/Window.js b/src/components/Window.js index 62466dd06d74ee869da7f31cbf7d73acd6e39f50..56d3bd45e6c893b89638f30836c3aaccf1c771ed 100644 --- a/src/components/Window.js +++ b/src/components/Window.js @@ -9,7 +9,7 @@ import PrimaryWindow from '../containers/PrimaryWindow'; import CompanionArea from '../containers/CompanionArea'; import MinimalWindow from '../containers/MinimalWindow'; import ErrorContent from '../containers/ErrorContent'; -import WindowAuthenticationControl from '../containers/WindowAuthenticationControl'; +import IIIFAuthentication from '../containers/IIIFAuthentication'; import { PluginHook } from './PluginHook'; /** @@ -44,7 +44,7 @@ export class Window extends Component { windowId={windowId} windowDraggable={windowDraggable} /> - <WindowAuthenticationControl key="auth" windowId={windowId} /> + <IIIFAuthentication windowId={windowId} /> </div> ); if (workspaceType === 'mosaic' && windowDraggable) { diff --git a/src/components/WindowAuthenticationBar.js b/src/components/WindowAuthenticationBar.js new file mode 100644 index 0000000000000000000000000000000000000000..c0765eacf06705cbc911c718bd24be80f866a3e9 --- /dev/null +++ b/src/components/WindowAuthenticationBar.js @@ -0,0 +1,133 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import Button from '@material-ui/core/Button'; +import Paper from '@material-ui/core/Paper'; +import Collapse from '@material-ui/core/Collapse'; +import DialogActions from '@material-ui/core/DialogActions'; +import Typography from '@material-ui/core/Typography'; +import LockIcon from '@material-ui/icons/LockSharp'; +import SanitizedHtml from '../containers/SanitizedHtml'; +import { PluginHook } from './PluginHook'; + +/** */ +export class WindowAuthenticationBar extends Component { + /** */ + constructor(props) { + super(props); + + this.state = { open: false }; + this.setOpen = this.setOpen.bind(this); + this.onSubmit = this.onSubmit.bind(this); + } + + /** */ + onSubmit() { + const { onConfirm } = this.props; + this.setOpen(false); + onConfirm(); + } + + /** Toggle the full description */ + setOpen(open) { + this.setState(state => ({ ...state, open })); + } + + /** */ + render() { + const { + classes, confirmButton, continueLabel, + header, description, icon, label, t, + ruleSet, hasLogoutService, status, ConfirmProps, + } = this.props; + + if (status === 'ok' && !hasLogoutService) return null; + + const { open } = this.state; + + const button = ( + <Button onClick={this.onSubmit} className={classes.buttonInvert} autoFocus color="secondary" {...ConfirmProps}> + {confirmButton || t('login')} + </Button> + ); + + if (!description && !header) { + return ( + <Paper square elevation={4} color="secondary" classes={{ root: classes.paper }}> + <div className={classes.topBar}> + { icon || <LockIcon className={classes.icon} /> } + <Typography className={classes.label} component="h3" variant="body1" color="inherit"> + <SanitizedHtml htmlString={label} ruleSet={ruleSet} /> + </Typography> + <PluginHook {...this.props} /> + { button } + </div> + </Paper> + ); + } + + return ( + <Paper square elevation={4} color="secondary" classes={{ root: classes.paper }}> + <Button fullWidth className={classes.topBar} onClick={() => this.setOpen(true)} component="div" color="inherit"> + { icon || <LockIcon className={classes.icon} /> } + <Typography className={classes.label} component="h3" variant="body1" color="inherit"> + <SanitizedHtml htmlString={label} ruleSet="iiif" /> + </Typography> + <PluginHook {...this.props} /> + <span className={classes.fauxButton}> + { !open && ( + <Typography variant="button" color="inherit"> + { continueLabel || t('continue') } + </Typography> + )} + </span> + </Button> + <Collapse + in={open} + onClose={() => this.setOpen(false)} + > + <Typography variant="body1" color="inherit" className={classes.expanded}> + <SanitizedHtml htmlString={header} ruleSet={ruleSet} /> + { header && description ? ': ' : '' } + <SanitizedHtml htmlString={description} ruleSet={ruleSet} /> + </Typography> + <DialogActions> + <Button onClick={() => this.setOpen(false)} color="inherit"> + { t('cancel') } + </Button> + + { button } + </DialogActions> + </Collapse> + </Paper> + ); + } +} + +WindowAuthenticationBar.propTypes = { + classes: PropTypes.objectOf(PropTypes.string).isRequired, + confirmButton: PropTypes.string, + ConfirmProps: PropTypes.object, // eslint-disable-line react/forbid-prop-types + continueLabel: PropTypes.string, + description: PropTypes.node, + hasLogoutService: PropTypes.bool, + header: PropTypes.node, + icon: PropTypes.node, + label: PropTypes.node.isRequired, + onConfirm: PropTypes.func.isRequired, + ruleSet: PropTypes.string, + status: PropTypes.string, + t: PropTypes.func, +}; + +WindowAuthenticationBar.defaultProps = { + confirmButton: undefined, + ConfirmProps: {}, + continueLabel: undefined, + description: undefined, + hasLogoutService: true, + header: undefined, + icon: undefined, + ruleSet: 'iiif', + status: undefined, + t: k => k, +}; diff --git a/src/components/WindowAuthenticationControl.js b/src/components/WindowAuthenticationControl.js deleted file mode 100644 index 32b3cbef3856c4fffdf3adcb6af8c9cb050c6d5b..0000000000000000000000000000000000000000 --- a/src/components/WindowAuthenticationControl.js +++ /dev/null @@ -1,173 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import Button from '@material-ui/core/Button'; -import Paper from '@material-ui/core/Paper'; -import Collapse from '@material-ui/core/Collapse'; -import DialogActions from '@material-ui/core/DialogActions'; -import Typography from '@material-ui/core/Typography'; -import LockIcon from '@material-ui/icons/LockSharp'; -import SanitizedHtml from '../containers/SanitizedHtml'; -import AuthenticationLogout from '../containers/AuthenticationLogout'; - -/** - */ -export class WindowAuthenticationControl extends Component { - /** */ - constructor(props) { - super(props); - - this.state = { - open: false, - showFailureMessage: true, - }; - - this.handleClickOpen = this.handleClickOpen.bind(this); - this.handleClose = this.handleClose.bind(this); - this.handleConfirm = this.handleConfirm.bind(this); - } - - /** */ - handleClickOpen() { - this.setState({ open: true }); - } - - /** */ - handleClose() { - this.setState({ open: false, showFailureMessage: false }); - } - - /** */ - handleConfirm() { - const { - handleAuthInteraction, infoId, serviceId, windowId, - } = this.props; - handleAuthInteraction(windowId, infoId, serviceId); - this.setState({ showFailureMessage: true }); - } - - /** */ - isInteractive() { - const { - profile, - } = this.props; - - return profile === 'http://iiif.io/api/auth/1/clickthrough' || profile === 'http://iiif.io/api/auth/1/login'; - } - - /** */ - render() { - const { - classes, - confirmLabel, - degraded, - description, - failureDescription, - failureHeader, - header, - label, - profile, - status, - t, - windowId, - } = this.props; - - const failed = status === 'failed'; - if ((!degraded || !profile) && status !== 'fetching') return <AuthenticationLogout windowId={windowId} />; - if (!this.isInteractive() && !failed) return <></>; - - const { showFailureMessage, open } = this.state; - - const isInFailureState = showFailureMessage && failed; - - const hasCollapsedContent = isInFailureState - ? failureDescription - : header || description; - - const confirmButton = ( - <Button onClick={this.handleConfirm} className={classes.buttonInvert} autoFocus color="secondary"> - {confirmLabel || (this.isInteractive() ? t('login') : t('retry')) } - </Button> - ); - - return ( - <Paper square elevation={4} color="secondary" classes={{ root: classes.paper }}> - <Button fullWidth className={classes.topBar} onClick={hasCollapsedContent ? this.handleClickOpen : this.handleConfirm} component="div" color="inherit"> - <LockIcon className={classes.icon} /> - <Typography className={classes.label} component="h3" variant="body1" color="inherit"> - <SanitizedHtml htmlString={(isInFailureState ? failureHeader : label) || t('authenticationRequired')} ruleSet="iiif" /> - </Typography> - <span className={classes.fauxButton}> - { hasCollapsedContent - ? !open && ( - <Typography variant="button" color="inherit"> - { t('continue') } - </Typography> - ) - : confirmButton} - </span> - </Button> - { - hasCollapsedContent && ( - <Collapse - in={open} - onClose={this.handleClose} - > - <Typography variant="body1" color="inherit" className={classes.expanded}> - { - isInFailureState - ? <SanitizedHtml htmlString={failureDescription || ''} ruleSet="iiif" /> - : ( - <> - <SanitizedHtml htmlString={header || ''} ruleSet="iiif" /> - { header && description ? ': ' : '' } - <SanitizedHtml htmlString={description || ''} ruleSet="iiif" /> - </> - ) - } - </Typography> - <DialogActions> - <Button onClick={this.handleClose} color="inherit"> - {t('cancel')} - </Button> - {confirmButton} - </DialogActions> - </Collapse> - ) - } - </Paper> - ); - } -} - -WindowAuthenticationControl.propTypes = { - classes: PropTypes.objectOf(PropTypes.string).isRequired, - confirmLabel: PropTypes.string, - degraded: PropTypes.bool, - description: PropTypes.string, - failureDescription: PropTypes.string, - failureHeader: PropTypes.string, - handleAuthInteraction: PropTypes.func.isRequired, - header: PropTypes.string, - infoId: PropTypes.string, - label: PropTypes.string, - profile: PropTypes.string, - serviceId: PropTypes.string, - status: PropTypes.oneOf(['ok', 'fetching', 'failed', null]), - t: PropTypes.func, - windowId: PropTypes.string.isRequired, -}; - -WindowAuthenticationControl.defaultProps = { - confirmLabel: undefined, - degraded: false, - description: undefined, - failureDescription: undefined, - failureHeader: undefined, - header: undefined, - infoId: undefined, - label: undefined, - profile: undefined, - serviceId: undefined, - status: null, - t: () => {}, -}; diff --git a/src/config/settings.js b/src/config/settings.js index a13a0b154da291c192ecc08299716637d76864e6..abea8f9feb52d36221e6a8710e2e72f277c763ec 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -337,5 +337,17 @@ export default { viewers: true, windows: true, workspace: true, + }, + auth: { + serviceProfiles: [ + { profile: 'http://iiif.io/api/auth/1/external', external: true }, + { profile: 'http://iiif.io/api/auth/1/kiosk', kiosk: true }, + { profile: 'http://iiif.io/api/auth/1/clickthrough' }, + { profile: 'http://iiif.io/api/auth/1/login' }, + { profile: 'http://iiif.io/api/auth/0/external', external: true }, + { profile: 'http://iiif.io/api/auth/0/kiosk', kiosk: true }, + { profile: 'http://iiif.io/api/auth/0/clickthrough' }, + { profile: 'http://iiif.io/api/auth/0/login' } + ] } }; diff --git a/src/containers/AccessTokenSender.js b/src/containers/AccessTokenSender.js deleted file mode 100644 index eb8b51fea3aa7441fd6b2b04c43b33eec34a8b93..0000000000000000000000000000000000000000 --- a/src/containers/AccessTokenSender.js +++ /dev/null @@ -1,31 +0,0 @@ -import { compose } from 'redux'; -import { connect } from 'react-redux'; -import { withPlugins } from '../extend/withPlugins'; -import * as actions from '../state/actions'; -import { getAccessTokens } from '../state/selectors'; -import { AccessTokenSender } from '../components/AccessTokenSender'; - -/** - * mapStateToProps - to hook up connect - * @memberof App - * @private - */ -const mapStateToProps = (state) => ({ - url: (Object.values(getAccessTokens(state)).find(e => e.isFetching) || {}).id, -}); - -/** - * mapDispatchToProps - used to hook up connect to action creators - * @memberof App - * @private - */ -const mapDispatchToProps = { - handleAccessTokenMessage: actions.resolveAccessTokenRequest, -}; - -const enhance = compose( - connect(mapStateToProps, mapDispatchToProps), - withPlugins('AccessTokenSender'), -); - -export default enhance(AccessTokenSender); diff --git a/src/containers/AuthenticationLogout.js b/src/containers/AuthenticationLogout.js deleted file mode 100644 index 1178992d1a2e45e993bbded92f091b62211cdb14..0000000000000000000000000000000000000000 --- a/src/containers/AuthenticationLogout.js +++ /dev/null @@ -1,46 +0,0 @@ -import { compose } from 'redux'; -import { connect } from 'react-redux'; -import { withTranslation } from 'react-i18next'; -import { withStyles } from '@material-ui/core'; -import { withPlugins } from '../extend/withPlugins'; - -import { - getCurrentCanvas, - selectAuthStatus, - selectCanvasAuthService, - selectLogoutAuthService, -} from '../state/selectors'; -import * as actions from '../state/actions'; -import { AuthenticationLogout } from '../components/AuthenticationLogout'; - -/** - * mapStateToProps - to hook up connect - * @memberof App - * @private - */ -const mapStateToProps = (state, { windowId }) => { - const canvasId = (getCurrentCanvas(state, { windowId }) || {}).id; - const service = selectCanvasAuthService(state, { canvasId, windowId }); - const logoutService = selectLogoutAuthService(state, { canvasId, windowId }); - return { - authServiceId: service && service.id, - label: logoutService && logoutService.getLabel()[0].value, - logoutServiceId: logoutService && logoutService.id, - status: service && selectAuthStatus(state, service), - }; -}; - -const mapDispatchToProps = { - resetAuthenticationState: actions.resetAuthenticationState, -}; - -const styles = {}; - -const enhance = compose( - connect(mapStateToProps, mapDispatchToProps), - withStyles(styles), - withTranslation(), - withPlugins('AuthenticationLogout'), -); - -export default enhance(AuthenticationLogout); diff --git a/src/containers/AuthenticationSender.js b/src/containers/AuthenticationSender.js deleted file mode 100644 index 97ab2847d775bc1e5467ac1f47f91ff88c4b12d7..0000000000000000000000000000000000000000 --- a/src/containers/AuthenticationSender.js +++ /dev/null @@ -1,32 +0,0 @@ -import { compose } from 'redux'; -import { connect } from 'react-redux'; -import { withPlugins } from '../extend/withPlugins'; -import * as actions from '../state/actions'; -import { getAuth, getConfig } from '../state/selectors'; -import { AuthenticationSender } from '../components/AuthenticationSender'; - -/** - * mapStateToProps - to hook up connect - * @memberof App - * @private - */ -const mapStateToProps = (state) => ({ - center: getConfig(state).window.authNewWindowCenter, - url: (Object.values(getAuth(state)).find(e => e.isFetching && e.profile !== 'http://iiif.io/api/auth/1/external') || {}).id, -}); - -/** - * mapDispatchToProps - used to hook up connect to action creators - * @memberof App - * @private - */ -const mapDispatchToProps = { - handleInteraction: actions.resolveAuthenticationRequest, -}; - -const enhance = compose( - connect(mapStateToProps, mapDispatchToProps), - withPlugins('AuthenticationSender'), -); - -export default enhance(AuthenticationSender); diff --git a/src/containers/IIIFAuthentication.js b/src/containers/IIIFAuthentication.js new file mode 100644 index 0000000000000000000000000000000000000000..68a605258cc0648abdb48034299b6774006deb55 --- /dev/null +++ b/src/containers/IIIFAuthentication.js @@ -0,0 +1,96 @@ +import { connect } from 'react-redux'; +import { compose } from 'redux'; +import { withTranslation } from 'react-i18next'; +import { Utils } from 'manifesto.js/dist-esmodule/Utils'; +import { withPlugins } from '../extend/withPlugins'; +import * as actions from '../state/actions'; +import { + getAuth, + getAuthProfiles, + selectCurrentAuthServices, + getAccessTokens, +} from '../state/selectors'; +import { IIIFAuthentication } from '../components/IIIFAuthentication'; + +/** + * mapStateToProps - to hook up connect + * @memberof FullScreenButton + * @private + */ +const mapStateToProps = (state, { windowId }) => { + const services = selectCurrentAuthServices(state, { windowId }); + + // TODO: get the most actionable auth service... + const service = services[0]; + + const accessTokenService = service && ( + Utils.getService(service, 'http://iiif.io/api/auth/1/token') + || Utils.getService(service, 'http://iiif.io/api/auth/0/token') + ); + const logoutService = service && ( + Utils.getService(service, 'http://iiif.io/api/auth/1/logout') + || Utils.getService(service, 'http://iiif.io/api/auth/0/logout') + ); + + const authStatuses = getAuth(state); + const authStatus = service && authStatuses[service.id]; + const accessTokens = getAccessTokens(state); + const accessTokenStatus = accessTokenService && accessTokens[accessTokenService.id]; + + let status = null; + + if (!authStatus) { + status = null; + } else if (authStatus.ok) { + status = 'ok'; + } else if (authStatus.ok === false) { + status = 'failed'; + } else if (authStatus.isFetching) { + if (authStatus.windowId === windowId) status = 'cookie'; + } else if (accessTokenStatus && accessTokenStatus.isFetching) { + if (authStatus.windowId === windowId) status = 'token'; + } + + const authProfiles = getAuthProfiles(state); + + const profile = service && service.getProfile(); + + const isInteractive = authProfiles.some( + config => config.profile === profile && !(config.external || config.kiosk), + ); + + return { + accessTokenServiceId: accessTokenService && accessTokenService.id, + authServiceId: service && service.id, + confirm: service && service.getConfirmLabel(), + description: service && service.getDescription(), + failureDescription: service && service.getFailureDescription(), + failureHeader: service && service.getFailureHeader(), + header: service && service.getHeader(), + isInteractive, + label: service && service.getLabel()[0].value, + logoutServiceId: logoutService && logoutService.id, + profile, + status, + }; +}; + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestListItem + * @private + */ +const mapDispatchToProps = { + handleAuthInteraction: actions.addAuthenticationRequest, + resetAuthenticationState: actions.resetAuthenticationState, + resolveAccessTokenRequest: actions.resolveAccessTokenRequest, + resolveAuthenticationRequest: actions.resolveAuthenticationRequest, +}; + +const enhance = compose( + withTranslation(), + connect(mapStateToProps, mapDispatchToProps), + withPlugins('IIIFAuthentication'), +); + +export default enhance(IIIFAuthentication); diff --git a/src/containers/WindowAuthenticationBar.js b/src/containers/WindowAuthenticationBar.js new file mode 100644 index 0000000000000000000000000000000000000000..881bf419a1423289b504995b616cb96270366050 --- /dev/null +++ b/src/containers/WindowAuthenticationBar.js @@ -0,0 +1,65 @@ +import { compose } from 'redux'; +import { withTranslation } from 'react-i18next'; +import { withStyles } from '@material-ui/core/styles'; +import { fade } from '@material-ui/core/styles/colorManipulator'; +import { withPlugins } from '../extend/withPlugins'; +import { WindowAuthenticationBar } from '../components/WindowAuthenticationBar'; + +/** + * @param theme + * @returns {{typographyBody: {flexGrow: number, fontSize: number|string}, + * windowTopBarStyle: {minHeight: number, paddingLeft: number, backgroundColor: string}}} + */ +const styles = theme => ({ + buttonInvert: { + '&:hover': { + backgroundColor: fade( + theme.palette.secondary.contrastText, 1 - theme.palette.action.hoverOpacity, + ), + }, + backgroundColor: theme.palette.secondary.contrastText, + marginLeft: theme.spacing(5), + paddingBottom: 0, + paddingTop: 0, + }, + expanded: { + paddingLeft: theme.spacing(), + paddingRight: theme.spacing(), + }, + failure: { + backgroundColor: theme.palette.error.dark, + }, + fauxButton: { + marginLeft: theme.spacing(2.5), + }, + icon: { + marginRight: theme.spacing(1.5), + verticalAlign: 'text-bottom', + }, + label: { + lineHeight: 2.25, + }, + paper: { + backgroundColor: theme.palette.secondary.main, + color: theme.palette.secondary.contrastText, + cursor: 'pointer', + }, + topBar: { + '&:hover': { + backgroundColor: theme.palette.secondary.main, + }, + alignItems: 'center', + display: 'flex', + justifyContent: 'inherit', + padding: theme.spacing(1), + textTransform: 'none', + }, +}); + +const enhance = compose( + withTranslation(), + withStyles(styles), + withPlugins('WindowAuthenticationBar'), +); + +export default enhance(WindowAuthenticationBar); diff --git a/src/containers/WindowAuthenticationControl.js b/src/containers/WindowAuthenticationControl.js deleted file mode 100644 index 97b8978ae201e4bc7b5ee87a19251741cc4009b3..0000000000000000000000000000000000000000 --- a/src/containers/WindowAuthenticationControl.js +++ /dev/null @@ -1,100 +0,0 @@ -import { compose } from 'redux'; -import { connect } from 'react-redux'; -import { withTranslation } from 'react-i18next'; -import { withStyles } from '@material-ui/core'; -import { fade } from '@material-ui/core/styles/colorManipulator'; -import { withPlugins } from '../extend/withPlugins'; -import * as actions from '../state/actions'; -import { - getCurrentCanvas, - selectAuthStatus, - selectInfoResponse, - selectCanvasAuthService, -} from '../state/selectors'; -import { WindowAuthenticationControl } from '../components/WindowAuthenticationControl'; - -/** - * mapStateToProps - to hook up connect - * @memberof App - * @private - */ -const mapStateToProps = (state, { windowId }) => { - const canvasId = (getCurrentCanvas(state, { windowId }) || {}).id; - const service = selectCanvasAuthService(state, { canvasId, windowId }); - const infoResponse = selectInfoResponse(state, { canvasId, windowId }) || {}; - - return { - confirmLabel: service && service.getConfirmLabel(), - degraded: infoResponse.degraded, - description: service && service.getDescription(), - failureDescription: service && service.getFailureDescription(), - failureHeader: service && service.getFailureHeader(), - header: service && service.getHeader(), - infoId: infoResponse.id, - label: service && service.getLabel()[0].value, - profile: service && service.getProfile(), - serviceId: service && service.id, - status: service && selectAuthStatus(state, service), - }; -}; - -/** - * mapDispatchToProps - used to hook up connect to action creators - * @memberof App - * @private - */ -const mapDispatchToProps = { - handleAuthInteraction: actions.addAuthenticationRequest, -}; -/** - * @param theme - * @returns {{typographyBody: {flexGrow: number, fontSize: number|string}, - * windowTopBarStyle: {minHeight: number, paddingLeft: number, backgroundColor: string}}} - */ -const styles = theme => ({ - buttonInvert: { - '&:hover': { - backgroundColor: fade( - theme.palette.secondary.contrastText, 1 - theme.palette.action.hoverOpacity, - ), - }, - backgroundColor: theme.palette.secondary.contrastText, - }, - expanded: { - paddingLeft: theme.spacing(), - paddingRight: theme.spacing(), - }, - failure: { - backgroundColor: theme.palette.error.dark, - }, - fauxButton: { - marginLeft: theme.spacing(2.5), - }, - icon: { - marginRight: theme.spacing(1.5), - verticalAlign: 'text-bottom', - }, - label: { - lineHeight: 2.25, - }, - paper: { - backgroundColor: theme.palette.secondary.main, - color: theme.palette.secondary.contrastText, - cursor: 'pointer', - }, - topBar: { - '&:hover': { - backgroundColor: theme.palette.secondary.main, - }, - justifyContent: 'inherit', - textTransform: 'none', - }, -}); -const enhance = compose( - connect(mapStateToProps, mapDispatchToProps), - withStyles(styles), - withTranslation(), - withPlugins('WindowAuthenticationControl'), -); - -export default enhance(WindowAuthenticationControl); diff --git a/src/state/actions/auth.js b/src/state/actions/auth.js index 72f4ba337758d0bfa8e6ea639b3a1977d170a6cf..582a472b09e1fcf2c5c4789f730f5052c826bc1a 100644 --- a/src/state/actions/auth.js +++ b/src/state/actions/auth.js @@ -1,18 +1,15 @@ -import { Utils } from 'manifesto.js/dist-esmodule/Utils'; import ActionTypes from './action-types'; /** * addAuthenticationRequest - action creator * * @param {String} windowId - * @param {String} infoId * @param {String} id * @memberof ActionCreators */ -export function addAuthenticationRequest(windowId, infoId, id, profile = undefined) { +export function addAuthenticationRequest(windowId, id, profile = undefined) { return { id, - infoId, profile, type: ActionTypes.ADD_AUTHENTICATION_REQUEST, windowId, @@ -21,16 +18,19 @@ export function addAuthenticationRequest(windowId, infoId, id, profile = undefin /** * resolveAuthenticationRequest - action creator + * Triggered when we might have an IIIF auth cookie available (but we + * can't be really sure until try the access token) * * @param {String} id * @memberof ActionCreators */ -export function resolveAuthenticationRequest(id) { - return ((dispatch, getState) => { - const { auth } = getState(); - - dispatch(fetchAccessTokenRequest(id, auth[id].infoId)); - }); +export function resolveAuthenticationRequest(id, tokenServiceId, props) { + return { + id, + tokenServiceId, + type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST, + ...props, + }; } /** @@ -39,13 +39,11 @@ export function resolveAuthenticationRequest(id) { * * @param {String} serviceId * @param {String} authId - * @param {String} infoIds * @memberof ActionCreators */ -export function requestAccessToken(serviceId, authId, infoIds) { +export function requestAccessToken(serviceId, authId) { return { authId, - infoIds, serviceId, type: ActionTypes.REQUEST_ACCESS_TOKEN, }; @@ -59,8 +57,9 @@ export function requestAccessToken(serviceId, authId, infoIds) { * @param {Object} json * @memberof ActionCreators */ -export function receiveAccessToken(serviceId, json, infoIds) { +export function receiveAccessToken(authId, serviceId, json) { return { + authId, json, serviceId, type: ActionTypes.RECEIVE_ACCESS_TOKEN, @@ -75,71 +74,34 @@ export function receiveAccessToken(serviceId, json, infoIds) { * @param {Object} error * @memberof ActionCreators */ -export function receiveAccessTokenFailure(serviceId, error) { +export function receiveAccessTokenFailure(authId, serviceId, error) { return { + authId, error, serviceId, type: ActionTypes.RECEIVE_ACCESS_TOKEN_FAILURE, }; } -/** @private */ -export function fetchAccessTokenRequest(id, infoIds, providedServices = undefined) { - return ((dispatch, getState) => { - const { infoResponses } = getState(); - - const infoResponse = infoResponses[infoIds[0]].json; - - const services = providedServices || Utils.getServices(infoResponse); - - const authService = services.find(e => e.id === id); - - if (!authService) return null; - - const accessTokenService = Utils.getService(authService, 'http://iiif.io/api/auth/1/token'); - - dispatch(requestAccessToken(accessTokenService.id, authService.id, infoIds)); - return null; - }); -} - /** * resolveAccessTokenRequest - action creator * * @param {Object} message * @memberof ActionCreators */ -export function resolveAccessTokenRequest({ messageId, ...json }) { - return ((dispatch, getState) => { - const { authId } = getState().accessTokens[messageId]; +export function resolveAccessTokenRequest(authServiceId, tokenServiceId, json) { + if (!json.accessToken) return receiveAccessTokenFailure(authServiceId, tokenServiceId, json); - dispatch({ - id: authId, - ok: !!json.accessToken, - type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST, - }); - - if (json.accessToken) { - dispatch(receiveAccessToken(messageId, json)); - } else { - dispatch(receiveAccessTokenFailure(messageId, json)); - } - }); + return receiveAccessToken(authServiceId, tokenServiceId, json); } /** * Resets authentication state for a token service */ -export function resetAuthenticationState({ authServiceId }) { - return ((dispatch, getState) => { - const { accessTokens } = getState(); - - const currentService = Object.values(accessTokens) - .find(service => service.authId === authServiceId); - dispatch({ - id: authServiceId, - tokenServiceId: currentService && currentService.id, - type: ActionTypes.RESET_AUTHENTICATION_STATE, - }); - }); +export function resetAuthenticationState({ authServiceId, tokenServiceId }) { + return { + id: authServiceId, + tokenServiceId, + type: ActionTypes.RESET_AUTHENTICATION_STATE, + }; } diff --git a/src/state/actions/infoResponse.js b/src/state/actions/infoResponse.js index 3aed86c00b71c5f5dcbcf8e675232fe848a77ed6..b990a9c86a894aa8a8abc6e08971c5add1821264 100644 --- a/src/state/actions/infoResponse.js +++ b/src/state/actions/infoResponse.js @@ -6,11 +6,12 @@ import ActionTypes from './action-types'; * @param {String} infoId * @memberof ActionCreators */ -export function requestInfoResponse(infoId, imageResource) { +export function requestInfoResponse(infoId, imageResource, windowId) { return { imageResource, infoId, type: ActionTypes.REQUEST_INFO_RESPONSE, + windowId, }; } @@ -38,13 +39,14 @@ export function receiveInfoResponse(infoId, infoJson, ok, tokenServiceId) { * @param {Object} manifestJson * @memberof ActionCreators */ -export function receiveDegradedInfoResponse(infoId, infoJson, ok, tokenServiceId) { +export function receiveDegradedInfoResponse(infoId, infoJson, ok, tokenServiceId, windowId) { return { infoId, infoJson, ok, tokenServiceId, type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE, + windowId, }; } @@ -70,10 +72,10 @@ export function receiveInfoResponseFailure(infoId, error, tokenServiceId) { * @param {String} infoId * @memberof ActionCreators */ -export function fetchInfoResponse({ imageId, imageResource }) { +export function fetchInfoResponse({ imageId, imageResource, windowId }) { const imageService = imageResource && imageResource.getServices()[0]; const infoId = (imageId || imageService.id); - return requestInfoResponse(infoId, imageService); + return requestInfoResponse(infoId, imageService, windowId); } /** diff --git a/src/state/reducers/accessTokens.js b/src/state/reducers/accessTokens.js index 1dd6d1b7a90b3cc8639f6198ec5736ab7754c872..45f7f6ad36888daecc33700a82e745908146f9bd 100644 --- a/src/state/reducers/accessTokens.js +++ b/src/state/reducers/accessTokens.js @@ -1,29 +1,15 @@ import omit from 'lodash/omit'; -import { Utils } from 'manifesto.js/dist-esmodule/Utils'; import ActionTypes from '../actions/action-types'; /** */ export function accessTokensReducer(state = {}, action) { - let authService; - let tokenService; - switch (action.type) { - case ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE: - authService = Utils.getService({ ...action.infoJson, options: {} }, 'http://iiif.io/api/auth/1/external'); - if (!authService) return state; - - tokenService = Utils.getService(authService, 'http://iiif.io/api/auth/1/token'); - if (!tokenService || state[tokenService.id]) return state; - + case ActionTypes.RESOLVE_AUTHENTICATION_REQUEST: return { ...state, - [tokenService.id]: { - authId: authService.id, - id: tokenService.id, - infoIds: [].concat( - (state[tokenService.id] && state[tokenService.id].infoIds) || [], - action.infoId, - ), + [action.tokenServiceId]: { + authId: action.id, + id: action.tokenServiceId, isFetching: true, }, }; @@ -33,7 +19,6 @@ export function accessTokensReducer(state = {}, action) { [action.serviceId]: { authId: action.authId, id: action.serviceId, - infoIds: action.infoIds, isFetching: true, }, }; @@ -46,14 +31,6 @@ export function accessTokensReducer(state = {}, action) { json: action.json, }, }; - case ActionTypes.CLEAR_ACCESS_TOKEN_QUEUE: - return { - ...state, - [action.serviceId]: { - ...state[action.serviceId], - infoIds: [], - }, - }; case ActionTypes.RECEIVE_ACCESS_TOKEN_FAILURE: return { ...state, @@ -65,6 +42,17 @@ export function accessTokensReducer(state = {}, action) { }; case ActionTypes.RESET_AUTHENTICATION_STATE: return omit(state, action.tokenServiceId); + case ActionTypes.RECEIVE_INFO_RESPONSE: + if (!action.tokenServiceId) return state; + if (state[action.tokenServiceId].success) return state; + + return { + ...state, + [action.tokenServiceId]: { + ...state[action.tokenServiceId], + success: true, + }, + }; default: return state; } diff --git a/src/state/reducers/auth.js b/src/state/reducers/auth.js index 0b230a0327aec712397fe867d5463528742dcd3b..2541bdc7c9fd5b16f7a2051a937969370e13e4a4 100644 --- a/src/state/reducers/auth.js +++ b/src/state/reducers/auth.js @@ -1,56 +1,40 @@ import omit from 'lodash/omit'; - import ActionTypes from '../actions/action-types'; -import { selectNextAuthService } from '../selectors/canvases'; /** * authReducer */ export const authReducer = (state = {}, action) => { - let service; - switch (action.type) { - case ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE: - service = selectNextAuthService( - { auth: state }, action.infoJson, { external: true, kiosk: true }, - ); - - if (!service || state[service.id]) return state; - - return { - ...state, - [service.id]: { - id: service.id, - infoId: [].concat( - (state[service.id] && state[service.id].infoId) || [], - action.infoId, - ), - isFetching: true, - profile: service.getProfile(), - }, - }; case ActionTypes.ADD_AUTHENTICATION_REQUEST: return { ...state, [action.id]: { id: action.id, - infoId: [].concat( - (state[action.id] && state[action.id].infoId) || [], - action.infoId, - ), isFetching: true, profile: action.profile, + windowId: action.windowId, }, }; case ActionTypes.RESOLVE_AUTHENTICATION_REQUEST: return { ...state, [action.id]: { - id: action.id, + ...state[action.id], isFetching: false, ok: action.ok, }, }; + case ActionTypes.RECEIVE_ACCESS_TOKEN: + if (!action.authId) return state; + + return { + ...state, + [action.authId]: { + ...state[action.authId], + ok: true, + }, + }; case ActionTypes.RESET_AUTHENTICATION_STATE: return omit(state, action.id); default: return state; diff --git a/src/state/reducers/infoResponses.js b/src/state/reducers/infoResponses.js index d38b6b8e809bc3b8da1215df5e15d7335ddca634..3141f096de68a7cc332ab8dd80159776ac1532ec 100644 --- a/src/state/reducers/infoResponses.js +++ b/src/state/reducers/infoResponses.js @@ -54,13 +54,6 @@ export const infoResponsesReducer = (state = {}, action) => { }, {}); case ActionTypes.IMPORT_MIRADOR_STATE: return {}; - case ActionTypes.RESET_AUTHENTICATION_STATE: - return Object.keys(state).reduce((object, key) => { - if (state[key].tokenServiceId !== action.tokenServiceId) { - object[key] = state[key]; // eslint-disable-line no-param-reassign - } - return object; - }, {}); default: return state; } }; diff --git a/src/state/sagas/auth.js b/src/state/sagas/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..21135885e2005a631cee0725559b63aa305cb792 --- /dev/null +++ b/src/state/sagas/auth.js @@ -0,0 +1,162 @@ +import { + all, call, put, select, takeEvery, delay, +} from 'redux-saga/effects'; +import { Utils } from 'manifesto.js/dist-esmodule/Utils'; +import flatten from 'lodash/flatten'; +import ActionTypes from '../actions/action-types'; +import MiradorCanvas from '../../lib/MiradorCanvas'; +import { + addAuthenticationRequest, + resolveAuthenticationRequest, + requestAccessToken, + resetAuthenticationState, +} from '../actions'; +import { + selectInfoResponses, + getVisibleCanvases, + getWindows, + getConfig, + getAuth, + getAccessTokens, +} from '../selectors'; +import { fetchInfoResponse } from './iiif'; + +/** */ +export function* refetchInfoResponsesOnLogout({ tokenServiceId }) { + // delay logout actions to give the cookie service a chance to invalidate our cookies + // before we reinitialize openseadragon and rerequest images. + + yield delay(2000); + yield call(refetchInfoResponses, { serviceId: tokenServiceId }); +} + +/** + * Figure out what info responses could have used the access token service and: + * - refetch, if they are currently visible + * - throw them out (and lazy re-fetch) otherwise + */ +export function* refetchInfoResponses({ serviceId }) { + const windows = yield select(getWindows); + + const canvases = yield all( + Object.keys(windows).map(windowId => select(getVisibleCanvases, { windowId })), + ); + + const visibleImageApiIds = flatten(flatten(canvases).map((canvas) => { + const miradorCanvas = new MiradorCanvas(canvas); + return miradorCanvas.imageServiceIds; + })); + + const infoResponses = yield select(selectInfoResponses); + /** */ + const haveThisTokenService = infoResponse => { + const services = Utils.getServices(infoResponse); + return services.some(e => { + const infoTokenService = Utils.getService(e, 'http://iiif.io/api/auth/1/token') + || Utils.getService(e, 'http://iiif.io/api/auth/0/token'); + return infoTokenService && infoTokenService.id === serviceId; + }); + }; + + const obsoleteInfoResponses = Object.values(infoResponses).filter( + i => i.json && haveThisTokenService(i.json), + ); + + yield all(obsoleteInfoResponses.map(({ id: infoId }) => { + if (visibleImageApiIds.includes(infoId)) { + return call(fetchInfoResponse, { infoId }); + } + return put({ infoId, type: ActionTypes.REMOVE_INFO_RESPONSE }); + })); +} + +/** try to start any non-interactive auth flows */ +export function* doAuthWorkflow({ infoJson, windowId }) { + const auths = yield select(getAuth); + const { auth: { serviceProfiles } } = yield select(getConfig); + const nonInteractiveAuthFlowProfiles = serviceProfiles.filter(p => p.external || p.kiosk); + + // try to get an untried, non-interactive auth service + const authService = Utils.getServices(infoJson) + .filter(s => !auths[s.id]) + .find(e => nonInteractiveAuthFlowProfiles.some(p => p.profile === e.getProfile())); + if (!authService) return; + + const profileConfig = nonInteractiveAuthFlowProfiles.find( + p => p.profile === authService.getProfile(), + ); + + if (profileConfig.kiosk) { + // start the auth + yield put(addAuthenticationRequest(windowId, authService.id, authService.getProfile())); + } else if (profileConfig.external) { + const tokenService = Utils.getService(authService, 'http://iiif.io/api/auth/1/token') + || Utils.getService(authService, 'http://iiif.io/api/auth/0/token'); + + if (!tokenService) return; + // resolve the auth + yield put(resolveAuthenticationRequest(authService.id, tokenService.id)); + // start access tokens + yield put(requestAccessToken(tokenService.id, authService.id)); + } +} + +/** */ +export function* rerequestOnAccessTokenFailure({ infoJson, windowId, tokenServiceId }) { + if (!tokenServiceId) return; + + // make sure we have an auth service to try + const authService = Utils.getServices(infoJson).find(service => { + const tokenService = Utils.getService(service, 'http://iiif.io/api/auth/1/token') + || Utils.getService(service, 'http://iiif.io/api/auth/0/token'); + + return tokenService && tokenService.id === tokenServiceId; + }); + + if (!authService) return; + + // make sure the token ever worked (and might have expired or needs to be re-upped) + const accessTokenServices = yield select(getAccessTokens); + const service = accessTokenServices[tokenServiceId]; + if (!(service && service.success)) return; + + yield put(requestAccessToken(tokenServiceId, authService.id)); +} + +/** */ +export function* invalidateInvalidAuth({ serviceId }) { + const accessTokenServices = yield select(getAccessTokens); + const authServices = yield select(getAuth); + + const accessTokenService = accessTokenServices[serviceId]; + if (!accessTokenService) return; + const authService = authServices[accessTokenService.authId]; + if (!authService) return; + + if (accessTokenService.success) { + // if the token ever worked, reset things so we try to get a new cookie + yield put(resetAuthenticationState({ + authServiceId: authService.id, + tokenServiceId: accessTokenService.id, + })); + } else { + // if the token never worked, mark the auth service as bad so we could + // try to pick a different service + yield put(resolveAuthenticationRequest( + authService.id, + accessTokenService.id, + { ok: false }, + )); + } +} + +/** */ +export default function* authSaga() { + yield all([ + takeEvery(ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE, rerequestOnAccessTokenFailure), + takeEvery(ActionTypes.RECEIVE_ACCESS_TOKEN_FAILURE, invalidateInvalidAuth), + takeEvery(ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE, doAuthWorkflow), + takeEvery(ActionTypes.RECEIVE_ACCESS_TOKEN, refetchInfoResponses), + takeEvery(ActionTypes.RESET_AUTHENTICATION_STATE, refetchInfoResponsesOnLogout), + ]); +} diff --git a/src/state/sagas/iiif.js b/src/state/sagas/iiif.js index ee1f8e20599d286895b4af96b6032b0d6dd4b2f0..f0ce4c5371f91f62095e952251dd786e998afec3 100644 --- a/src/state/sagas/iiif.js +++ b/src/state/sagas/iiif.js @@ -115,7 +115,10 @@ export function* fetchManifest({ manifestId }) { /** @private */ function* getAccessTokenService(resource) { - const services = Utils.getServices({ ...resource, options: {} }).filter(s => s.getProfile().match(/http:\/\/iiif.io\/api\/auth\//)); + const manifestoCompatibleResource = resource && resource.__jsonld + ? resource + : { ...resource, options: {} }; + const services = Utils.getServices(manifestoCompatibleResource).filter(s => s.getProfile().match(/http:\/\/iiif.io\/api\/auth\//)); if (services.length === 0) return undefined; const accessTokens = yield select(getAccessTokens); @@ -123,8 +126,9 @@ function* getAccessTokenService(resource) { for (let i = 0; i < services.length; i += 1) { const authService = services[i]; - const accessTokenService = Utils.getService(authService, 'http://iiif.io/api/auth/1/token'); - const token = accessTokens[accessTokenService.id]; + const accessTokenService = Utils.getService(authService, 'http://iiif.io/api/auth/1/token') + || Utils.getService(authService, 'http://iiif.io/api/auth/0/token'); + const token = accessTokenService && accessTokens[accessTokenService.id]; if (token && token.json) return token; } @@ -132,7 +136,7 @@ function* getAccessTokenService(resource) { } /** @private */ -export function* fetchInfoResponse({ imageResource, infoId, tokenService: passedTokenService }) { +export function* fetchInfoResponse({ imageResource, infoId, windowId }) { let iiifResource = imageResource; if (!iiifResource) { iiifResource = yield select(selectInfoResponse, { infoId }); @@ -141,7 +145,7 @@ export function* fetchInfoResponse({ imageResource, infoId, tokenService: passed const callbacks = { degraded: ({ json, response, tokenServiceId, - }) => receiveDegradedInfoResponse(infoId, json, response.ok, tokenServiceId), + }) => receiveDegradedInfoResponse(infoId, json, response.ok, tokenServiceId, windowId), failure: ({ error, json, response, tokenServiceId, }) => ( @@ -194,22 +198,6 @@ export function* fetchResourceManifest({ manifestId, manifestJson }) { if (!manifests[manifestId]) yield* fetchManifest({ manifestId }); } -/** @private */ -export function* refetchInfoResponses({ serviceId }) { - const accessTokens = yield select(getAccessTokens); - const tokenService = accessTokens && accessTokens[serviceId]; - - if (!tokenService || tokenService.infoIds === []) return; - - yield all( - tokenService.infoIds.map(infoId => call(fetchInfoResponse, { infoId, tokenService })), - ); - - // TODO: Other resources could be refetched too - - yield put({ serviceId, type: ActionTypes.CLEAR_ACCESS_TOKEN_QUEUE }); -} - /** */ export function* fetchManifests(...manifestIds) { const manifests = yield select(getManifests); @@ -227,7 +215,6 @@ export default function* iiifSaga() { takeEvery(ActionTypes.REQUEST_INFO_RESPONSE, fetchInfoResponse), takeEvery(ActionTypes.REQUEST_SEARCH, fetchSearchResponse), takeEvery(ActionTypes.REQUEST_ANNOTATION, fetchAnnotation), - takeEvery(ActionTypes.RECEIVE_ACCESS_TOKEN, refetchInfoResponses), takeEvery(ActionTypes.ADD_RESOURCE, fetchResourceManifest), ]); } diff --git a/src/state/sagas/index.js b/src/state/sagas/index.js index 3ebb80e450e713a8833833568d12182bc1854425..d3e23cca68b1591746be40fbf5c981e6c5dbd588 100644 --- a/src/state/sagas/index.js +++ b/src/state/sagas/index.js @@ -5,7 +5,8 @@ import { import appSaga from './app'; import iiifSaga from './iiif'; import windowSaga from './windows'; -import annotations from './annotations'; +import annotationsSaga from './annotations'; +import authSaga from './auth'; /** */ function* launchSaga(saga) { @@ -23,10 +24,11 @@ function* launchSaga(saga) { function getRootSaga(pluginSagas = []) { return function* rootSaga() { const sagas = [ - annotations, + annotationsSaga, appSaga, iiifSaga, windowSaga, + authSaga, ...pluginSagas, ]; diff --git a/src/state/sagas/windows.js b/src/state/sagas/windows.js index 26c8b9b1aa1ea548c0ec5db74c69629fd045361d..af08e2378d089a3d21fed758de81254586ce3f8c 100644 --- a/src/state/sagas/windows.js +++ b/src/state/sagas/windows.js @@ -223,7 +223,7 @@ export function* fetchInfoResponses({ visibleCanvases: visibleCanvasIds, windowI const miradorCanvas = new MiradorCanvas(canvas); return all(miradorCanvas.iiifImageResources.map(imageResource => ( !infoResponses[imageResource.getServices()[0].id] - && put(fetchInfoResponse({ imageResource })) + && put(fetchInfoResponse({ imageResource, windowId })) )).filter(Boolean)); })); } diff --git a/src/state/selectors/auth.js b/src/state/selectors/auth.js index c1d75341d9e3bc86a3d02aef9c074f24730b3964..0345021475d3f710a504ba520cc6c26ddae20851 100644 --- a/src/state/selectors/auth.js +++ b/src/state/selectors/auth.js @@ -1,7 +1,83 @@ +import { createSelector } from 'reselect'; +import { Utils } from 'manifesto.js/dist-esmodule/Utils'; +import flatten from 'lodash/flatten'; +import MiradorCanvas from '../../lib/MiradorCanvas'; import { miradorSlice } from './utils'; +import { getConfig } from './config'; +import { getVisibleCanvases, selectInfoResponses } from './canvases'; + +export const getAuthProfiles = createSelector( + [ + getConfig, + ], + ({ auth: { serviceProfiles = [] } }) => serviceProfiles, +); /** */ export const getAccessTokens = state => miradorSlice(state).accessTokens || {}; /** */ export const getAuth = state => miradorSlice(state).auth || {}; + +export const selectCurrentAuthServices = createSelector( + [ + getVisibleCanvases, + selectInfoResponses, + getAuthProfiles, + getAuth, + (state, { iiifResources }) => iiifResources, + ], + (canvases, infoResponses = {}, serviceProfiles, auth, iiifResources) => { + let currentAuthResources = iiifResources; + + if (!currentAuthResources && canvases) { + currentAuthResources = flatten(canvases.map(c => { + const miradorCanvas = new MiradorCanvas(c); + const images = miradorCanvas.iiifImageResources; + + return images.map(i => { + const iiifImageService = i.getServices()[0]; + + const infoResponse = infoResponses[iiifImageService.id]; + if (infoResponse && infoResponse.json) { + return { ...infoResponse.json, options: {} }; + } + + return iiifImageService; + }); + })); + } + + if (!currentAuthResources) return []; + if (currentAuthResources.length === 0) return []; + + const currentAuthServices = currentAuthResources.map(resource => { + let lastAttemptedService; + const services = Utils.getServices(resource); + + for (const authProfile of serviceProfiles) { + const profiledAuthServices = services.filter( + p => authProfile.profile === p.getProfile(), + ); + + for (const service of profiledAuthServices) { + lastAttemptedService = service; + + if (!auth[service.id] || auth[service.id].isFetching || auth[service.id].ok) { + return service; + } + } + } + + return lastAttemptedService; + }); + + return Object.values(currentAuthServices.reduce((h, service) => { + if (service && !h[service.id]) { + h[service.id] = service; // eslint-disable-line no-param-reassign + } + + return h; + }, {})); + }, +); diff --git a/src/state/selectors/canvases.js b/src/state/selectors/canvases.js index d2083c184f89a5e46e2ec4afb0633ef399ec2b2b..684676bcd8fb15756f7a5246b2c6b535ef12046c 100644 --- a/src/state/selectors/canvases.js +++ b/src/state/selectors/canvases.js @@ -1,5 +1,4 @@ import { createSelector } from 'reselect'; -import { Utils } from 'manifesto.js/dist-esmodule/Utils'; import flatten from 'lodash/flatten'; import CanvasGroupings from '../../lib/CanvasGroupings'; import MiradorCanvas from '../../lib/MiradorCanvas'; @@ -206,100 +205,3 @@ export const selectInfoResponse = createSelector( && infoResponses[iiifServiceId]; }, ); - -const authServiceProfiles = { - clickthrough: true, external: true, kiosk: true, login: true, -}; -/** - * - */ -export function selectNextAuthService({ auth }, resource, filter = authServiceProfiles) { - const orderedAuthServiceProfiles = [ - 'http://iiif.io/api/auth/1/external', - 'http://iiif.io/api/auth/1/kiosk', - 'http://iiif.io/api/auth/1/clickthrough', - 'http://iiif.io/api/auth/1/login', - ]; - - const mapFilterToProfiles = { - 'http://iiif.io/api/auth/1/clickthrough': 'clickthrough', - 'http://iiif.io/api/auth/1/external': 'external', - 'http://iiif.io/api/auth/1/kiosk': 'kiosk', - 'http://iiif.io/api/auth/1/login': 'login', - }; - - for (const profile of orderedAuthServiceProfiles) { - const services = getServices(resource, profile); - for (const service of services) { - if (!auth[service.id]) { - return filter[mapFilterToProfiles[profile]] && service; - } - - if (auth[service.id].isFetching || auth[service.id].ok) return null; - } - } - - return null; -} - -/** */ -export function selectActiveAuthService(state, resource) { - const orderedAuthServiceProfiles = [ - 'http://iiif.io/api/auth/1/login', - 'http://iiif.io/api/auth/1/clickthrough', - 'http://iiif.io/api/auth/1/kiosk', - 'http://iiif.io/api/auth/1/external', - ]; - - for (const profile of orderedAuthServiceProfiles) { - const services = getServices(resource, profile); - const service = services.find(s => selectAuthStatus(state, s)); - if (service) return service; - } - - return null; -} - -export const selectCanvasAuthService = createSelector( - [ - selectInfoResponse, - state => state, - ], - (infoResponse, state) => { - const resource = infoResponse && infoResponse.json; - - if (!resource) return undefined; - - return selectNextAuthService(state, resource) - || selectActiveAuthService(state, resource); - }, -); - -export const selectLogoutAuthService = createSelector( - [ - selectInfoResponse, - state => state, - ], - (infoResponse, state) => { - if (!infoResponse) return undefined; - const authService = selectActiveAuthService(state, infoResponse.json); - if (!authService) return undefined; - return authService.getService('http://iiif.io/api/auth/1/logout'); - }, -); - -/** */ -export function selectAuthStatus({ auth }, service) { - if (!service) return null; - if (!auth[service.id]) return null; - if (auth[service.id].isFetching) return 'fetching'; - if (auth[service.id].ok) return 'ok'; - return 'failed'; -} - -/** Get all the services that match a profile */ -function getServices(resource, profile) { - const services = Utils.getServices({ ...resource, options: {} }); - - return services.filter(service => service.getProfile() === profile); -}