diff --git a/package.json b/package.json index 866ab6c..64a1dbb 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,7 @@ "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", "yalc-pub:sdk-vue": "yarn build:sdk-vue && yalc publish packages/sdk-vue", - "yalc-push:sdk-vue": "yarn build:sdk-vue && yalc push packages/sdk-vue", "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", diff --git a/packages/core/src/CookieHelpers/CookieHelpers.test.ts b/packages/core/src/CookieHelpers/CookieHelpers.test.ts index dfae7d6..3ff5ed0 100644 --- a/packages/core/src/CookieHelpers/CookieHelpers.test.ts +++ b/packages/core/src/CookieHelpers/CookieHelpers.test.ts @@ -1,18 +1,44 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { getAccessTokenExpirationMoment } from '.'; +import { CookieAdapter, getAccessTokenExpirationMoment } from '.'; describe('getAccessTokenExpirationMoment', () => { afterEach(() => { document.cookie = 'app.at_exp' + '=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; }); + it('Should get the "app.at_exp" cookie value in milliseconds', () => { const exp = Date.now(); document.cookie = `app.at_exp=${exp}`; expect(getAccessTokenExpirationMoment()).toBe(exp * 1000); }); - it('Should return null if the cookie is not set', () => { - expect(getAccessTokenExpirationMoment()).toBeNull(); + + it('Should return -1 if the cookie is not set', () => { + expect(getAccessTokenExpirationMoment()).toBe(-1); + }); + + it('Accepts a specific cookieName if one is provided', () => { + const cookieName = 'my-special-cookie'; + const exp = 1200; + document.cookie = `${cookieName}=${exp}`; + + expect(getAccessTokenExpirationMoment(cookieName)).toBe(1200000); + + document.cookie = + cookieName + '=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + }); + + it('Will get the value from a cookieAdapter if one is passed in', () => { + const value = '500'; + const cookieAdapter: CookieAdapter = { + at_exp() { + return value; + }, + }; + + expect(getAccessTokenExpirationMoment(undefined, cookieAdapter)).toBe( + 500000, + ); }); }); diff --git a/packages/core/src/CookieHelpers/CookieHelpers.ts b/packages/core/src/CookieHelpers/CookieHelpers.ts index 2978c50..ead398b 100644 --- a/packages/core/src/CookieHelpers/CookieHelpers.ts +++ b/packages/core/src/CookieHelpers/CookieHelpers.ts @@ -1,14 +1,45 @@ /** - * Parses document.cookie for the access token expiration cookie value. - * @returns {(number | null)} The moment of expiration in milliseconds since epoch. + * Gets the `app.at_exp` cookie and converts it to milliseconds since epoch. + * Returns -1 if the cookie is not present. + * @param cookieName - defaults to `app.at_exp`. + * @param adapter - SSR frameworks like Nuxt and Next will pass in an adapter. */ export function getAccessTokenExpirationMoment( cookieName: string = 'app.at_exp', -): number | null { - const expCookie = document.cookie + adapter?: CookieAdapter, +): number | -1 { + if (adapter) { + return toMilliseconds(adapter.at_exp(cookieName)); + } + + let cookie; + + try { + // `document` throws a ReferenceError if this runs in a + // non-browser environment such as an SSR framework like Nuxt or Next. + cookie = document.cookie; + } catch { + console.error( + 'Error accessing cookies in fusionauth. If you are using SSR you must configure the SDK with a cookie adapter', + ); + return -1; + } + + const expCookie = cookie .split('; ') .map(c => c.split('=')) .find(([name]) => name === cookieName); const cookieValue = expCookie?.[1]; - return cookieValue ? parseInt(cookieValue) * 1000 : null; + + return toMilliseconds(cookieValue); +} + +export interface CookieAdapter { + /** returns the `app.at_exp` cookie without manipulating the value. */ + at_exp: (cookieName?: string) => number | string | undefined; +} + +function toMilliseconds(seconds?: number | string): number { + if (!seconds) return -1; + else return Number(seconds) * 1000; } diff --git a/packages/core/src/RedirectHelper/RedirectHelper.ts b/packages/core/src/RedirectHelper/RedirectHelper.ts index 3b8a3b7..62c341e 100644 --- a/packages/core/src/RedirectHelper/RedirectHelper.ts +++ b/packages/core/src/RedirectHelper/RedirectHelper.ts @@ -1,24 +1,38 @@ /** A class responsible for storing a redirect value in localStorage and cleanup afterward. */ export class RedirectHelper { private readonly REDIRECT_VALUE = 'fa-sdk-redirect-value'; + private get storage(): Storage { + try { + return localStorage; + } catch { + // fallback for non-browser environments where localStorage is not defined. + return { + /* eslint-disable */ + setItem(_key: string, _value: string) {}, + getItem(_key: string) {}, + removeItem(_key: string) {}, + /* eslint-enable */ + } as Storage; + } + } handlePreRedirect(state?: string) { const valueForStorage = `${this.generateRandomString()}:${state ?? ''}`; - localStorage.setItem(this.REDIRECT_VALUE, valueForStorage); + this.storage.setItem(this.REDIRECT_VALUE, valueForStorage); } handlePostRedirect(callback?: (state?: string) => void) { const stateValue = this.stateValue ?? undefined; callback?.(stateValue); - localStorage.removeItem(this.REDIRECT_VALUE); + this.storage.removeItem(this.REDIRECT_VALUE); } get didRedirect() { - return Boolean(localStorage.getItem(this.REDIRECT_VALUE)); + return Boolean(this.storage.getItem(this.REDIRECT_VALUE)); } private get stateValue() { - const redirectValue = localStorage.getItem(this.REDIRECT_VALUE); + const redirectValue = this.storage.getItem(this.REDIRECT_VALUE); if (!redirectValue) { return null; diff --git a/packages/core/src/SDKConfig/SDKConfig.ts b/packages/core/src/SDKConfig/SDKConfig.ts index 1cb747e..2d2db95 100644 --- a/packages/core/src/SDKConfig/SDKConfig.ts +++ b/packages/core/src/SDKConfig/SDKConfig.ts @@ -1,3 +1,5 @@ +import { CookieAdapter } from '..'; + /** * Config for FusionAuth Web SDKs */ @@ -83,6 +85,11 @@ export interface SDKConfig { */ onAutoRefreshFailure?: (error: Error) => void; + /** + * Adapter pattern for SSR frameworks such as next or nuxt + */ + cookieAdapter?: CookieAdapter; + /** * Callback to be invoked at the moment of access token expiration */ diff --git a/packages/core/src/SDKCore/SDKCore.ts b/packages/core/src/SDKCore/SDKCore.ts index 6a72270..715ee9b 100644 --- a/packages/core/src/SDKCore/SDKCore.ts +++ b/packages/core/src/SDKCore/SDKCore.ts @@ -80,18 +80,17 @@ export class SDKCore { } initAutoRefresh(): NodeJS.Timeout | undefined { - const tokenExpirationMoment = this.at_exp; - const secondsBeforeRefresh = - this.config.autoRefreshSecondsBeforeExpiry ?? 10; - - if (!tokenExpirationMoment) { + if (!this.isLoggedIn) { return; } + const secondsBeforeRefresh = + this.config.autoRefreshSecondsBeforeExpiry ?? 10; + const millisecondsBeforeRefresh = secondsBeforeRefresh * 1000; const now = new Date().getTime(); - const refreshTime = tokenExpirationMoment - millisecondsBeforeRefresh; + const refreshTime = this.at_exp - millisecondsBeforeRefresh; const timeTillRefresh = Math.max(refreshTime - now, 0); return setTimeout(async () => { @@ -111,17 +110,14 @@ export class SDKCore { } get isLoggedIn() { - if (!this.at_exp) { - return false; - } - return this.at_exp > new Date().getTime(); } /** The moment of access token expiration in milliseconds since epoch. */ - private get at_exp(): number | null { + private get at_exp(): number | -1 { return getAccessTokenExpirationMoment( this.config.accessTokenExpireCookieName, + this.config.cookieAdapter, ); } @@ -129,10 +125,8 @@ export class SDKCore { private scheduleTokenExpiration(): void { clearTimeout(this.tokenExpirationTimeout); - const expirationMoment = this.at_exp ?? -1; - const now = new Date().getTime(); - const millisecondsTillExpiration = expirationMoment - now; + const millisecondsTillExpiration = this.at_exp - now; if (millisecondsTillExpiration > 0) { this.tokenExpirationTimeout = setTimeout( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9732d30..5f1379b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,3 +2,4 @@ export * from './SDKCore'; export * from './SDKConfig'; export * from './SDKContext'; export * from './testUtils'; +export { type CookieAdapter } from './CookieHelpers'; diff --git a/packages/sdk-vue/CHANGES.md b/packages/sdk-vue/CHANGES.md index 2c985e3..a1d73da 100644 --- a/packages/sdk-vue/CHANGES.md +++ b/packages/sdk-vue/CHANGES.md @@ -1,5 +1,10 @@ fusionauth-vue-sdk Changes +Changes in 1.1.0 + +- The SDK now supports [Nuxt](https://nuxt.com/). +- Adds `nuxtUseCookie` option to `FusionAuthConfig` to handle SSR. + Changes in 1.0.1 - Adds `onAutoRefreshFailure` option to `FusionAuthConfig`. diff --git a/packages/sdk-vue/README.md b/packages/sdk-vue/README.md index 979bb2c..58c7d6c 100644 --- a/packages/sdk-vue/README.md +++ b/packages/sdk-vue/README.md @@ -7,6 +7,7 @@ An SDK for using FusionAuth in Vue applications. - [Installation](#installation) - [Usage](#usage) - [Configuring the SDK](#configuring-the-sdk) + - [Configuring with Nuxt](#configuring-with-nuxt) - [useFusionAuth Composable](#usefusionauth-composable) - [State parameter](#state-parameter) - [UI Components](#ui-components) @@ -105,6 +106,21 @@ If you want to use the pre-styled buttons, don't forget to import the css file: import '@fusionauth/vue-sdk/dist/style.css'; ``` +#### Configuring with [Nuxt](https://nuxt.com/) + +If you're using the SDK in a nuxt app, pass the [`useCookie`](https://nuxt.com/docs/api/composables/use-cookie) composable into the config object in your plugin definition. + +```typescript +import { useCookie } from "#app"; + +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.vueApp.use(FusionAuthVuePlugin, { + ...config + nuxtUseCookie: useCookie, + }); +}); +``` + ### `useFusionAuth` composable You can interact with the SDK by using the `useFusionAuth`, which leverages [Vue's Composition API](https://vuejs.org/guide/reusability/composables). @@ -216,11 +232,7 @@ Use backticks for code in this readme. This readme is included on the FusionAuth ## Known issues -### Nuxt - -This issue affects versions `<=1.0.0`. - -If you are using [Nuxt](https://nuxt.com/) or any type of SSR (server side rendering), the SDK will not work. [See details here.](https://github.com/FusionAuth/fusionauth-javascript-sdk/issues/74) +None. ## Releases diff --git a/packages/sdk-vue/docs/README.md b/packages/sdk-vue/docs/README.md index e90e90e..18c7caa 100644 --- a/packages/sdk-vue/docs/README.md +++ b/packages/sdk-vue/docs/README.md @@ -9,16 +9,17 @@ An SDK for using FusionAuth in Vue applications. - [Installation](#installation) - [Usage](#usage) - [Configuring the SDK](#configuring-the-sdk) + - [Configuring with Nuxt](#configuring-with-nuxt) - [useFusionAuth Composable](#usefusionauth-composable) - [State parameter](#state-parameter) - [UI Components](#ui-components) - [Protecting Content](#protecting-content) - [Pre-built buttons](#pre-built-buttons) - - [Quickstart](#quickstart) - - [Documentation](#documentation) - - [Known Issues](#known-issues) - - [Releases](#releases) - - [Upgrade Policy](#upgrade-policy) +- [Quickstart](#quickstart) +- [Documentation](#documentation) +- [Known Issues](#known-issues) +- [Releases](#releases) +- [Upgrade Policy](#upgrade-policy)