fix: handle token expiry (#3847)
This commit is contained in:
parent
43ef28b5dc
commit
285c940562
@ -23,14 +23,15 @@ export default class BitbucketAuthenticationPage extends React.Component {
|
||||
state = {};
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
auth_type: authType = '',
|
||||
base_url = 'https://bitbucket.org',
|
||||
auth_endpoint = 'site/oauth2/authorize',
|
||||
app_id = '',
|
||||
} = this.props.config.backend;
|
||||
const { auth_type: authType = '' } = this.props.config.backend;
|
||||
|
||||
if (authType === 'implicit') {
|
||||
const {
|
||||
base_url = 'https://bitbucket.org',
|
||||
auth_endpoint = 'site/oauth2/authorize',
|
||||
app_id = '',
|
||||
} = this.props.config.backend;
|
||||
|
||||
this.auth = new ImplicitAuthenticator({
|
||||
base_url,
|
||||
auth_endpoint,
|
||||
|
@ -37,6 +37,7 @@ import {
|
||||
generateContentKey,
|
||||
localForage,
|
||||
allEntriesByFolder,
|
||||
AccessTokenError,
|
||||
} from 'netlify-cms-lib-util';
|
||||
import { NetlifyAuthenticator } from 'netlify-cms-lib-auth';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
@ -66,12 +67,12 @@ export default class BitbucketBackend implements Implementation {
|
||||
refreshToken?: string;
|
||||
refreshedTokenPromise?: Promise<string>;
|
||||
authenticator?: NetlifyAuthenticator;
|
||||
auth?: unknown;
|
||||
_mediaDisplayURLSem?: Semaphore;
|
||||
squashMerges: boolean;
|
||||
previewContext: string;
|
||||
largeMediaURL: string;
|
||||
_largeMediaClientPromise?: Promise<GitLfsClient>;
|
||||
authType: string;
|
||||
|
||||
constructor(config: Config, options = {}) {
|
||||
this.options = {
|
||||
@ -105,12 +106,26 @@ export default class BitbucketBackend implements Implementation {
|
||||
this.squashMerges = config.backend.squash_merges || false;
|
||||
this.previewContext = config.backend.preview_context || '';
|
||||
this.lock = asyncLock();
|
||||
this.authType = config.backend.auth_type || '';
|
||||
}
|
||||
|
||||
isGitBackend() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async status() {
|
||||
const auth =
|
||||
(await this.api
|
||||
?.user()
|
||||
.then(user => !!user)
|
||||
.catch(e => {
|
||||
console.warn('Failed getting Bitbucket user', e);
|
||||
return false;
|
||||
})) || false;
|
||||
|
||||
return { auth };
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
@ -180,12 +195,15 @@ export default class BitbucketBackend implements Implementation {
|
||||
}
|
||||
|
||||
getRefreshedAccessToken() {
|
||||
if (this.authType === 'implicit') {
|
||||
throw new AccessTokenError(`Can't refresh access token when using implicit auth`);
|
||||
}
|
||||
if (this.refreshedTokenPromise) {
|
||||
return this.refreshedTokenPromise;
|
||||
}
|
||||
|
||||
// instantiating a new Authenticator on each refresh isn't ideal,
|
||||
if (!this.auth) {
|
||||
if (!this.authenticator) {
|
||||
const cfg = {
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
base_url: this.baseUrl,
|
||||
|
@ -24,6 +24,7 @@ import {
|
||||
getPointerFileForMediaFileObj,
|
||||
getLargeMediaFilteredMediaFiles,
|
||||
DisplayURLObject,
|
||||
AccessTokenError,
|
||||
} from 'netlify-cms-lib-util';
|
||||
import { GitHubBackend } from 'netlify-cms-backend-github';
|
||||
import { GitLabBackend } from 'netlify-cms-backend-gitlab';
|
||||
@ -38,12 +39,14 @@ type NetlifyIdentity = {
|
||||
currentUser: () => User;
|
||||
on: (event: string, args: unknown) => void;
|
||||
init: () => void;
|
||||
store: { user: unknown; modal: { page: string }; saving: boolean };
|
||||
};
|
||||
|
||||
type AuthClient = {
|
||||
logout: () => void;
|
||||
currentUser: () => unknown;
|
||||
login?(email: string, password: string, remember?: boolean): Promise<unknown>;
|
||||
clearStore: () => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
@ -168,6 +171,18 @@ export default class GitGateway implements Implementation {
|
||||
return true;
|
||||
}
|
||||
|
||||
async status() {
|
||||
const auth =
|
||||
(await this.tokenPromise?.()
|
||||
.then(token => !!token)
|
||||
.catch(e => {
|
||||
console.warn('Failed getting Identity token', e);
|
||||
return false;
|
||||
})) || false;
|
||||
|
||||
return { auth };
|
||||
}
|
||||
|
||||
async getAuthClient() {
|
||||
if (this.authClient) {
|
||||
return this.authClient;
|
||||
@ -177,6 +192,14 @@ export default class GitGateway implements Implementation {
|
||||
this.authClient = {
|
||||
logout: () => window.netlifyIdentity?.logout(),
|
||||
currentUser: () => window.netlifyIdentity?.currentUser(),
|
||||
clearStore: () => {
|
||||
const store = window.netlifyIdentity?.store;
|
||||
if (store) {
|
||||
store.user = null;
|
||||
store.modal.page = 'login';
|
||||
store.saving = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const goTrue = new GoTrue({ APIUrl: this.apiUrl });
|
||||
@ -189,6 +212,7 @@ export default class GitGateway implements Implementation {
|
||||
},
|
||||
currentUser: () => goTrue.currentUser(),
|
||||
login: goTrue.login.bind(goTrue),
|
||||
clearStore: () => undefined,
|
||||
};
|
||||
}
|
||||
return this.authClient;
|
||||
@ -203,7 +227,15 @@ export default class GitGateway implements Implementation {
|
||||
|
||||
authenticate(credentials: Credentials) {
|
||||
const user = credentials as NetlifyUser;
|
||||
this.tokenPromise = user.jwt.bind(user);
|
||||
this.tokenPromise = async () => {
|
||||
try {
|
||||
const func = user.jwt.bind(user);
|
||||
const token = await func();
|
||||
return token;
|
||||
} catch (error) {
|
||||
throw new AccessTokenError(`Failed getting access token: ${error.message}`);
|
||||
}
|
||||
};
|
||||
return this.tokenPromise!().then(async token => {
|
||||
if (!this.backendType) {
|
||||
const {
|
||||
@ -303,7 +335,13 @@ export default class GitGateway implements Implementation {
|
||||
|
||||
async logout() {
|
||||
const client = await this.getAuthClient();
|
||||
return client.logout();
|
||||
try {
|
||||
client.logout();
|
||||
} catch (e) {
|
||||
// due to a bug in the identity widget (gotrue-js actually) the store is not reset if logout fails
|
||||
// TODO: remove after https://github.com/netlify/gotrue-js/pull/83 is merged
|
||||
client.clearStore();
|
||||
}
|
||||
}
|
||||
getToken() {
|
||||
return this.tokenPromise!();
|
||||
|
@ -202,11 +202,15 @@ export default class API {
|
||||
|
||||
user(): Promise<{ name: string; login: string }> {
|
||||
if (!this._userPromise) {
|
||||
this._userPromise = this.request('/user') as Promise<GitHubUser>;
|
||||
this._userPromise = this.getUser();
|
||||
}
|
||||
return this._userPromise;
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this.request('/user') as Promise<GitHubUser>;
|
||||
}
|
||||
|
||||
async hasWriteAccess() {
|
||||
try {
|
||||
const result: Octokit.ReposGetResponse = await this.request(this.repoURL);
|
||||
|
@ -111,6 +111,19 @@ export default class GitHub implements Implementation {
|
||||
return true;
|
||||
}
|
||||
|
||||
async status() {
|
||||
const auth =
|
||||
(await this.api
|
||||
?.getUser()
|
||||
.then(user => !!user)
|
||||
.catch(e => {
|
||||
console.warn('Failed getting GitHub user', e);
|
||||
return false;
|
||||
})) || false;
|
||||
|
||||
return { auth };
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
const wrappedAuthenticationPage = (props: Record<string, unknown>) => (
|
||||
<AuthenticationPage {...props} backend={this} />
|
||||
|
@ -87,6 +87,19 @@ export default class GitLab implements Implementation {
|
||||
return true;
|
||||
}
|
||||
|
||||
async status() {
|
||||
const auth =
|
||||
(await this.api
|
||||
?.user()
|
||||
.then(user => !!user)
|
||||
.catch(e => {
|
||||
console.warn('Failed getting GitLab user', e);
|
||||
return false;
|
||||
})) || false;
|
||||
|
||||
return { auth };
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
@ -63,6 +63,10 @@ export default class ProxyBackend implements Implementation {
|
||||
return false;
|
||||
}
|
||||
|
||||
status() {
|
||||
return Promise.resolve({ auth: true });
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
@ -101,6 +101,10 @@ export default class TestBackend implements Implementation {
|
||||
return false;
|
||||
}
|
||||
|
||||
status() {
|
||||
return Promise.resolve({ auth: true });
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { actions as notifActions } from 'redux-notifications';
|
||||
import { currentBackend } from 'coreSrc/backend';
|
||||
|
||||
const { notifSend } = notifActions;
|
||||
const { notifSend, notifClear } = notifActions;
|
||||
|
||||
export const AUTH_REQUEST = 'AUTH_REQUEST';
|
||||
export const AUTH_SUCCESS = 'AUTH_SUCCESS';
|
||||
@ -111,6 +111,7 @@ export function logoutUser() {
|
||||
const backend = currentBackend(state.config);
|
||||
Promise.resolve(backend.logout()).then(() => {
|
||||
dispatch(logout());
|
||||
dispatch(notifClear());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
66
packages/netlify-cms-core/src/actions/status.ts
Normal file
66
packages/netlify-cms-core/src/actions/status.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { State } from '../types/redux';
|
||||
import { currentBackend } from '../backend';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
import { AnyAction } from 'redux';
|
||||
import { actions as notifActions } from 'redux-notifications';
|
||||
|
||||
const { notifSend } = notifActions;
|
||||
|
||||
export const STATUS_REQUEST = 'STATUS_REQUEST';
|
||||
export const STATUS_SUCCESS = 'STATUS_SUCCESS';
|
||||
export const STATUS_FAILURE = 'STATUS_FAILURE';
|
||||
|
||||
export function statusRequest() {
|
||||
return {
|
||||
type: STATUS_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function statusSuccess(status: { auth: boolean }) {
|
||||
return {
|
||||
type: STATUS_SUCCESS,
|
||||
payload: { status },
|
||||
};
|
||||
}
|
||||
|
||||
export function statusFailure(error: Error) {
|
||||
return {
|
||||
type: STATUS_FAILURE,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkBackendStatus() {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
try {
|
||||
const state = getState();
|
||||
if (state.status.get('isFetching') === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(statusRequest());
|
||||
const backend = currentBackend(state.config);
|
||||
const status = await backend.status();
|
||||
|
||||
const authError = status.auth === false;
|
||||
if (authError) {
|
||||
const key = 'ui.toast.onLoggedOut';
|
||||
const existingNotification = state.notifs.find(n => n.message?.key === key);
|
||||
if (!existingNotification) {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: {
|
||||
key: 'ui.toast.onLoggedOut',
|
||||
},
|
||||
kind: 'danger',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(statusSuccess(status));
|
||||
} catch (error) {
|
||||
dispatch(statusFailure(error));
|
||||
}
|
||||
};
|
||||
}
|
@ -179,6 +179,21 @@ export class Backend {
|
||||
}
|
||||
}
|
||||
|
||||
async status() {
|
||||
const attempts = 3;
|
||||
let status: { auth: boolean } = { auth: false };
|
||||
for (let i = 1; i <= attempts; i++) {
|
||||
status = await this.implementation!.status();
|
||||
// return on first success
|
||||
if (Object.values(status).every(s => s === true)) {
|
||||
return status;
|
||||
} else {
|
||||
await new Promise(resolve => setTimeout(resolve, i * 1000));
|
||||
}
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
currentUser() {
|
||||
if (this.user) {
|
||||
return this.user;
|
||||
@ -222,13 +237,17 @@ export class Backend {
|
||||
});
|
||||
}
|
||||
|
||||
logout() {
|
||||
return Promise.resolve(this.implementation.logout()).then(() => {
|
||||
async logout() {
|
||||
try {
|
||||
await this.implementation.logout();
|
||||
} catch (e) {
|
||||
console.warn('Error during logout', e.message);
|
||||
} finally {
|
||||
this.user = null;
|
||||
if (this.authStore) {
|
||||
this.authStore.logout();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getToken = () => this.implementation.getToken();
|
||||
|
@ -17,6 +17,8 @@ import {
|
||||
zIndex,
|
||||
} from 'netlify-cms-ui-default';
|
||||
import SettingsDropdown from 'UI/SettingsDropdown';
|
||||
import { connect } from 'react-redux';
|
||||
import { checkBackendStatus } from '../../actions/status';
|
||||
|
||||
const styles = {
|
||||
buttonActive: css`
|
||||
@ -120,8 +122,21 @@ class Header extends React.Component {
|
||||
displayUrl: PropTypes.string,
|
||||
isTestRepo: PropTypes.bool,
|
||||
t: PropTypes.func.isRequired,
|
||||
checkBackendStatus: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
intervalId;
|
||||
|
||||
componentDidMount() {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.props.checkBackendStatus();
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
handleCreatePostClick = collectionName => {
|
||||
const { onCreateEntryClick } = this.props;
|
||||
if (onCreateEntryClick) {
|
||||
@ -211,4 +226,8 @@ class Header extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default translate()(Header);
|
||||
const mapDispatchToProps = {
|
||||
checkBackendStatus,
|
||||
};
|
||||
|
||||
export default connect(null, mapDispatchToProps)(translate()(Header));
|
||||
|
@ -1,7 +1,12 @@
|
||||
import { Map } from 'immutable';
|
||||
import { USE_OPEN_AUTHORING } from 'Actions/auth';
|
||||
|
||||
const LOADING_IGNORE_LIST = ['DEPLOY_PREVIEW'];
|
||||
const LOADING_IGNORE_LIST = [
|
||||
'DEPLOY_PREVIEW',
|
||||
'STATUS_REQUEST',
|
||||
'STATUS_SUCCESS',
|
||||
'STATUS_FAILURE',
|
||||
];
|
||||
|
||||
const ignoreWhenLoading = action => LOADING_IGNORE_LIST.some(type => action.type.includes(type));
|
||||
|
||||
|
@ -11,6 +11,7 @@ import medias from './medias';
|
||||
import mediaLibrary from './mediaLibrary';
|
||||
import deploys, * as fromDeploys from './deploys';
|
||||
import globalUI from './globalUI';
|
||||
import status from './status';
|
||||
import { Status } from '../constants/publishModes';
|
||||
import { State, Collection } from '../types/redux';
|
||||
|
||||
@ -28,6 +29,7 @@ const reducers = {
|
||||
mediaLibrary,
|
||||
deploys,
|
||||
globalUI,
|
||||
status,
|
||||
};
|
||||
|
||||
export default reducers;
|
||||
|
33
packages/netlify-cms-core/src/reducers/status.ts
Normal file
33
packages/netlify-cms-core/src/reducers/status.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Map, fromJS } from 'immutable';
|
||||
import { AnyAction } from 'redux';
|
||||
import { STATUS_REQUEST, STATUS_SUCCESS, STATUS_FAILURE } from '../actions/status';
|
||||
import { Status } from '../types/redux';
|
||||
|
||||
export interface EntriesAction extends AnyAction {
|
||||
payload: { status: { auth: boolean }; error?: Error };
|
||||
}
|
||||
|
||||
const status = (state = Map(), action: EntriesAction) => {
|
||||
switch (action.type) {
|
||||
case STATUS_REQUEST:
|
||||
return state.set('isFetching', true);
|
||||
case STATUS_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.set('isFetching', false);
|
||||
map.set('status', fromJS(action.payload.status));
|
||||
});
|
||||
case STATUS_FAILURE:
|
||||
return state.withMutations(map => {
|
||||
map.set('isFetching', false);
|
||||
map.set('error', action.payload.error);
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const selectStatus = (status: Status) => {
|
||||
return status.get('status')?.toJS() || {};
|
||||
};
|
||||
|
||||
export default status;
|
@ -241,6 +241,11 @@ export type Search = StaticallyTypedRecord<{
|
||||
|
||||
export type Cursors = StaticallyTypedRecord<{}>;
|
||||
|
||||
export type Status = StaticallyTypedRecord<{
|
||||
isFetching: boolean;
|
||||
status: StaticallyTypedRecord<{ auth: boolean }>;
|
||||
}>;
|
||||
|
||||
export interface State {
|
||||
config: Config;
|
||||
cursors: Cursors;
|
||||
@ -253,6 +258,8 @@ export interface State {
|
||||
medias: Medias;
|
||||
mediaLibrary: MediaLibrary;
|
||||
search: Search;
|
||||
notifs: { message: { key: string }; kind: string; id: number }[];
|
||||
status: Status;
|
||||
}
|
||||
|
||||
export interface MediasAction extends Action<string> {
|
||||
|
11
packages/netlify-cms-lib-util/src/AccessTokenError.ts
Normal file
11
packages/netlify-cms-lib-util/src/AccessTokenError.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export const ACCESS_TOKEN_ERROR = 'ACCESS_TOKEN_ERROR';
|
||||
|
||||
export default class AccessTokenError extends Error {
|
||||
message: string;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.message = message;
|
||||
this.name = ACCESS_TOKEN_ERROR;
|
||||
}
|
||||
}
|
@ -84,6 +84,8 @@ export type Config = {
|
||||
large_media_url?: string;
|
||||
use_large_media_transforms_in_media_library?: boolean;
|
||||
proxy_url?: string;
|
||||
auth_type?: string;
|
||||
app_id?: string;
|
||||
};
|
||||
media_folder: string;
|
||||
base_url?: string;
|
||||
@ -139,6 +141,7 @@ export interface Implementation {
|
||||
) => Promise<{ entries: ImplementationEntry[]; cursor: Cursor }>;
|
||||
|
||||
isGitBackend?: () => boolean;
|
||||
status: () => Promise<{ auth: boolean }>;
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import APIError from './APIError';
|
||||
import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from './Cursor';
|
||||
import EditorialWorkflowError, { EDITORIAL_WORKFLOW_ERROR } from './EditorialWorkflowError';
|
||||
import AccessTokenError from './AccessTokenError';
|
||||
import localForage from './localForage';
|
||||
import { isAbsolutePath, basename, fileExtensionWithSeparator, fileExtension } from './path';
|
||||
import { onlySuccessfulPromises, flowAsync, then } from './promise';
|
||||
@ -138,6 +139,7 @@ export const NetlifyCmsLibUtil = {
|
||||
blobToFileObj,
|
||||
requestWithBackoff,
|
||||
allEntriesByFolder,
|
||||
AccessTokenError,
|
||||
};
|
||||
export {
|
||||
APIError,
|
||||
@ -192,4 +194,5 @@ export {
|
||||
blobToFileObj,
|
||||
requestWithBackoff,
|
||||
allEntriesByFolder,
|
||||
AccessTokenError,
|
||||
};
|
||||
|
@ -223,6 +223,7 @@ const en = {
|
||||
entryUpdated: 'Entry status updated',
|
||||
onDeleteUnpublishedChanges: 'Unpublished changes deleted',
|
||||
onFailToAuth: '%{details}',
|
||||
onLoggedOut: 'You have been logged out, please back up any data and login again',
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
|
Loading…
x
Reference in New Issue
Block a user