diff --git a/src/App.tsx b/src/App.tsx index 6232cfe..fbdd9a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,7 @@ import style from './App.module.css'; import SettingsPage from './pages/SettingsPage/SettingsPage'; import CategoryPage from './pages/CategoryPage/CategoryPage'; -import {APINode, apiTokenValid, callAPI, refreshAPIToken} from './utils/Api'; +import {APINode, callAPI, token} from './utils/Api'; import {BrowserRouter as Router, NavLink, Route, Switch} from 'react-router-dom'; import Player from './pages/Player/Player'; @@ -19,6 +19,7 @@ import {SettingsTypes} from './types/ApiTypes'; import AuthenticationPage from './pages/AuthenticationPage/AuthenticationPage'; import TVShowPage from './pages/TVShowPage/TVShowPage'; import TVPlayer from './pages/TVShowPage/TVPlayer'; +import {CookieTokenStore} from './utils/TokenStore/CookieTokenStore'; interface state { password: boolean | null; // null if uninitialized - true if pwd needed false if not needed @@ -32,12 +33,14 @@ class App extends React.Component<{}, state> { constructor(props: {}) { super(props); + token.setTokenStore(new CookieTokenStore()); + let pwdneeded: boolean | null = null; - if (apiTokenValid()) { + if (token.apiTokenValid()) { pwdneeded = false; } else { - refreshAPIToken((err) => { + token.refreshAPIToken((err) => { if (err === 'invalid_client') { this.setState({password: true}); } else if (err === '') { @@ -61,7 +64,7 @@ class App extends React.Component<{}, state> { // set the hook to load passwordfield on global func call GlobalInfos.loadPasswordPage = (callback?: () => void): void => { // try refreshing the token - refreshAPIToken((err) => { + token.refreshAPIToken((err) => { if (err !== '') { this.setState({password: true}); } else { diff --git a/src/pages/AuthenticationPage/AuthenticationPage.test.js b/src/pages/AuthenticationPage/AuthenticationPage.test.js index 679aeb6..0762bfb 100644 --- a/src/pages/AuthenticationPage/AuthenticationPage.test.js +++ b/src/pages/AuthenticationPage/AuthenticationPage.test.js @@ -1,6 +1,7 @@ import React from 'react'; import AuthenticationPage from './AuthenticationPage'; import {shallow} from 'enzyme'; +import {token} from "../../utils/Api"; describe('', function () { it('renders without crashing ', function () { @@ -25,8 +26,7 @@ describe('', function () { it('test fail authenticate', function () { const events = mockKeyPress(); - const helpers = require('../../utils/Api'); - helpers.refreshAPIToken = jest.fn().mockImplementation((callback, force, pwd) => { + token.refreshAPIToken = jest.fn().mockImplementation((callback, force, pwd) => { callback('there was an error') }); @@ -41,8 +41,7 @@ describe('', function () { const events = mockKeyPress(); const func = jest.fn() - const helpers = require('../../utils/Api'); - helpers.refreshAPIToken = jest.fn().mockImplementation((callback, force, pwd) => { + token.refreshAPIToken = jest.fn().mockImplementation((callback, force, pwd) => { callback('') }); diff --git a/src/pages/AuthenticationPage/AuthenticationPage.tsx b/src/pages/AuthenticationPage/AuthenticationPage.tsx index 5aef7ee..65f3dd3 100644 --- a/src/pages/AuthenticationPage/AuthenticationPage.tsx +++ b/src/pages/AuthenticationPage/AuthenticationPage.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {Button} from '../../elements/GPElements/Button'; import style from './AuthenticationPage.module.css'; import {addKeyHandler, removeKeyHandler} from '../../utils/ShortkeyHandler'; -import {refreshAPIToken} from '../../utils/Api'; +import {token} from '../../utils/Api'; import {faTimes} from '@fortawesome/free-solid-svg-icons'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; @@ -76,7 +76,7 @@ class AuthenticationPage extends React.Component { * request a new token and check if pwd was valid */ authenticate(): void { - refreshAPIToken( + token.refreshAPIToken( (error) => { if (error !== '') { this.setState({wrongPWDInfo: true}); diff --git a/src/setupTests.js b/src/setupTests.js index 47cf44f..0bedd82 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -7,6 +7,8 @@ import '@testing-library/jest-dom/extend-expect'; import {configure} from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import GlobalInfos from './utils/GlobalInfos'; +import {CookieTokenStore} from "./utils/TokenStore/CookieTokenStore"; +import {token} from "./utils/Api"; configure({adapter: new Adapter()}); @@ -44,6 +46,7 @@ global.callAPIMock = (resonse) => { global.beforeEach(() => { // empty fetch response implementation for each test global.fetch = prepareFetchApi({}); + token.setTokenStore(new CookieTokenStore()); // todo with callAPIMock }); diff --git a/src/utils/Api.ts b/src/utils/Api.ts index a7c27b3..c126639 100644 --- a/src/utils/Api.ts +++ b/src/utils/Api.ts @@ -1,4 +1,5 @@ import GlobalInfos from './GlobalInfos'; +import {TokenStore} from './TokenStore/TokenStore'; const APIPREFIX: string = '/api/'; @@ -11,165 +12,127 @@ interface ApiBaseRequest { [_: string]: string | number | boolean | object; } -// store api token - empty if not set -let apiToken = ''; +export namespace token { + // store api token - empty if not set + let apiToken = ''; -// a callback que to be called after api token refresh -let callQue: ((error: string) => void)[] = []; -// flag to check wheter a api refresh is currently pending -let refreshInProcess = false; -// store the expire seconds of token -let expireSeconds = -1; + // a callback que to be called after api token refresh + let callQue: ((error: string) => void)[] = []; + // flag to check wheter a api refresh is currently pending + let refreshInProcess = false; + // store the expire seconds of token + let expireSeconds = -1; -/** - * refresh the api token or use that one in cookie if still valid - * @param callback to be called after successful refresh - * @param password - * @param force - */ -export function refreshAPIToken(callback: (error: string) => void, force?: boolean, password?: string): void { - callQue.push(callback); + let tokenStore: TokenStore; - // check if already is a token refresh is in process - if (refreshInProcess) { - // if yes return - return; - } else { - // if not set flat - refreshInProcess = true; + export function setTokenStore(ts: TokenStore): void { + tokenStore = ts; } - if (apiTokenValid() && !force) { - console.log('token still valid...'); - callFuncQue(''); - return; - } + /** + * refresh the api token or use that one in cookie if still valid + * @param callback to be called after successful refresh + * @param password + * @param force + */ + export function refreshAPIToken(callback: (error: string) => void, force?: boolean, password?: string): void { + callQue.push(callback); - const formData = new FormData(); - formData.append('grant_type', 'client_credentials'); - formData.append('client_id', 'openmediacenter'); - formData.append('client_secret', password ? password : 'openmediacenter'); - formData.append('scope', 'all'); + // check if already is a token refresh is in process + if (refreshInProcess) { + // if yes return + return; + } else { + // if not set flat + refreshInProcess = true; + } - interface APIToken { - error?: string; - // eslint-disable-next-line camelcase - access_token: string; // no camel case allowed because of backendlib - // eslint-disable-next-line camelcase - expires_in: number; // no camel case allowed because of backendlib - scope: string; - // eslint-disable-next-line camelcase - token_type: string; // no camel case allowed because of backendlib - } - - fetch('/token', {method: 'POST', body: formData}).then((response) => - response.json().then((result: APIToken) => { - if (result.error) { - callFuncQue(result.error); - return; - } - // set api token - apiToken = result.access_token; - // set expire time - expireSeconds = new Date().getTime() / 1000 + result.expires_in; - setTokenCookie(apiToken, expireSeconds); - // call all handlers and release flag + if (apiTokenValid() && !force) { + console.log('token still valid...'); callFuncQue(''); - }) - ); -} - -export function apiTokenValid(): boolean { - // check if a cookie with token is available - const token = getTokenCookie(); - if (token !== null) { - // check if token is at least valid for the next minute - if (token.expire > new Date().getTime() / 1000 + 60) { - apiToken = token.token; - expireSeconds = token.expire; - - return true; + return; } - } - return false; -} -/** - * call all qued callbacks - */ -function callFuncQue(error: string): void { - // call all pending handlers - callQue.map((func) => { - return func(error); - }); - // reset pending que - callQue = []; - // release flag to be able to start new refresh - refreshInProcess = false; -} + const formData = new FormData(); + formData.append('grant_type', 'client_credentials'); + formData.append('client_id', 'openmediacenter'); + formData.append('client_secret', password ? password : 'openmediacenter'); + formData.append('scope', 'all'); -/** - * set the cookie for the currently gotten token - * @param token token string - * @param validSec second time when the token will be invalid - */ -function setTokenCookie(token: string, validSec: number): void { - let d = new Date(); - d.setTime(validSec * 1000); - console.log('token set' + d.toUTCString()); - let expires = 'expires=' + d.toUTCString(); - document.cookie = 'token=' + token + ';' + expires + ';path=/'; - document.cookie = 'token_expire=' + validSec + ';' + expires + ';path=/'; -} - -/** - * get all required cookies for the token - */ -function getTokenCookie(): {token: string; expire: number} | null { - const token = decodeCookie('token'); - const expireInString = decodeCookie('token_expire'); - const expireIn = parseInt(expireInString, 10); - - if (expireIn !== 0 && token !== '') { - return {token: token, expire: expireIn}; - } else { - return null; - } -} - -/** - * decode a simple cookie with key specified - * @param key cookie key - */ -function decodeCookie(key: string): string { - let name = key + '='; - let decodedCookie = decodeURIComponent(document.cookie); - let ca = decodedCookie.split(';'); - for (let i = 0; i < ca.length; i++) { - let c = ca[i]; - while (c.charAt(0) === ' ') { - c = c.substring(1); + interface APIToken { + error?: string; + // eslint-disable-next-line camelcase + access_token: string; // no camel case allowed because of backendlib + // eslint-disable-next-line camelcase + expires_in: number; // no camel case allowed because of backendlib + scope: string; + // eslint-disable-next-line camelcase + token_type: string; // no camel case allowed because of backendlib } - if (c.indexOf(name) === 0) { - return c.substring(name.length, c.length); - } - } - return ''; -} -/** - * check if api token is valid -- if not request new one - * when finished call callback - * @param callback function to be called afterwards - */ -function checkAPITokenValid(callback: () => void): void { - // check if token is valid and set - if (apiToken === '' || expireSeconds <= new Date().getTime() / 1000) { - refreshAPIToken(() => { - callback(); + fetch('/token', {method: 'POST', body: formData}).then((response) => + response.json().then((result: APIToken) => { + if (result.error) { + callFuncQue(result.error); + return; + } + // set api token + apiToken = result.access_token; + // set expire time + expireSeconds = new Date().getTime() / 1000 + result.expires_in; + // setTokenCookie(apiToken, expireSeconds); + tokenStore.setToken({accessToken: apiToken, expireTime: expireSeconds, tokenType: '', scope: ''}); + // call all handlers and release flag + callFuncQue(''); + }) + ); + } + + export function apiTokenValid(): boolean { + // check if a cookie with token is available + // const token = getTokenCookie(); + const tmptoken = tokenStore.loadToken(); + if (tmptoken !== null) { + // check if token is at least valid for the next minute + if (tmptoken.expireTime > new Date().getTime() / 1000 + 60) { + apiToken = tmptoken.accessToken; + expireSeconds = tmptoken.expireTime; + + return true; + } + } + return false; + } + + /** + * call all qued callbacks + */ + function callFuncQue(error: string): void { + // call all pending handlers + callQue.map((func) => { + return func(error); }); - } else { - callback(); + // reset pending que + callQue = []; + // release flag to be able to start new refresh + refreshInProcess = false; + } + + /** + * check if api token is valid -- if not request new one + * when finished call callback + * @param callback function to be called afterwards + */ + export function checkAPITokenValid(callback: (mytoken: string) => void): void { + // check if token is valid and set + if (apiToken === '' || expireSeconds <= new Date().getTime() / 1000) { + console.log('token not valid...'); + refreshAPIToken(() => { + callback(apiToken); + }); + } else { + callback(apiToken); + } } } @@ -186,13 +149,13 @@ export function callAPI( callback: (_: T) => void, errorcallback: (_: string) => void = (_: string): void => {} ): void { - checkAPITokenValid(() => { + token.checkAPITokenValid((mytoken) => { fetch(APIPREFIX + apinode, { method: 'POST', body: JSON.stringify(fd), headers: new Headers({ 'Content-Type': 'application/json', - Authorization: 'Bearer ' + apiToken + Authorization: 'Bearer ' + mytoken }) }) .then((response) => { @@ -252,13 +215,13 @@ export function callApiUnsafe( * @param callback the callback with PLAIN text reply from backend */ export function callAPIPlain(apinode: APINode, fd: ApiBaseRequest, callback: (_: string) => void): void { - checkAPITokenValid(() => { + token.checkAPITokenValid((mytoken) => { fetch(APIPREFIX + apinode, { method: 'POST', body: JSON.stringify(fd), headers: new Headers({ 'Content-Type': 'application/json', - Authorization: 'Bearer ' + apiToken + Authorization: 'Bearer ' + mytoken }) }).then((response) => response.text().then((result) => { diff --git a/src/utils/TokenStore/CookieTokenStore.ts b/src/utils/TokenStore/CookieTokenStore.ts new file mode 100644 index 0000000..efdff79 --- /dev/null +++ b/src/utils/TokenStore/CookieTokenStore.ts @@ -0,0 +1,48 @@ +import {Token, TokenStore} from './TokenStore'; + +export class CookieTokenStore extends TokenStore { + loadToken(): Token | null { + const token = this.decodeCookie('token'); + const expireInString = this.decodeCookie('token_expire'); + const expireIn = parseInt(expireInString, 10); + + if (expireIn !== 0 && token !== '') { + return {accessToken: token, expireTime: expireIn, scope: '', tokenType: ''}; + } else { + return null; + } + } + + /** + * set the cookie for the currently gotten token + * @param token the token to set + */ + setToken(token: Token): void { + let d = new Date(); + d.setTime(token.expireTime * 1000); + console.log('token set' + d.toUTCString()); + let expires = 'expires=' + d.toUTCString(); + document.cookie = 'token=' + token.accessToken + ';' + expires + ';path=/'; + document.cookie = 'token_expire=' + token.expireTime + ';' + expires + ';path=/'; + } + + /** + * decode a simple cookie with key specified + * @param key cookie key + */ + decodeCookie(key: string): string { + let name = key + '='; + let decodedCookie = decodeURIComponent(document.cookie); + let ca = decodedCookie.split(';'); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) === ' ') { + c = c.substring(1); + } + if (c.indexOf(name) === 0) { + return c.substring(name.length, c.length); + } + } + return ''; + } +} diff --git a/src/utils/TokenStore/TokenStore.ts b/src/utils/TokenStore/TokenStore.ts new file mode 100644 index 0000000..7d36fb4 --- /dev/null +++ b/src/utils/TokenStore/TokenStore.ts @@ -0,0 +1,11 @@ +export interface Token { + accessToken: string; + expireTime: number; // second time when token will be invalidated + scope: string; + tokenType: string; +} + +export abstract class TokenStore { + abstract loadToken(): Token | null; + abstract setToken(token: Token): void; +}