Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React v2.1.1 #88

Merged
merged 4 commits into from
May 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
"build:sdk-angular": "yarn build:lexicon && yarn build:core && yarn workspace sdk-angular-workspace build",
"build:sdk-react": "yarn build:lexicon && yarn build:core && yarn workspace @fusionauth/react-sdk build",
"build:sdk-vue": "yarn build:lexicon && yarn build:core && yarn workspace @fusionauth/vue-sdk build",
"yalc-pub:sdk-react": "yarn build:sdk-react && yalc publish packages/sdk-react",
"yalc-push:sdk-react": "yarn build:sdk-react && yalc push packages/sdk-react",
"test": "yarn test:lexicon && yarn test:core && yarn test:sdk-react && yarn test:sdk-angular && yarn test:sdk-vue",
"test:core": "yarn workspace @fusionauth-sdk/core test",
"test:lexicon": "yarn workspace @fusionauth-sdk/lexicon test",
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/CookieHelpers/CookieHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest';

import { CookieHelpers } from '.';
import { getAccessTokenExpirationMoment } from '.';

describe('getAccessTokenExpirationMoment', () => {
afterEach(() => {
Expand All @@ -10,9 +10,9 @@ describe('getAccessTokenExpirationMoment', () => {
it('Should get the "app.at_exp" cookie value in milliseconds', () => {
const exp = Date.now();
document.cookie = `app.at_exp=${exp}`;
expect(CookieHelpers.getAccessTokenExpirationMoment()).toBe(exp * 1000);
expect(getAccessTokenExpirationMoment()).toBe(exp * 1000);
});
it('Should return null if the cookie is not set', () => {
expect(CookieHelpers.getAccessTokenExpirationMoment()).toBeNull();
expect(getAccessTokenExpirationMoment()).toBeNull();
});
});
28 changes: 13 additions & 15 deletions packages/core/src/CookieHelpers/CookieHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
export class CookieHelpers {
/**
* Parses document.cookie for the access token expiration cookie value.
* @returns {(number | null)} The moment of expiration in milliseconds since epoch.
*/
static getAccessTokenExpirationMoment(
cookieName: string = 'app.at_exp',
): number | null {
const expCookie = document.cookie
.split('; ')
.map(c => c.split('='))
.find(([name]) => name === cookieName);
const cookieValue = expCookie?.[1];
return cookieValue ? parseInt(cookieValue) * 1000 : null;
}
/**
* Parses document.cookie for the access token expiration cookie value.
* @returns {(number | null)} The moment of expiration in milliseconds since epoch.
*/
export function getAccessTokenExpirationMoment(
cookieName: string = 'app.at_exp',
): number | null {
const expCookie = document.cookie
.split('; ')
.map(c => c.split('='))
.find(([name]) => name === cookieName);
const cookieValue = expCookie?.[1];
return cookieValue ? parseInt(cookieValue) * 1000 : null;
}
7 changes: 6 additions & 1 deletion packages/core/src/SDKConfig/SDKConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,13 @@ export interface SDKConfig {
*/
accessTokenExpireCookieName?: string;

/**
* Callback to be invoked if a request to refresh the access token fails during autorefresh.
*/
onAutoRefreshFailure?: (error: Error) => void;

/**
* Callback to be invoked at the moment of access token expiration
*/
onTokenExpiration?: () => void;
onTokenExpiration: () => void;
}
61 changes: 44 additions & 17 deletions packages/core/src/SDKCore/SDKCore.test.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,68 @@
import { afterEach, describe, it, expect, vi } from 'vitest';

import { SDKConfig } from '#/SDKConfig';

import { SDKCore } from '.';

import { mockIsLoggedIn, removeAt_expCookie } from '..';

describe('SDKCore', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
document.cookie =
'app.at_exp' + '=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
removeAt_expCookie();
});

const config: SDKConfig = {
serverUrl: 'http://my-server',
clientId: 'abc123',
redirectUri: 'http://my-client',
onTokenExpiration: vi.fn(),
};

it('Knows that the user is logged in when the at_exp is present and in the future', () => {
vi.useFakeTimers();
mockIsLoggedIn();

const expirationMoment = new Date();
expirationMoment.setHours(expirationMoment.getHours() + 1);
const oneHourInTheFutureInMilliseconds = expirationMoment.getTime() / 1000;

document.cookie = `app.at_exp=${oneHourInTheFutureInMilliseconds}`;
const config: SDKConfig = {
serverUrl: 'http://my-server',
clientId: 'abc123',
redirectUri: 'http://my-client',
};
const onTokenExpiration = vi.fn();
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(null, { status: 200 }),
);

const core = new SDKCore({ ...config, onTokenExpiration });
const core = new SDKCore(config);

expect(core.isLoggedIn).toBe(true);

vi.advanceTimersByTime(60 * 59 * 1000); // move time ahead 59 minutes
expect(core.isLoggedIn).toBe(true);
expect(onTokenExpiration).not.toHaveBeenCalled();
expect(config.onTokenExpiration).not.toHaveBeenCalled();

vi.advanceTimersByTime(60 * 1000); // move time ahead 1 minute
expect(core.isLoggedIn).toBe(false);
expect(onTokenExpiration).toHaveBeenCalledTimes(1);
expect(config.onTokenExpiration).toHaveBeenCalledTimes(1);
});

it('Initialize automatic token refresh', async () => {
vi.useFakeTimers();
mockIsLoggedIn();

vi.spyOn(window, 'fetch').mockResolvedValueOnce(
new Response(null, { status: 200 }),
);

vi.spyOn(SDKCore.prototype, 'refreshToken');

const core = new SDKCore({ ...config, shouldAutoRefresh: true });

const timeout = core.initAutoRefresh(); // set autorefresh for 30 seconds before expiration

vi.advanceTimersByTime(59 * 60 * 1000); // advance time 59 minutes
expect(core.refreshToken).not.toHaveBeenCalled();

vi.advanceTimersByTime(49 * 1000); // advance time 50 seconds
expect(core.refreshToken).not.toHaveBeenCalled();

vi.advanceTimersByTime(1000); // advance time 1 second
expect(core.refreshToken).toHaveBeenCalledTimes(1);

clearTimeout(timeout);
});
});
87 changes: 64 additions & 23 deletions packages/core/src/SDKCore/SDKCore.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { UrlHelper } from '#/UrlHelper';
import { TokenRefresher } from '#/TokenRefresher';
import { SDKConfig } from '#/SDKConfig';
import { TokenExpirationScheduler } from '#/TokenExpirationScheduler';
import { UserInfo } from '#/SDKContext';
import { RedirectHelper } from '#/RedirectHelper';
import { CookieHelpers } from '#/CookieHelpers';
import { getAccessTokenExpirationMoment } from '#/CookieHelpers';

/** A class containing framework-agnostic SDK methods */
export class SDKCore {
private config: SDKConfig;
private urlHelper: UrlHelper;
private tokenRefresher: TokenRefresher;
private redirectHelper: RedirectHelper = new RedirectHelper();
private tokenExpirationTimeout?: NodeJS.Timeout;

constructor(config: SDKConfig) {
this.config = config;
Expand All @@ -26,10 +24,7 @@ export class SDKCore {
logoutPath: config.logoutPath,
tokenRefreshPath: config.tokenRefreshPath,
});
this.tokenRefresher = new TokenRefresher(
this.urlHelper.getTokenRefreshUrl(),
);
this.scheduleTokenExpirationEvent();
this.scheduleTokenExpiration();
}

startLogin(state?: string) {
Expand Down Expand Up @@ -61,15 +56,52 @@ export class SDKCore {
return userInfo;
}

async refreshToken() {
return await this.tokenRefresher.refreshToken();
async refreshToken(): Promise<Response> {
const response = await fetch(this.urlHelper.getTokenRefreshUrl(), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'text/plain',
},
});

if (!(response.status >= 200 && response.status < 300)) {
const message =
(await response?.text()) ||
'Error refreshing access token in fusionauth';
throw new Error(message);
}

// a successful request means that app_exp was bumped into the future.
// reschedule the access token expiration event.
this.scheduleTokenExpiration();

return response;
}

initAutoRefresh() {
return this.tokenRefresher.initAutoRefresh(
this.config.autoRefreshSecondsBeforeExpiry,
this.config.accessTokenExpireCookieName,
);
initAutoRefresh(): NodeJS.Timeout | undefined {
const tokenExpirationMoment = this.at_exp;
const secondsBeforeRefresh =
this.config.autoRefreshSecondsBeforeExpiry ?? 10;

if (!tokenExpirationMoment) {
return;
}

const millisecondsBeforeRefresh = secondsBeforeRefresh * 1000;

const now = new Date().getTime();
const refreshTime = tokenExpirationMoment - millisecondsBeforeRefresh;
const timeTillRefresh = Math.max(refreshTime - now, 0);

return setTimeout(async () => {
try {
await this.refreshToken();
this.initAutoRefresh();
} catch (error) {
this.config.onAutoRefreshFailure?.(error as Error);
}
}, timeTillRefresh);
}

handlePostRedirect(callback?: (state?: string) => void) {
Expand All @@ -79,24 +111,33 @@ export class SDKCore {
}

get isLoggedIn() {
if (!this.accessTokenExpirationMoment) {
if (!this.at_exp) {
return false;
}

return this.accessTokenExpirationMoment > new Date().getTime();
return this.at_exp > new Date().getTime();
}

private get accessTokenExpirationMoment() {
return CookieHelpers.getAccessTokenExpirationMoment(
/** The moment of access token expiration in milliseconds since epoch. */
private get at_exp(): number | null {
return getAccessTokenExpirationMoment(
this.config.accessTokenExpireCookieName,
);
}

private scheduleTokenExpirationEvent() {
if (this.accessTokenExpirationMoment && this.config.onTokenExpiration) {
TokenExpirationScheduler.scheduleTokenExpirationCallback(
this.accessTokenExpirationMoment,
/** Schedules `onTokenExpiration` at moment of access token expiration. */
private scheduleTokenExpiration(): void {
clearTimeout(this.tokenExpirationTimeout);

const expirationMoment = this.at_exp ?? -1;

const now = new Date().getTime();
const millisecondsTillExpiration = expirationMoment - now;

if (millisecondsTillExpiration > 0) {
this.tokenExpirationTimeout = setTimeout(
this.config.onTokenExpiration,
millisecondsTillExpiration,
);
}
}
Expand Down

This file was deleted.

1 change: 0 additions & 1 deletion packages/core/src/TokenExpirationScheduler/index.ts

This file was deleted.

55 changes: 0 additions & 55 deletions packages/core/src/TokenRefresher/TokenRefresher.test.ts

This file was deleted.

Loading