Skip to content

Commit b477eea

Browse files
committed
retry hook for JWT expiry
1 parent d9568e9 commit b477eea

File tree

9 files changed

+165
-55
lines changed

9 files changed

+165
-55
lines changed

dash/dash-renderer/src/AppContainer.react.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ class UnconnectedAppContainer extends React.Component {
1313
super(props);
1414
if (
1515
props.hooks.request_pre !== null ||
16-
props.hooks.request_post !== null
16+
props.hooks.request_post !== null ||
17+
props.hooks.request_refresh_jwt !== null
1718
) {
1819
props.dispatch(setHooks(props.hooks));
1920
}

dash/dash-renderer/src/AppProvider.react.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@ const AppProvider = ({hooks}: any) => {
1818
AppProvider.propTypes = {
1919
hooks: PropTypes.shape({
2020
request_pre: PropTypes.func,
21-
request_post: PropTypes.func
21+
request_post: PropTypes.func,
22+
request_refresh_jwt: PropTypes.func
2223
})
2324
};
2425

2526
AppProvider.defaultProps = {
2627
hooks: {
2728
request_pre: null,
28-
request_post: null
29+
request_post: null,
30+
request_refresh_jwt: null
2931
}
3032
};
3133

dash/dash-renderer/src/actions/api.js

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {mergeDeepRight, once} from 'ramda';
2-
import {handleAsyncError, getCSRFHeader} from '../actions';
2+
import {getCSRFHeader, handleAsyncError, setConfigNoRefresh} from '../actions';
33
import {urlBase} from './utils';
4+
import {MAX_AUTH_RETRIES} from './constants';
5+
import {JWT_EXPIRED_MESSAGE, STATUS} from '../constants/constants';
46

57
/* eslint-disable-next-line no-console */
68
const logWarningOnce = once(console.warn);
@@ -29,8 +31,10 @@ function POST(path, fetchConfig, body = {}) {
2931
const request = {GET, POST};
3032

3133
export default function apiThunk(endpoint, method, store, id, body) {
32-
return (dispatch, getState) => {
33-
const {config} = getState();
34+
return async (dispatch, getState) => {
35+
let {config, hooks} = getState();
36+
let configChanged = false;
37+
3438
const url = `${urlBase(config)}${endpoint}`;
3539

3640
function setConnectionStatus(connected) {
@@ -46,48 +50,81 @@ export default function apiThunk(endpoint, method, store, id, body) {
4650
type: store,
4751
payload: {id, status: 'loading'}
4852
});
49-
return request[method](url, config.fetch, body)
50-
.then(
51-
res => {
52-
setConnectionStatus(true);
53-
const contentType = res.headers.get('content-type');
54-
if (
55-
contentType &&
56-
contentType.indexOf('application/json') !== -1
57-
) {
58-
return res.json().then(json => {
59-
dispatch({
60-
type: store,
61-
payload: {
62-
status: res.status,
63-
content: json,
64-
id
65-
}
66-
});
67-
return json;
68-
});
53+
54+
try {
55+
let res;
56+
for (let retry = 0; retry <= MAX_AUTH_RETRIES; retry++) {
57+
try {
58+
res = await request[method](url, config.fetch, body);
59+
} catch (e) {
60+
// fetch rejection - this means the request didn't return,
61+
// we don't get here from 400/500 errors, only network
62+
// errors or unresponsive servers.
63+
console.log('fetch error', res);
64+
setConnectionStatus(false);
65+
throw e;
66+
}
67+
68+
if (res.status === STATUS.FORBIDDEN) {
69+
console.log(getState());
70+
if (hooks.request_refresh_jwt) {
71+
const body = await res.text();
72+
if (body.includes(JWT_EXPIRED_MESSAGE)) {
73+
const newJwt = await hooks.request_refresh_jwt(
74+
config.fetch.headers.Authorization.substr(
75+
'Bearer '.length
76+
)
77+
);
78+
if (newJwt) {
79+
config = mergeDeepRight(config, {
80+
fetch: {
81+
headers: {
82+
Authorization: `Bearer ${newJwt}`
83+
}
84+
}
85+
});
86+
configChanged = true;
87+
88+
continue;
89+
}
90+
}
6991
}
70-
logWarningOnce(
71-
'Response is missing header: content-type: application/json'
72-
);
73-
return dispatch({
92+
}
93+
break;
94+
}
95+
96+
const contentType = res.headers.get('content-type');
97+
98+
if (configChanged) {
99+
dispatch(setConfigNoRefresh(config));
100+
}
101+
setConnectionStatus(true);
102+
if (contentType && contentType.indexOf('application/json') !== -1) {
103+
return res.json().then(json => {
104+
dispatch({
74105
type: store,
75106
payload: {
76-
id,
77-
status: res.status
107+
status: res.status,
108+
content: json,
109+
id
78110
}
79111
});
80-
},
81-
() => {
82-
// fetch rejection - this means the request didn't return,
83-
// we don't get here from 400/500 errors, only network
84-
// errors or unresponsive servers.
85-
setConnectionStatus(false);
112+
return json;
113+
});
114+
}
115+
logWarningOnce(
116+
'Response is missing header: content-type: application/json'
117+
);
118+
return dispatch({
119+
type: store,
120+
payload: {
121+
id,
122+
status: res.status
86123
}
87-
)
88-
.catch(err => {
89-
const message = 'Error from API call: ' + endpoint;
90-
handleAsyncError(err, message, dispatch);
91124
});
125+
} catch (err) {
126+
const message = 'Error from API call: ' + endpoint;
127+
handleAsyncError(err, message, dispatch);
128+
}
92129
};
93130
}

dash/dash-renderer/src/actions/callbacks.ts

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
zip
1111
} from 'ramda';
1212

13-
import {STATUS} from '../constants/constants';
13+
import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants';
14+
import {MAX_AUTH_RETRIES} from './constants';
1415
import {
1516
CallbackActionType,
1617
CallbackAggregateActionType
@@ -29,6 +30,7 @@ import {isMultiValued, stringifyId, isMultiOutputProp} from './dependencies';
2930
import {urlBase} from './utils';
3031
import {getCSRFHeader} from '.';
3132
import {createAction, Action} from 'redux-actions';
33+
import {setConfigNoRefresh} from '../actions';
3234

3335
export const addBlockedCallbacks = createAction<IBlockedCallback[]>(
3436
CallbackActionType.AddBlocked
@@ -488,7 +490,7 @@ export function executeCallback(
488490
};
489491
}
490492

491-
const __promise = new Promise<CallbackResult>(resolve => {
493+
const __execute = async (): Promise<CallbackResult> => {
492494
try {
493495
const payload: ICallbackPayload = {
494496
output,
@@ -502,32 +504,85 @@ export function executeCallback(
502504

503505
if (clientside_function) {
504506
try {
505-
resolve({
507+
return {
506508
data: handleClientside(
507509
dispatch,
508510
clientside_function,
509511
config,
510512
payload
511513
),
512514
payload
513-
});
515+
};
514516
} catch (error) {
515-
resolve({error, payload});
517+
return {error, payload};
516518
}
517-
return null;
518519
}
519520

520-
handleServerside(dispatch, hooks, config, payload)
521-
.then(data => resolve({data, payload}))
522-
.catch(error => resolve({error, payload}));
521+
let newConfig = config;
522+
let configChanged = false;
523+
let retry = 0;
524+
525+
while (true) {
526+
try {
527+
const data = await handleServerside(
528+
dispatch,
529+
hooks,
530+
newConfig,
531+
payload
532+
);
533+
534+
if (configChanged) {
535+
dispatch(setConfigNoRefresh(newConfig));
536+
}
537+
538+
return {data, payload};
539+
} catch (res) {
540+
retry++;
541+
542+
if (
543+
retry <= MAX_AUTH_RETRIES &&
544+
res.status === STATUS.FORBIDDEN
545+
) {
546+
const body = await res.text();
547+
548+
if (body.includes(JWT_EXPIRED_MESSAGE)) {
549+
// From dash embedded
550+
if (hooks.request_refresh_jwt !== null) {
551+
const newJwt =
552+
await hooks.request_refresh_jwt(
553+
config.fetch.headers.Authorization.substr(
554+
'Bearer '.length
555+
)
556+
);
557+
if (newJwt) {
558+
newConfig = mergeDeepRight(config, {
559+
fetch: {
560+
headers: {
561+
Authorization: `Bearer ${newJwt}`
562+
}
563+
}
564+
});
565+
566+
configChanged = true;
567+
568+
continue;
569+
}
570+
}
571+
}
572+
}
573+
574+
// here, it is an error we're not supposed to retry.
575+
throw res;
576+
}
577+
}
523578
} catch (error) {
524-
resolve({error, payload: null});
579+
return {error, payload: null};
525580
}
526-
});
581+
};
527582

528583
const newCb = {
529584
...cb,
530-
executionPromise: __promise
585+
executionPromise: __execute()
531586
};
532587

533588
return newCb;

dash/dash-renderer/src/actions/constants.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const actionList = {
66
SET_LAYOUT: 1,
77
SET_APP_LIFECYCLE: 1,
88
SET_CONFIG: 1,
9+
SET_CONFIG_NO_REFRESH: 2,
910
ON_ERROR: 1,
1011
SET_HOOKS: 1
1112
};
@@ -16,3 +17,5 @@ export const getAction = action => {
1617
}
1718
throw new Error(`${action} is not defined.`);
1819
};
20+
21+
export const MAX_AUTH_RETRIES = 1;

dash/dash-renderer/src/actions/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import {getPath} from './paths';
1111
export const onError = createAction(getAction('ON_ERROR'));
1212
export const setAppLifecycle = createAction(getAction('SET_APP_LIFECYCLE'));
1313
export const setConfig = createAction(getAction('SET_CONFIG'));
14+
export const setConfigNoRefresh = createAction(
15+
getAction('SET_CONFIG_NO_REFRESH')
16+
);
1417
export const setGraphs = createAction(getAction('SET_GRAPHS'));
1518
export const setHooks = createAction(getAction('SET_HOOKS'));
1619
export const setLayout = createAction(getAction('SET_LAYOUT'));

dash/dash-renderer/src/constants/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
export const REDIRECT_URI_PATHNAME = '/_oauth2/callback';
22
export const OAUTH_COOKIE_NAME = 'plotly_oauth_token';
3+
export const JWT_EXPIRED_MESSAGE = 'JWT Expired';
34

45
export const STATUS = {
56
OK: 200,
67
PREVENT_UPDATE: 204,
8+
FORBIDDEN: 401,
79
CLIENTSIDE_ERROR: 'CLIENTSIDE_ERROR',
810
NO_RESPONSE: 'NO_RESPONSE'
911
};

dash/dash-renderer/src/reducers/config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {getAction} from '../actions/constants';
33
export default function config(state = null, action) {
44
if (action.type === getAction('SET_CONFIG')) {
55
return action.payload;
6+
} else if (action.type === getAction('SET_CONFIG_NO_REFRESH')) {
7+
return action.payload;
68
}
79
return state;
810
}

dash/dash-renderer/src/reducers/hooks.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
const customHooks = (
2-
state = {request_pre: null, request_post: null, bear: false},
2+
state = {
3+
request_pre: null,
4+
request_post: null,
5+
request_refresh_jwt: null,
6+
bear: false
7+
},
38
action
49
) => {
510
switch (action.type) {

0 commit comments

Comments
 (0)