Skip to content

Instantly share code, notes, and snippets.

@lifeart
Last active July 21, 2023 19:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lifeart/78c7ab206064de32dec8fbd99d71e493 to your computer and use it in GitHub Desktop.
Save lifeart/78c7ab206064de32dec8fbd99d71e493 to your computer and use it in GitHub Desktop.
React Oauth 2.0
// inspired by https://github.com/simplabs/ember-simple-auth/blob/master/packages/ember-simple-auth/addon/authenticators/oauth2-password-grant.js
import { LOGOUT_503_MESSAGE } from '@auth/messages';
/*
Idea is to have Authentication hub (HUB)
HUB could have multiple providers, our own provider is oauth2.0
Provider responsible for:
session authentication
session restore
session token refresh
Oauth options belongs to provider (not HUB)
*/
// we need to figure our needed options for BE
interface OAuthOptions {
fetch?: typeof fetch;
storage?: typeof window.localStorage;
sessionData?: SSOData;
endpoint?: string;
minRefreshTokenOffset?: number;
maxRefreshTokenOffset?: number;
refreshAccessTokens?: boolean;
}
// raw oauth data from BE auth implementation
// should be private
interface UserDetails {
username: string;
pool: {
clientId: string;
};
signInUserSession: UserSession;
}
export enum AuthStatus {
SignIn = 'signIn',
SignOut = 'signOut',
}
type AuthEventCallback = (msg: string) => void;
type Seconds = number;
class UserSession {
accessToken!: string | null;
constructor(data: Oauth2Provider) {
Object.defineProperty(this, 'accessToken', {
get() {
return data.sessionData?.access_token ?? null;
},
set() {
throw new Error('Cannot set accessToken');
},
enumerable: false,
configurable: true,
});
}
}
export interface SSOData {
expires_in: number;
expires_at: number;
refresh_token: string;
access_token: string;
}
// event hub methods, need to figure out
type AuthEvent = 'signIn' | 'customOAuthState' | 'signOut' | 'signIn_failure';
export interface AuthResult {
payload: {
event: AuthEvent;
data: Record<string, unknown>;
};
}
function isSSR() {
return false;
}
function isTesting() {
return false;
}
type ServerTokenResponseDTO = {
access: string;
refresh: string;
};
export class Hub {
provider!: Oauth2Provider;
destructors: Array<() => void> = [];
listen(eventName: AuthStatus, callback: AuthEventCallback) {
const record: [AuthStatus, AuthEventCallback] = [eventName, callback];
this.provider.handlers.push(record);
return () => {
this.provider.handlers = this.provider.handlers.filter((h) => h !== record);
};
// to-do figure out app wires for this logic
}
init() {
this.provider = new Oauth2Provider();
this.destructors.push(() => this.provider.teardown());
if (!isTesting()) {
this.destructors.push(tabsAuthSync(getGlobal(), this.provider));
}
}
destroy() {
this.destructors.forEach((d) => d());
}
}
// main user class, wrapper around UserDetails
export interface User {
getUsername(): string;
getSignInUserSession(): UserSession | null;
}
export const APP_WEB_AUTH_SESSION_KEY = 'APP/web-auth/session';
function tabsAuthSync(
windowImplementation: typeof window | typeof globalThis,
authInstance: Oauth2Provider
) {
if (!windowImplementation || !('addEventListener' in windowImplementation)) {
console.info('No window implementation found, skipping tabsAuthSync');
console.log(new Error('wrong compilation path').stack);
return () => {};
}
const managedKeys = new Set([APP_WEB_AUTH_SESSION_KEY]);
function storageChangeHandler({ key, newValue }: StorageEvent) {
if (key === null) {
return;
}
// skip changes to other keys
if (!managedKeys.has(key)) {
return;
}
if (newValue === null) {
authInstance.setSessionData(null);
} else {
authInstance.setSessionData(JSON.parse(newValue as string));
}
}
windowImplementation.addEventListener('storage', storageChangeHandler);
return () => {
return windowImplementation.removeEventListener('storage', storageChangeHandler);
};
}
interface AuthorizedProfile {
id: string;
connection_id: string;
connection_type: string;
email: string;
first_name: string;
idp_id: string;
last_name: string;
object: string;
raw_attributes: Record<string, unknown>;
}
export interface AuthPayload {
access_token: string;
profile: AuthorizedProfile;
}
export function jwtDecode(token: string) {
const payload = token.split('.')[1];
const content =
'Buffer' in globalThis ? Buffer.from(payload, 'base64').toString() : atob(payload);
return JSON.parse(content);
}
export type AuthenticateCredentials =
| {
code: string;
}
| {
email: string;
password: string;
};
type AuthenticateResult = {
refresh: string;
access: string;
};
function getGlobal() {
return typeof window !== 'undefined' ? window : globalThis;
}
export default class Oauth2Provider {
_fetch!: typeof globalThis['fetch'];
_storage!: typeof globalThis['localStorage'];
get fetch() {
if (!this._fetch) {
this._fetch = getGlobal().fetch.bind(getGlobal());
}
return this._fetch;
}
set fetch(value: typeof globalThis['fetch']) {
this._fetch = value;
}
get storage() {
if (!this._storage) {
this._storage = getGlobal().localStorage;
}
return this._storage;
}
set storage(value: typeof globalThis['localStorage']) {
this._storage = value;
}
sessionData: SSOData | null = null;
endpoint = '/';
outdatedRefreshTokensRequests = 0;
handlers: Array<[AuthStatus, AuthEventCallback]> = [];
emit(event: AuthStatus, msg: string) {
this.handlers.forEach(([name, callback]) => {
if (name === event) {
callback(msg);
}
});
}
configure(options: OAuthOptions) {
if (options.fetch) {
this.fetch = options.fetch;
}
if (options.storage) {
this.storage = options.storage;
}
this.endpoint = options.endpoint ?? this.endpoint;
this.sessionData = 'sessionData' in options ? options?.sessionData ?? null : this.sessionData;
this.minRefreshTokenOffset = options.minRefreshTokenOffset ?? this.minRefreshTokenOffset;
this.maxRefreshTokenOffset = options.maxRefreshTokenOffset ?? this.maxRefreshTokenOffset;
this.refreshAccessTokens = options.refreshAccessTokens ?? this.refreshAccessTokens;
}
localStorageSessionData() {
try {
const persistedData = this.storage.getItem(APP_WEB_AUTH_SESSION_KEY);
if (persistedData !== null) {
return JSON.parse(persistedData);
} else {
return null;
}
} catch (e) {
console.error({
message: 'Failed to restore session data',
error: e,
});
return null;
}
}
restoreSessionData() {
const persistedData = this.localStorageSessionData();
if (persistedData !== null) {
const meta = this._extractAccessTokenMeta({
refresh: persistedData.refresh_token,
access: persistedData.access_token,
});
this.sessionData = meta;
}
}
async signIn(username: string, password: string): Promise<UserDetails> {
await this.authenticate({ email: username, password });
// todo - extract user details from meta
const info: UserDetails = {} as UserDetails;
return info;
}
async currentSession(): Promise<UserSession> {
if (this.sessionData === null) {
this.restoreSessionData();
if (this.sessionData !== null) {
await this.restore(this.sessionData);
}
}
return new UserSession(this);
}
async signOut(msg = ''): Promise<void> {
if (this.sessionData === null) {
return;
}
this.setSessionData(null, msg);
this.storage.removeItem(APP_WEB_AUTH_SESSION_KEY);
}
/**
The endpoint on the server that login/password authentication requests are sent to.
@property serverTokenEndpoint
@type String
@public
*/
serverTokenEndpoint = 'auth/token';
/**
The endpoint on the server that token refresh requests
are sent to.
@property serverTokenEndpoint
@type String
@public
*/
serverTokenRefreshEndpoint = 'auth/token/refresh';
/**
The endpoint on the server for token request
are sent to.
@property serverTokenEndpoint
@type String
@public
*/
serverTokenCodeEndpoint = 'auth/token/code';
/**
Sets whether the authenticator automatically refreshes access tokens if the
server supports it.
@property refreshAccessTokens
@type Boolean
@default true
@public
*/
refreshAccessTokens = true;
/**
The offset time in milliseconds to refresh the access token. This must
return a random number. This randomization is needed because in case of
multiple tabs, we need to prevent the tabs from sending refresh token
request at the same exact moment.
__When overriding this property, make sure to mark the overridden property
as volatile so it will actually have a different value each time it is
accessed.__
@property tokenRefreshOffset
@type Integer
@default a random number between 5 and 10
@public
*/
get tokenRefreshOffset() {
const min = this.minRefreshTokenOffset;
const max = this.maxRefreshTokenOffset;
return (Math.floor(Math.random() * (max - min)) + min) * 1000;
}
minRefreshTokenOffset = 5;
maxRefreshTokenOffset = 10;
// logs: string[] = [];
// log(msg: string) {
// this.logs.push(msg);
// }
_refreshTokenTimeout!: ReturnType<typeof setTimeout>;
/**
Restores the session from a session data object; __will return a resolving
promise when there is a non-empty `access_token` in the session data__ and
a rejecting promise otherwise.
If the server issues
[expiring access tokens](https://tools.ietf.org/html/rfc6749#section-5.1)
and there is an expired access token in the session data along with a
refresh token, the authenticator will try to refresh the access token and
return a promise that resolves with the new access token if the refresh was
successful. If there is no refresh token or the token refresh is not
successful, a rejecting promise will be returned.
@method restore
@param {Object} data The data to restore the session from
@return {Promise} A promise that when it resolves results in the session becoming or remaining authenticated. If restoration fails, the promise will reject with the server response (in case the access token had expired and was refreshed using a refresh token); however, the authenticator reads that response already so if you need to read it again you need to clone the response object first
@public
*/
restore(data: SSOData | null = this.sessionData) {
return new Promise((resolve, reject) => {
if (data === null) {
reject(new Error('OAUTH_NO_SESSION_DATA'));
} else {
const now = Date.now();
const { expires_at, expires_in, access_token, refresh_token } = data;
// attempt to refresh access token if expired
if (expires_at && expires_at < now) {
if (this.refreshAccessTokens) {
this._refreshAccessToken(refresh_token)
.then(resolve)
.catch(() => {
reject(new Error('OAUTH_TOKEN_REFRESH_FAILED'));
});
} else {
reject(new Error('OAUTH_REFRESH_ACCESS_TOKENS_IS_DISABLED'));
}
} else {
// fail to error if no access token settled
if (!refresh_token) {
reject(new Error('OAUTH_NO_REFRESH_TOKEN_FOUND'));
} else if (!access_token) {
reject(new Error('OAUTH_NO_ACCESS_TOKEN_FOUND'));
} else {
// if everything is good (token not expired) - resolve promise
this._scheduleAccessTokenRefresh(expires_in, expires_at, refresh_token);
resolve(data);
}
}
}
});
}
/**
Authenticates the session with the specified `identification`, `password`
and optional `scope`; issues a `POST` request to the
{{#crossLink "OAuth2PasswordGrantAuthenticator/serverTokenEndpoint:property"}}{{/crossLink}}
and receives the access token in response (see
http://tools.ietf.org/html/rfc6749#section-4.3).
__If the credentials are valid (and the optionally requested scope is
granted) and thus authentication succeeds, a promise that resolves with the
server's response is returned__, otherwise a promise that rejects with the
error as returned by the server is returned.
__If the
[server supports it](https://tools.ietf.org/html/rfc6749#section-5.1), this
method also schedules refresh requests for the access token before it
expires.__
The server responses are expected to look as defined in the spec (see
http://tools.ietf.org/html/rfc6749#section-5). The response to a successful
authentication request should be:
```json
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"bearer",
"expires_in":3600, // optional
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA" // optional
}
```
The response for a failing authentication request should be:
```json
HTTP/1.1 400 Bad Request
Content-Type: application/json;charset=UTF-8
{
"error":"invalid_grant"
}
```
A full list of error codes can be found
[here](https://tools.ietf.org/html/rfc6749#section-5.2).
@method authenticate
@param {String} identification The resource owner username
@param {String} password The resource owner password
@param {String|Array} scope The scope of the access request (see [RFC 6749, section 3.3](http://tools.ietf.org/html/rfc6749#section-3.3))
@param {Object} headers Optional headers that particular backends may require (for example sending 2FA challenge responses)
@return {Promise} A promise that when it resolves results in the session becoming authenticated. If authentication fails, the promise will reject with the server response; however, the authenticator reads that response already so if you need to read it again you need to clone the response object first
@public
*/
authenticate(credentials: AuthenticateCredentials) {
return new Promise((resolve, reject) => {
const isCode = 'code' in credentials;
const data = { ...credentials };
const serverTokenEndpoint = isCode ? this.serverTokenCodeEndpoint : this.serverTokenEndpoint;
this._makeRequest(serverTokenEndpoint, data).then(
(response: AuthenticateResult) => {
try {
this._handleNewAccessToken(response);
resolve(response);
} catch (e) {
reject(
new Error('OAUTH2_TOKEN_EXTRACTION_FAILED\n' + e.toString() + e.stack.toString())
);
}
},
(response) => {
reject(response);
}
);
});
}
/**
Makes a request to the OAuth 2.0 server.
@method _makeRequest
@param {String} url The request URL
@param {Object} data The request data
@param {Object} headers Additional headers to send in request
@return {Promise} A promise that resolves with the response object
@protected
*/
async _makeRequest(
url: string,
data: Record<string, unknown> = {},
headers: Record<string, string> = {}
) {
headers['Content-Type'] = 'application/json';
const options = {
body: JSON.stringify(data),
headers,
method: 'POST',
};
const endpoint = this.endpoint.endsWith('/') ? this.endpoint : `${this.endpoint}/`;
const response = await this.fetch(`${endpoint}${url}`, options);
const isJSON = response.headers.get('content-type') === 'application/json';
if (isJSON) {
const data = await response.json();
if (response.ok) {
return data;
} else {
let error = new Error('OAUTH2_SERVER_ERROR');
Object.assign(error, {
...data,
status: response.status,
code: response.status.toString(), // for redux toolkit error serialization, it expects value as a string see https://redux-toolkit.js.org/api/createAsyncThunk#handling-thunk-errors
});
throw error;
}
} else {
const text = await response.text();
throw new Error(text);
}
}
teardown() {
this._cancelAccessTokenRefresh();
this.handlers = [];
this.sessionData = null;
}
_cancelAccessTokenRefresh() {
clearTimeout(this._refreshTokenTimeout);
}
// maximum safe amount of outdated token requests, should be 0 or 1 for session restore case
maxOutdatedTokenRequestsThreshold = 10;
// fallback delay in token refresh request if we rich maxOutdatedTokenRequestsThreshold
fallbackTokenRefreshTimeout = 60 * 1000; // one minute
_scheduleAccessTokenRefresh(expiresIn: number, expiresAt: number | null, refreshToken: string) {
this._cancelAccessTokenRefresh();
const refreshAccessTokens = this.refreshAccessTokens && !isSSR() && !isTesting();
if (!refreshAccessTokens) {
return;
}
const now = Date.now();
if (!expiresAt && expiresIn) {
expiresAt = this._absolutizeExpirationTime(expiresIn, now);
}
// @todo - figure out huge negative expiresAt values
const offset = this.tokenRefreshOffset;
if (refreshToken && expiresAt) {
const relativeDelay = (expiresAt as number) - now - offset;
if (relativeDelay < 0) {
this.outdatedRefreshTokensRequests++;
}
const delay = relativeDelay > 0 ? relativeDelay : 0;
let resolvedDelay = delay;
if (
this.outdatedRefreshTokensRequests > this.maxOutdatedTokenRequestsThreshold &&
resolvedDelay === 0
) {
//
resolvedDelay = this.fallbackTokenRefreshTimeout;
}
this._refreshTokenTimeout = setTimeout(async () => {
try {
await this._refreshAccessToken(refreshToken);
} catch (e) {
if (e.status === 503) {
this.setSessionData(null, LOGOUT_503_MESSAGE);
return;
}
// waiting one second before trying again
await new Promise((resolve) => setTimeout(resolve, 1000));
try {
// may be a case where sessionData has new refresh token (we got it from local storage sync)
if (this.sessionData?.refresh_token) {
await this._refreshAccessToken(this.sessionData?.refresh_token);
} else {
this.setSessionData(null);
}
} catch (e) {
this.setSessionData(null);
}
console.error(e);
}
}, resolvedDelay);
}
}
_extractAccessTokenMeta(data: AuthenticateResult): SSOData {
const meta = jwtDecode(data.access);
const expiresIn = Math.round((meta.exp * 1000 - Date.now()) / 1000);
const result = {
expires_in: expiresIn,
expires_at: this._absolutizeExpirationTime(expiresIn),
access_token: data.access,
refresh_token: data.refresh,
};
return Object.freeze(result);
}
_onSessionDataUpdated(data: SSOData) {
this.storage.setItem(APP_WEB_AUTH_SESSION_KEY, JSON.stringify(data));
}
normalizeSessionData(data: null | SSOData | Pick<SSOData, 'access_token' | 'refresh_token'>) {
if (data === null) {
return null;
}
return this._extractAccessTokenMeta({
access: data.access_token,
refresh: data.refresh_token,
});
}
setSessionData(
rawData: SSOData | Pick<SSOData, 'access_token' | 'refresh_token'> | null,
message: string = ''
) {
const data = this.normalizeSessionData(rawData);
if (data === null) {
this._cancelAccessTokenRefresh();
} else {
this._scheduleAccessTokenRefresh(data.expires_in, data.expires_at, data.refresh_token);
}
this.sessionData = data;
this.emit(data ? AuthStatus.SignIn : AuthStatus.SignOut, message);
}
_handleNewAccessToken(response: ServerTokenResponseDTO) {
const meta = this._extractAccessTokenMeta(response);
this.setSessionData(meta);
this._onSessionDataUpdated(meta);
}
refreshAccessToken() {
if (this.sessionData) {
this._cancelAccessTokenRefresh();
return this._refreshAccessToken(this.sessionData.refresh_token);
}
}
_refreshAccessToken(refreshToken: string) {
const data = { refresh: refreshToken };
return new Promise((resolve, reject) => {
this._makeRequest(this.serverTokenRefreshEndpoint, data).then(
(response: { access: string; refresh?: string }) => {
try {
this._handleNewAccessToken({
access: response.access,
refresh: response.refresh || refreshToken,
});
resolve(data);
} catch (e) {
const error = new Error('OAUTH2_TOKEN_EXTRACTION_FAILED');
Object.assign(error, e);
reject(error);
}
},
(e) => {
// @todo - add test for it
const error = new Error('OAUTH2_TOKEN_EXTRACTION_FAILED');
Object.assign(error, e);
reject(error);
}
);
});
}
_absolutizeExpirationTime(expiresIn: Seconds, now = Date.now()) {
return new Date(now + expiresIn * 1000).getTime();
}
}
import { fetch as fetchPolyfill } from 'whatwg-fetch';
import Oauth2Provider from './oauth2';
const createJWT = (data: Record<string, unknown>) => {
if (data.exp) {
data.exp = Date.now() / 1000 + (data.exp as number);
}
return `.${Buffer.from(JSON.stringify(data)).toString('base64')}.`;
};
const buildFetchMock = (
response:
| Record<string, unknown>
| ((url?: string, options?: Record<string, unknown>) => Record<string, unknown>),
status: number = 200
): [[string, Record<string, unknown>][], typeof fetch] => {
const requests: [string, Record<string, unknown>][] = [];
const fetchMock = (url: string, options: Record<string, unknown>) => {
requests.push([url, options]);
return new Promise((resolve, reject) => {
if (status === 200) {
resolve({
ok: true,
headers: {
get() {
return 'application/json';
},
},
status,
json: () =>
new Promise((resolve) =>
resolve(typeof response === 'function' ? response(url, options) : response)
),
});
} else {
reject({
ok: false,
headers: {
get() {
return 'application/json';
},
},
status,
text: () => Promise.resolve(status),
json: () =>
new Promise((resolve) =>
resolve(typeof response === 'function' ? response(url, options) : response)
),
});
}
});
};
return [requests, fetchMock as typeof fetch];
};
describe('fetchMock', () => {
it('can handle multiple calls', async () => {
let i = 0;
const [requests, fetchMock] = buildFetchMock(() => {
return {
access: i++,
};
});
expect(requests.length).toBe(0);
const r1 = await fetchMock('https://example.com/1');
expect(requests.length).toBe(1);
expect(await r1.json()).toEqual({ access: 0 });
const r2 = await fetchMock('https://example.com/2');
expect(requests.length).toBe(2);
expect(await r2.json()).toEqual({ access: 1 });
});
});
describe('Auth providers', () => {
describe('Oauth2Provider', () => {
describe('instance has correct defaults', () => {
it('should not emit errors on creation', () => {
expect(new Oauth2Provider() instanceof Oauth2Provider).toBe(true);
});
it('should have default fetch implementation', () => {
window.fetch = fetchPolyfill;
const provider = new Oauth2Provider();
expect(provider.fetch).toBeDefined();
delete (window as unknown as { fetch?: unknown }).fetch;
});
it('should have default storage implementation', () => {
const provider = new Oauth2Provider();
expect(provider.storage).toBeDefined();
});
it('should not have empty session data by default', () => {
const provider = new Oauth2Provider();
expect(provider.sessionData).toBeNull();
});
});
describe('configure() works as expected', () => {
it('fetch should be configurable', () => {
window.fetch = fetchPolyfill;
const provider = new Oauth2Provider();
const oldFetch = provider.fetch;
const newFetch = jest.fn();
provider.configure({ fetch: newFetch });
expect(provider.fetch).toBe(newFetch);
expect(provider.fetch).not.toBe(oldFetch);
delete (window as unknown as { fetch?: unknown }).fetch;
});
it('fetch should be reconfigurable', () => {
const provider = new Oauth2Provider();
const oldFetch = jest.fn();
const newFetch = jest.fn();
provider.configure({ fetch: oldFetch });
expect(provider.fetch).toBe(oldFetch);
provider.configure({ fetch: newFetch });
expect(provider.fetch).toBe(newFetch);
});
it('storage should be configurable', () => {
const provider = new Oauth2Provider();
const oldStorage = provider.storage;
const newStorage = jest.fn();
provider.configure({ storage: newStorage as unknown as typeof provider.storage });
expect(provider.storage).toBe(newStorage);
expect(provider.storage).not.toBe(oldStorage);
});
it('storage should be reconfigurable', () => {
const provider = new Oauth2Provider();
const oldStorage = jest.fn();
const newStorage = jest.fn();
provider.configure({ storage: oldStorage as unknown as typeof provider.storage });
expect(provider.storage).toBe(oldStorage);
provider.configure({ storage: newStorage as unknown as typeof provider.storage });
expect(provider.storage).toBe(newStorage);
});
it('sessionData should be configurable', () => {
const provider = new Oauth2Provider();
expect(provider.sessionData).toBeNull();
const sessionData = {
access_token: 'accessToken',
refresh_token: 'refreshToken',
expires_in: 0,
expires_at: 1,
};
provider.configure({ sessionData });
expect(provider.sessionData).toBe(sessionData);
});
it('sessionData should be reconfigurable', () => {
const provider = new Oauth2Provider();
const sessionData = {
access_token: 'accessToken',
refresh_token: 'refreshToken',
expires_in: 0,
expires_at: 1,
};
provider.configure({ sessionData });
expect(provider.sessionData).toBe(sessionData);
const newSessionData = {
access_token: 'newAccessToken',
refresh_token: 'newRefreshToken',
expires_in: 0,
expires_at: 1,
};
provider.configure({ sessionData: newSessionData });
expect(provider.sessionData).toBe(newSessionData);
});
});
describe('currentSession', () => {
it('should be reactive', async () => {
const provider = new Oauth2Provider();
const userSession = await provider.currentSession();
expect(userSession.accessToken).toBeNull();
const sessionData = {
access_token: 'accessToken',
refresh_token: 'refreshToken',
expires_in: 0,
expires_at: 1,
};
provider.configure({ sessionData });
expect(userSession.accessToken).toBe('accessToken');
});
});
describe('authenticate', () => {
let provider: Oauth2Provider;
beforeEach(() => {
provider = new Oauth2Provider();
});
afterEach(() => {
provider.teardown();
});
it('works for login/password case in case of successful network request', async () => {
const response = {
access: createJWT({ exp: 200000 }),
refresh: 'refreshToken#1',
};
const [requests, fetchMock] = buildFetchMock(response);
provider.configure({ fetch: fetchMock });
expect(provider.sessionData).toBeNull();
await provider.authenticate({
email: 'email',
password: 'password',
});
expect(requests.length).toBe(1);
expect(provider.sessionData?.access_token).toEqual(response.access);
expect(provider.sessionData?.refresh_token).toEqual(response.refresh);
});
it('works for login/password case in case of failed network request', async () => {
const [requests, fetchMock] = buildFetchMock({}, 500);
provider.configure({ fetch: fetchMock });
expect(provider.sessionData).toBeNull();
try {
await provider.authenticate({
email: 'email',
password: 'password',
});
} catch (e) {
expect(requests.length).toBe(1);
expect(provider.sessionData).toBeNull();
// expected
}
});
it('works for login/password case in case of failed network', async () => {
provider.configure({
fetch: () => {
throw new Error('Network error');
},
});
expect(provider.sessionData).toBeNull();
try {
await provider.authenticate({
email: 'email',
password: 'password',
});
} catch (e) {
expect(provider.sessionData).toBeNull();
// expected
}
});
it('works for code case in case of successful network request', async () => {
const response = {
access: createJWT({ exp: 200000 }),
refresh: 'refreshToken#2',
};
const [requests, fetchMock] = buildFetchMock(response);
provider.configure({ fetch: fetchMock });
expect(provider.sessionData).toBeNull();
await provider.authenticate({ code: '42' });
expect(requests.length).toBe(1);
expect(provider.sessionData?.access_token).toEqual(response.access);
expect(provider.sessionData?.refresh_token).toEqual(response.refresh);
});
it('works for code case in case of failed network request', async () => {
const [requests, fetchMock] = buildFetchMock({}, 500);
provider.configure({ fetch: fetchMock });
expect(provider.sessionData).toBeNull();
try {
await provider.authenticate({ code: '42' });
} catch (e) {
expect(requests.length).toBe(1);
expect(provider.sessionData).toBeNull();
}
});
it('works for code case in case of failed network', async () => {
provider.configure({
fetch: () => {
throw new Error('Network error');
},
});
expect(provider.sessionData).toBeNull();
try {
await provider.authenticate({ code: '42' });
} catch (e) {
expect(provider.sessionData).toBeNull();
}
});
});
describe('restore | refresh token', () => {
jest.setTimeout(30000);
it('should try to refresh token if expired', async () => {
const provider = new Oauth2Provider();
const now = new Date().getTime();
const storageMock = {
setItem: jest.fn(),
};
const [requests, fetchMock] = buildFetchMock({
access: createJWT({ exp: 200000 }),
refresh: 'newRefreshToken',
});
const sessionData = {
access_token: 'accessToken',
refresh_token: 'refreshToken',
expires_in: 0,
expires_at: now - 5000,
};
provider.configure({
storage: storageMock as unknown as typeof provider.storage,
sessionData,
fetch: fetchMock as typeof provider.fetch,
});
expect(provider.refreshAccessTokens).toBe(true);
await provider.restore();
const [request] = requests;
const [url, options] = request;
expect(url.endsWith(provider.serverTokenRefreshEndpoint)).toBe(true);
expect(JSON.parse(options.body as string)).toEqual({
refresh: sessionData.refresh_token,
});
expect(storageMock.setItem.mock.calls.length).toBe(1);
const storageData = JSON.parse(storageMock.setItem.mock.calls[0][1]);
expect(storageData.access_token).toEqual(provider.sessionData?.access_token);
expect(storageData.refresh_token).toEqual(provider.sessionData?.refresh_token);
expect(storageData.expires_at).toBeLessThanOrEqual(
provider.sessionData?.expires_at as number
);
expect(storageData.expires_in).toBeLessThanOrEqual(
provider.sessionData?.expires_in as number
);
expect(provider.sessionData?.access_token).not.toBe(sessionData.access_token);
expect(typeof provider._refreshTokenTimeout).toBe('number');
});
it('should try to refresh token later if not expired', async () => {
const provider = new Oauth2Provider();
const now = new Date().getTime();
const storageMock = {
setItem: jest.fn(),
};
const [requests, fetchMock] = buildFetchMock({
access: createJWT({ exp: 200000 }),
refresh: 'newRefreshToken',
});
const expirationDelay = 4000;
const sessionData = {
access_token: 'accessToken',
refresh_token: 'refreshToken',
expires_in: expirationDelay,
expires_at: now + expirationDelay,
};
provider.configure({
storage: storageMock as unknown as typeof provider.storage,
sessionData,
fetch: fetchMock as typeof provider.fetch,
minRefreshTokenOffset: 1,
maxRefreshTokenOffset: 2,
});
expect(provider.refreshAccessTokens).toBe(true);
expect(provider.minRefreshTokenOffset).toBe(1);
expect(provider.maxRefreshTokenOffset).toBe(2);
await provider.restore();
expect(requests.length).toBe(0);
await new Promise((resolve) => setTimeout(resolve, expirationDelay));
const [request] = requests;
const [url, options] = request;
expect(url.endsWith(provider.serverTokenRefreshEndpoint)).toBe(true);
expect(JSON.parse(options.body as string)).toEqual({
refresh: sessionData.refresh_token,
});
expect(storageMock.setItem.mock.calls.length).toBe(1);
const storageData = JSON.parse(storageMock.setItem.mock.calls[0][1]);
expect(storageData.access_token).toEqual(provider.sessionData?.access_token);
expect(storageData.refresh_token).toEqual(provider.sessionData?.refresh_token);
expect(storageData.expires_at).toBeLessThanOrEqual(
provider.sessionData?.expires_at as number
);
expect(storageData.expires_in).toBeLessThanOrEqual(
provider.sessionData?.expires_in as number
);
expect(provider.sessionData?.access_token).not.toBe(sessionData.access_token);
expect(typeof provider._refreshTokenTimeout).toBe('number');
});
it('should handle partial response during refresh token and take refresh token from old source', async () => {
const provider = new Oauth2Provider();
const now = new Date().getTime();
const storageMock = {
setItem: jest.fn(),
};
const [requests, fetchMock] = buildFetchMock({
access: createJWT({ exp: 200000 }),
});
const expirationDelay = 4000;
const sessionData = {
access_token: 'accessToken',
refresh_token: 'refreshToken',
expires_in: expirationDelay,
expires_at: now + expirationDelay,
};
provider.configure({
storage: storageMock as unknown as typeof provider.storage,
sessionData,
fetch: fetchMock as typeof provider.fetch,
minRefreshTokenOffset: 1,
maxRefreshTokenOffset: 2,
});
expect(provider.refreshAccessTokens).toBe(true);
expect(provider.minRefreshTokenOffset).toBe(1);
expect(provider.maxRefreshTokenOffset).toBe(2);
await provider.restore();
expect(requests.length).toBe(0);
await new Promise((resolve) => setTimeout(resolve, expirationDelay));
const [request] = requests;
const [url, options] = request;
expect(url.endsWith(provider.serverTokenRefreshEndpoint)).toBe(true);
expect(JSON.parse(options.body as string)).toEqual({
refresh: sessionData.refresh_token,
});
expect(storageMock.setItem.mock.calls.length).toBe(1);
const storageData = JSON.parse(storageMock.setItem.mock.calls[0][1]);
expect(storageData.access_token).toEqual(provider.sessionData?.access_token);
expect(storageData.refresh_token).toEqual(provider.sessionData?.refresh_token);
expect(storageData.expires_at).toBeLessThanOrEqual(
provider.sessionData?.expires_at as number
);
expect(storageData.expires_in).toBeLessThanOrEqual(
provider.sessionData?.expires_in as number
);
expect(provider.sessionData?.access_token).not.toBe(sessionData.access_token);
expect(provider.sessionData?.refresh_token).toBe(sessionData.refresh_token);
expect(typeof provider._refreshTokenTimeout).toBe('number');
});
it('should not try to refresh token if not expired', async () => {
const provider = new Oauth2Provider();
const now = new Date().getTime();
const storageMock = {
setItem: jest.fn(),
};
const fetchMock = jest.fn();
const sessionData = {
access_token: 'accessToken',
refresh_token: 'refreshToken',
expires_in: 0,
expires_at: now + 15000,
};
provider.configure({
storage: storageMock as unknown as typeof provider.storage,
sessionData,
fetch: fetchMock as typeof provider.fetch,
});
expect(provider.refreshAccessTokens).toBe(true);
const oldTimeout = provider._refreshTokenTimeout;
await provider.restore();
expect(fetchMock.mock.calls.length).toBe(0);
expect(storageMock.setItem.mock.calls.length).toBe(0);
expect(provider.sessionData?.access_token).toBe(sessionData.access_token);
expect(provider._refreshTokenTimeout).not.toBe(oldTimeout);
provider.teardown();
});
it('restore | should fail if there is no session data', async () => {
const provider = new Oauth2Provider();
let error: Error | null = null;
try {
await provider.restore();
} catch (e) {
error = e;
}
expect(error?.message).toBe('OAUTH_NO_SESSION_DATA');
});
it('restore | should fail if refresh access token is disabled and token expired', async () => {
const provider = new Oauth2Provider();
const now = new Date().getTime();
const sessionData = {
access_token: 'accessToken',
refresh_token: 'refreshToken',
expires_in: 0,
expires_at: now - 5000,
};
provider.configure({ sessionData, refreshAccessTokens: false });
expect(provider.refreshAccessTokens).toBe(false);
let error: Error | null = null;
try {
await provider.restore();
} catch (e) {
error = e;
}
expect(error?.message).toBe('OAUTH_REFRESH_ACCESS_TOKENS_IS_DISABLED');
});
it('restore | should fail if session not expired, but access token is not found', async () => {
const provider = new Oauth2Provider();
const now = new Date().getTime();
const sessionData = {
access_token: '',
refresh_token: 'refreshToken',
expires_in: 0,
expires_at: now + 20000,
};
provider.configure({ sessionData });
let error: Error | null = null;
try {
await provider.restore();
} catch (e) {
error = e;
}
expect(error?.message).toBe('OAUTH_NO_ACCESS_TOKEN_FOUND');
});
it('restore | should fail if session not expired, but refresh token is not found', async () => {
const provider = new Oauth2Provider();
const now = new Date().getTime();
const sessionData = {
access_token: 'accessToken',
refresh_token: '',
expires_in: 0,
expires_at: now + 20000,
};
provider.configure({ sessionData });
let error: Error | null = null;
try {
await provider.restore();
} catch (e) {
error = e;
}
expect(error?.message).toBe('OAUTH_NO_REFRESH_TOKEN_FOUND');
});
it('restore | should fail if unable to refresh expired token', async () => {
const provider = new Oauth2Provider();
const now = new Date().getTime();
const fetchMock = () => {
throw new Error('network error');
};
const sessionData = {
access_token: 'accessToken',
refresh_token: 'refreshToken',
expires_in: 0,
expires_at: now - 5000,
};
provider.configure({ sessionData, fetch: fetchMock as typeof provider.fetch });
let error: Error | null = null;
try {
await provider.restore();
} catch (e) {
error = e;
}
expect(error?.message).toBe('OAUTH_TOKEN_REFRESH_FAILED');
});
});
describe('refresh token logic is working as expected', () => {
jest.setTimeout(20000);
it('should refresh token if it is expired', async () => {
const provider = new Oauth2Provider();
const now = new Date().getTime();
const storageMock = {
setItem: jest.fn(),
};
let i = 0;
const [requests, fetchMock] = buildFetchMock(() => {
return {
access: createJWT({ exp: 5 /* 5 seconds */ }),
refresh: `newRefreshToken_${i++}`,
};
});
const sessionData = {
access_token: 'accessToken',
refresh_token: 'oldRefreshToken',
expires_in: 0,
expires_at: now - 1000,
};
provider.configure({
sessionData,
fetch: fetchMock,
storage: storageMock as unknown as typeof provider.storage,
refreshAccessTokens: true,
minRefreshTokenOffset: 1,
maxRefreshTokenOffset: 2,
});
expect(provider.refreshAccessTokens).toBe(true);
expect(provider.minRefreshTokenOffset).toBe(1);
expect(provider.maxRefreshTokenOffset).toBe(2);
expect(requests.length).toBe(0);
await provider.restore();
// expired token should be refreshed
expect(requests.length).toBe(1);
expect(storageMock.setItem.mock.calls.length).toBe(1);
// waiting for new expiration time #1
await new Promise((resolve) => setTimeout(resolve, 5000));
// expect(provider.logs.join(',')).toBe('');
expect(requests.length).toBe(2);
expect(storageMock.setItem.mock.calls.length).toBe(2);
// waiting for new expiration time #2
await new Promise((resolve) => setTimeout(resolve, 5000));
expect(requests.length).toBe(3);
expect(storageMock.setItem.mock.calls.length).toBe(3);
provider.teardown();
});
});
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment