Fix sdk auth (#3337)

* Fix format errors

* Fix lint error

* Fix auth token setter on server

* Fix auth logout for JSON
This commit is contained in:
Aleksandar
2021-01-30 00:13:15 +01:00
committed by GitHub
parent 5d96529341
commit 22bcf1244d
11 changed files with 277 additions and 87 deletions

View File

@@ -1,4 +1,4 @@
import { AxiosInstance } from 'axios';
import { AxiosInstance, AxiosRequestConfig } from 'axios';
import { AuthStorage } from '../types';
export type LoginCredentials = {
@@ -24,6 +24,13 @@ export class AuthHandler {
private storage: AuthStorage;
private mode: 'cookie' | 'json';
private autoRefresh: boolean;
private autoRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
private expiresAt?: number;
/**
* Used for tracking if accessToken is restored from store to config.
* Axios uses this number for interceptor. If it's number it means it's inited.
*/
private accessTokenInitId: number | null = null;
constructor(axios: AxiosInstance, options: AuthOptions) {
this.axios = axios;
@@ -31,6 +38,8 @@ export class AuthHandler {
this.mode = options.mode;
this.autoRefresh = options.autoRefresh;
this.accessTokenInitId = this.axios.interceptors.request.use((config) => this.initializeAccessToken(config));
if (this.autoRefresh) {
this.refresh();
}
@@ -41,32 +50,51 @@ export class AuthHandler {
}
set token(val: string | null) {
this.axios.defaults.headers = {
...(this.axios.defaults.headers || {}),
Authorization: val ? `Bearer ${val}` : undefined,
};
if (val === null) {
delete this.axios.defaults.headers?.Authorization;
} else {
this.axios.defaults.headers = {
...(this.axios.defaults.headers || {}),
Authorization: `Bearer ${val}`,
};
}
}
async login(credentials: LoginCredentials): Promise<{ data: AuthResponse }> {
const response = await this.axios.post<{ data: AuthResponse }>('/auth/login', {
...credentials,
mode: this.mode,
});
this.removeTimeout();
const response = await this.axios.post('/auth/login', { ...credentials, mode: this.mode });
this.token = response.data.data.access_token;
const data = response.data.data;
this.token = data.access_token;
this.expiresAt = Date.now() + data.expires;
await this.storage.setItem('directus_access_token', this.token);
await this.storage.setItem('directus_access_token_expires', this.expiresAt);
if (this.mode === 'json') {
await this.storage.setItem('directus_refresh_token', response.data.data.refresh_token);
await this.storage.setItem('directus_refresh_token', data.refresh_token);
}
if (this.autoRefresh) {
setTimeout(() => this.refresh(), response.data.data.expires - 10000);
this.refresh();
}
return response.data;
}
async refresh(): Promise<{ data: AuthResponse }> {
/**
* Refresh access token 10 seconds before expiration
*/
async refresh(): Promise<{ data: AuthResponse } | undefined> {
this.removeTimeout();
this.expiresAt = await this.storage.getItem('directus_access_token_expires');
if (!this.expiresAt) return;
if (Date.now() + 10000 < this.expiresAt && this.autoRefresh) {
this.autoRefreshTimeout = setTimeout(() => this.refresh(), this.expiresAt - Date.now() - 10000);
return;
}
const payload: Record<string, any> = { mode: this.mode };
if (this.mode === 'json') {
@@ -74,23 +102,34 @@ export class AuthHandler {
payload['refresh_token'] = refreshToken;
}
if (this.expiresAt < Date.now() + 1000) {
this.token = null;
}
const response = await this.axios.post<{ data: AuthResponse }>('/auth/refresh', payload);
this.token = response.data.data.access_token;
const data = response.data.data;
this.token = data.access_token;
this.expiresAt = Date.now() + data.expires;
await this.storage.setItem('directus_access_token', this.token);
await this.storage.setItem('directus_access_token_expires', this.expiresAt);
if (this.mode === 'json') {
await this.storage.setItem('directus_refresh_token', response.data.data.refresh_token);
}
if (this.autoRefresh) {
setTimeout(() => this.refresh(), response.data.data.expires - 10000);
this.autoRefreshTimeout = setTimeout(() => this.refresh(), data.expires - 10000);
}
return response.data;
}
async logout(): Promise<void> {
await this.axios.post('/auth/logout');
this.removeTimeout();
const data: Record<string, string> = {};
if (this.mode === 'json') {
data.refresh_token = await this.storage.getItem('directus_refresh_token');
}
await this.axios.post('/auth/logout', data);
this.token = null;
}
@@ -103,4 +142,31 @@ export class AuthHandler {
await this.axios.post('/auth/password/reset', { token, password });
},
};
/**
* There is no prettier way to do this. We need to set access token before first request.
* This way we intercept axios request and only first time request token from store,
* and allows us to do new Directus(url).items(col).read() without having to handle
* access_token restoration in methods
*/
private async initializeAccessToken(config: AxiosRequestConfig): Promise<AxiosRequestConfig> {
if (this.accessTokenInitId !== null) {
const token = await this.storage.getItem('directus_access_token');
if (token) {
this.token = token;
config.headers.Authorization = `Bearer ${token}`;
}
this.axios.interceptors.request.eject(this.accessTokenInitId);
this.accessTokenInitId = null;
}
return config;
}
private removeTimeout(): void {
if (this.autoRefreshTimeout !== null) {
clearTimeout(this.autoRefreshTimeout);
this.autoRefreshTimeout = null;
}
}
}

View File

@@ -7,17 +7,12 @@ export class ItemsHandler {
constructor(collection: string, axios: AxiosInstance) {
this.axios = axios;
this.endpoint = collection.startsWith('directus_')
? `/${collection.substring(9)}/`
: `/items/${collection}/`;
this.endpoint = collection.startsWith('directus_') ? `/${collection.substring(9)}/` : `/items/${collection}/`;
}
async create<T extends Item>(payload: Payload, query?: Query): Promise<Response<T>>;
async create<T extends Item>(payloads: Payload[], query?: Query): Promise<Response<T[]>>;
async create<T extends Item>(
payloads: Payload | Payload[],
query?: Query
): Promise<Response<T | T[]>> {
async create<T extends Item>(payloads: Payload | Payload[], query?: Query): Promise<Response<T | T[]>> {
const result = await this.axios.post(this.endpoint, payloads, {
params: query,
});
@@ -38,9 +33,7 @@ export class ItemsHandler {
if (
keysOrQuery &&
(Array.isArray(keysOrQuery) ||
typeof keysOrQuery === 'string' ||
typeof keysOrQuery === 'number')
(Array.isArray(keysOrQuery) || typeof keysOrQuery === 'string' || typeof keysOrQuery === 'number')
) {
keys = keysOrQuery;
}
@@ -49,11 +42,7 @@ export class ItemsHandler {
if (query) {
params = query;
} else if (
!query &&
typeof keysOrQuery === 'object' &&
Array.isArray(keysOrQuery) === false
) {
} else if (!query && typeof keysOrQuery === 'object' && Array.isArray(keysOrQuery) === false) {
params = keysOrQuery as Query;
}
@@ -68,16 +57,8 @@ export class ItemsHandler {
return result.data;
}
async update<T extends Item>(
key: PrimaryKey,
payload: Payload,
query?: Query
): Promise<Response<T>>;
async update<T extends Item>(
keys: PrimaryKey[],
payload: Payload,
query?: Query
): Promise<Response<T[]>>;
async update<T extends Item>(key: PrimaryKey, payload: Payload, query?: Query): Promise<Response<T>>;
async update<T extends Item>(keys: PrimaryKey[], payload: Payload, query?: Query): Promise<Response<T[]>>;
async update<T extends Item>(payload: Payload[], query?: Query): Promise<Response<T[]>>;
async update<T extends Item>(payload: Payload, query: Query): Promise<Response<T[]>>;
async update<T extends Item>(
@@ -88,8 +69,7 @@ export class ItemsHandler {
if (
typeof keyOrPayload === 'string' ||
typeof keyOrPayload === 'number' ||
(Array.isArray(keyOrPayload) &&
(keyOrPayload as any[]).every((key) => ['string', 'number'].includes(typeof key)))
(Array.isArray(keyOrPayload) && (keyOrPayload as any[]).every((key) => ['string', 'number'].includes(typeof key)))
) {
const key = keyOrPayload as PrimaryKey | PrimaryKey[];
const payload = payloadOrQuery as Payload;

View File

@@ -18,7 +18,7 @@ import {
AuthOptions,
RevisionsHandler,
} from './handlers';
import { MemoryStore } from './utils';
import { MemoryStore, BrowserStore } from './utils';
class DirectusSDK {
axios: AxiosInstance;
@@ -27,13 +27,16 @@ class DirectusSDK {
constructor(url: string, options?: { auth: Partial<AuthOptions> }) {
this.axios = axios.create({
baseURL: url,
withCredentials: true,
});
this.authOptions = {
storage: options?.auth?.storage !== undefined ? options.auth.storage : new MemoryStore(),
mode: options?.auth?.mode !== undefined ? options.auth.mode : 'cookie',
autoRefresh: options?.auth?.autoRefresh !== undefined ? options.auth.autoRefresh : false,
storage: options?.auth?.storage ?? (typeof window === 'undefined' ? new MemoryStore() : new BrowserStore()),
mode: options?.auth?.mode ?? 'cookie',
autoRefresh: options?.auth?.autoRefresh ?? false,
};
this.auth = new AuthHandler(this.axios, this.authOptions);
}
// Global helpers
@@ -49,8 +52,9 @@ class DirectusSDK {
// Handlers
////////////////////////////////////////////////////////////////////////////////////////////////
auth: AuthHandler;
items(collection: string) {
items(collection: string): ItemsHandler {
if (collection.startsWith('directus_')) {
throw new Error(`You can't read the "${collection}" collection directly.`);
}
@@ -58,63 +62,59 @@ class DirectusSDK {
return new ItemsHandler(collection, this.axios);
}
get activity() {
get activity(): ActivityHandler {
return new ActivityHandler(this.axios);
}
get auth() {
return new AuthHandler(this.axios, this.authOptions);
}
get collections() {
get collections(): CollectionsHandler {
return new CollectionsHandler(this.axios);
}
get fields() {
get fields(): FieldsHandler {
return new FieldsHandler(this.axios);
}
get files() {
get files(): FilesHandler {
return new FilesHandler(this.axios);
}
get folders() {
get folders(): FoldersHandler {
return new FoldersHandler(this.axios);
}
get permissions() {
get permissions(): PermissionsHandler {
return new PermissionsHandler(this.axios);
}
get presets() {
get presets(): PresetsHandler {
return new PresetsHandler(this.axios);
}
get relations() {
get relations(): RelationsHandler {
return new RelationsHandler(this.axios);
}
get revisions() {
get revisions(): RevisionsHandler {
return new RevisionsHandler(this.axios);
}
get roles() {
get roles(): RolesHandler {
return new RolesHandler(this.axios);
}
get server() {
get server(): ServerHandler {
return new ServerHandler(this.axios);
}
get settings() {
get settings(): SettingsHandler {
return new SettingsHandler(this.axios);
}
get users() {
get users(): UsersHandler {
return new UsersHandler(this.axios);
}
get utils() {
get utils(): UtilsHandler {
return new UtilsHandler(this.axios);
}
}

View File

@@ -0,0 +1,11 @@
import { AuthStorage } from '../types';
export class BrowserStore implements AuthStorage {
async getItem(key: string): Promise<string | null> {
return window.localStorage.getItem(key);
}
async setItem(key: string, value: any): Promise<void> {
window.localStorage.setItem(key, value);
}
}

View File

@@ -1 +1,2 @@
export * from './browser-store';
export * from './memory-store';

View File

@@ -30,9 +30,7 @@ describe('ActivityHandler', () => {
describe('read', () => {
it('Calls ItemsHandler#read with the provided params', async () => {
const stub = sandbox
.stub(handler['itemsHandler'], 'read')
.returns(Promise.resolve({ data: {} }));
const stub = sandbox.stub(handler['itemsHandler'], 'read').returns(Promise.resolve({ data: {} }));
await handler.read();
expect(stub).to.have.been.calledWith();
@@ -50,9 +48,7 @@ describe('ActivityHandler', () => {
describe('comments.create', () => {
it('Calls the /activity/comments endpoint', async () => {
const stub = sandbox
.stub(handler['axios'], 'post')
.returns(Promise.resolve({ data: {} }));
const stub = sandbox.stub(handler['axios'], 'post').returns(Promise.resolve({ data: {} }));
await handler.comments.create({
collection: 'articles',
@@ -70,9 +66,7 @@ describe('ActivityHandler', () => {
describe('comments.update', () => {
it('Calls the /activity/comments/:id endpoint', async () => {
const stub = sandbox
.stub(handler['axios'], 'patch')
.returns(Promise.resolve({ data: {} }));
const stub = sandbox.stub(handler['axios'], 'patch').returns(Promise.resolve({ data: {} }));
await handler.comments.update(15, { comment: 'Hello Update' });

View File

@@ -123,15 +123,12 @@ describe('AuthHandler', () => {
expect(stub).to.not.have.been.called;
});
it('Calls refresh 10 seconds before expiry time when in autoRefresh mode', async () => {
it('Calls refresh when in autoRefresh mode', async () => {
sandbox.stub(handler['axios'], 'post').resolves({ data: mockResponse });
handler['autoRefresh'] = true;
const stub = sandbox.stub(handler, 'refresh').resolves();
await handler.login({ email: 'test@example.com', password: 'test' });
clock.tick(885000); // 15 seconds before expiry time
expect(stub).to.not.have.been.called;
clock.tick(6000); // add +6s
expect(stub).to.have.been.called;
});
@@ -141,13 +138,33 @@ describe('AuthHandler', () => {
const stub = sandbox.stub(handler, 'refresh').resolves();
await handler.login({ email: 'test@example.com', password: 'test' });
clock.tick(910000);
expect(stub).to.not.have.been.called;
});
});
describe('refresh', () => {
it('Calls refresh if in auto refresh mode', async () => {
const stub = sandbox.stub(AuthHandler.prototype, 'refresh');
new AuthHandler(axiosInstance, {
autoRefresh: true,
storage: new MemoryStore(),
mode: 'json',
});
expect(stub).to.have.been.called;
});
it('Does not call refresh if not in auto refresh mode', async () => {
const stub = sandbox.stub(AuthHandler.prototype, 'refresh');
new AuthHandler(axiosInstance, {
autoRefresh: false,
storage: new MemoryStore(),
mode: 'json',
});
expect(stub).to.not.have.been.called;
});
it('Calls the /auth/refresh endpoint without refresh token when in cookie mode', async () => {
sandbox.stub(handler['storage'], 'getItem').resolves(Date.now() + 9000);
const stub = sandbox.stub(handler['axios'], 'post').resolves({ data: mockResponse });
handler['mode'] = 'cookie';
await handler.refresh();
@@ -159,6 +176,7 @@ describe('AuthHandler', () => {
handler['mode'] = 'json';
const testStore = new MemoryStore();
testStore['values'].directus_refresh_token = 'test-token';
testStore['values'].directus_access_token_expires = Date.now() + 9000;
handler['storage'] = testStore;
await handler.refresh();
expect(stub).to.have.been.calledWith('/auth/refresh', {
@@ -168,6 +186,7 @@ describe('AuthHandler', () => {
});
it('Sets the token on refresh', async () => {
sandbox.stub(handler['storage'], 'getItem').resolves(Date.now() + 9000);
sandbox.stub(handler['axios'], 'post').resolves({ data: mockResponse });
handler.token = 'before';
await handler.refresh();
@@ -178,6 +197,7 @@ describe('AuthHandler', () => {
sandbox.stub(handler['axios'], 'post').resolves({ data: mockResponse });
const testStore = new MemoryStore();
const stub = sandbox.stub(testStore, 'setItem');
sandbox.stub(testStore, 'getItem').resolves(Date.now() + 9000);
handler['storage'] = testStore;
@@ -187,6 +207,13 @@ describe('AuthHandler', () => {
});
it('Calls itself 10 seconds before expiry when autoRefresh is enabled', async () => {
const store = new MemoryStore();
const getItem = sandbox.stub(store, 'getItem').resolves(Date.now() + 9000);
handler = new AuthHandler(axiosInstance, {
mode: 'json',
autoRefresh: true,
storage: store,
});
sandbox.stub(handler['axios'], 'post').resolves({ data: mockResponse });
handler['autoRefresh'] = true;
const spy = sandbox.spy(handler, 'refresh');
@@ -195,6 +222,45 @@ describe('AuthHandler', () => {
expect(spy).to.have.been.calledTwice;
});
it('Does not refresh if there is no access token', async () => {
sandbox.stub(handler['storage'], 'getItem').resolves();
const post = sandbox.stub(handler['axios'], 'post').resolves();
await handler.refresh();
expect(post).to.not.have.been.called;
});
it('Does not refresh if there is more then 10 seconds in access token', async () => {
handler['autoRefresh'] = true;
sandbox.stub(handler['storage'], 'getItem').resolves(Date.now() + 11000);
const post = sandbox.stub(handler['axios'], 'post').resolves();
await handler.refresh();
expect(post).to.not.have.been.called;
});
it('Calls refresh if there is less then 10 seconds in access token', async () => {
sandbox.stub(handler['storage'], 'getItem').resolves(Date.now() + 9000);
const post = sandbox.stub(handler['axios'], 'post').resolves({ data: mockResponse });
await handler.refresh();
expect(post).to.have.been.called;
});
it('Sets timeout if there is more then 10 seconds left in access token', async () => {
handler['autoRefresh'] = true;
expect(handler['autoRefreshTimeout']).to.be.null;
sandbox.stub(handler['storage'], 'getItem').resolves(Date.now() + 11000);
await handler.refresh();
expect(handler['autoRefreshTimeout']).to.not.be.null;
});
it('Calls refresh again after timeout is passed', async () => {
handler['autoRefresh'] = true;
sandbox.stub(handler['storage'], 'getItem').resolves(Date.now() + 11000);
await handler.refresh();
const refresh = sandbox.stub(handler, 'refresh').resolves();
clock.tick(2000);
expect(refresh).to.have.been.calledOnce;
});
});
describe('logout', () => {
@@ -232,4 +298,33 @@ describe('AuthHandler', () => {
});
});
});
describe('initializeAccessToken', async () => {
it('Initializes only once', async () => {
const stub = sandbox.stub(handler['storage'], 'getItem').resolves();
expect(handler['accessTokenInitId']).to.not.be.null;
expect(stub).to.not.have.been.called;
await handler['initializeAccessToken']({ headers: {} });
expect(handler['accessTokenInitId']).to.be.null;
expect(stub).to.have.been.calledOnce;
await handler['initializeAccessToken']({ headers: {} });
expect(handler['accessTokenInitId']).to.be.null;
expect(stub).to.have.been.calledOnce;
});
it('Sets access token from storage', async () => {
const stub = sandbox.stub(handler['storage'], 'getItem').resolves('token');
const tokenSpy = sinon.spy(handler, 'token', ['get', 'set']);
await handler['initializeAccessToken']({ headers: {} });
expect(stub).to.have.been.calledWith('directus_access_token');
expect(tokenSpy.set).to.be.calledWith('token');
});
it('Changes Authorization header in provided config', async () => {
const config = { headers: {} };
const stub = sandbox.stub(handler['storage'], 'getItem').resolves('token');
await handler['initializeAccessToken'](config);
expect(config.headers['Authorization']).to.equal('Bearer token');
});
});
});

View File

@@ -20,6 +20,7 @@ import {
import { expect } from 'chai';
import { MemoryStore } from '../src/utils';
import { BrowserStore } from '../src/utils/browser-store';
describe('DirectusSDK', () => {
let directus: DirectusSDK;
@@ -31,7 +32,14 @@ describe('DirectusSDK', () => {
});
it('Sets the passed authOptions', () => {
const fakeStore = { async getItem() {}, async setItem() {} };
const fakeStore = {
async getItem() {
return;
},
async setItem() {
return;
},
};
const directusWithOptions = new DirectusSDK('http://example.com', {
auth: {
autoRefresh: false,
@@ -51,6 +59,20 @@ describe('DirectusSDK', () => {
expect(directus['authOptions'].storage).to.be.instanceOf(MemoryStore);
});
it('Defaults to the BrowserStore in browser, and MemoryStore in Node', () => {
const defaultWindow = globalThis.window;
globalThis.window = undefined;
let customDirectus = new DirectusSDK('http://example.com');
expect(customDirectus['authOptions'].storage).to.be.instanceOf(MemoryStore);
globalThis.window = {} as any;
customDirectus = new DirectusSDK('http://example.com');
expect(customDirectus['authOptions'].storage).to.be.instanceOf(BrowserStore);
globalThis.window = defaultWindow;
});
it('Gets / Sets URL', () => {
expect(directus.url).to.equal('http://example.com');

View File

@@ -1,5 +1,6 @@
import { MemoryStore } from '../src/utils/';
import { MemoryStore, BrowserStore } from '../src/utils/';
import { expect } from 'chai';
import sinon from 'sinon';
describe('Utils', () => {
describe('MemoryStore', () => {
@@ -16,4 +17,24 @@ describe('Utils', () => {
expect(store['values'].test).to.equal('test');
});
});
describe('BrowserStore', () => {
beforeEach(() => {
globalThis.window = {
localStorage: { getItem: sinon.spy(), setItem: sinon.spy() },
} as any;
});
it('Gets values based on key', async () => {
const store = new BrowserStore();
await store.getItem('test');
expect(globalThis.window.localStorage.getItem).to.be.calledWith('test');
});
it('Sets value based on key', async () => {
const store = new BrowserStore();
await store.setItem('key', 'value');
expect(globalThis.window.localStorage.setItem).to.be.calledWith('key', 'value');
});
});
});

View File

@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["es5"],
"module": "es2015",
"lib": ["es5", "DOM"],
"module": "CommonJS",
"moduleResolution": "node",
"sourceMap": true,
"declaration": true,