From 87c564f7cf7d2ece66dfe8dc58d0dd8a1c1f7940 Mon Sep 17 00:00:00 2001 From: Jake Loew Date: Tue, 14 May 2024 16:00:02 -0600 Subject: [PATCH 1/3] Nuxt support for Vue SDK --- package.json | 2 - .../src/CookieHelpers/CookieHelpers.test.ts | 32 +++++++++++++-- .../core/src/CookieHelpers/CookieHelpers.ts | 41 ++++++++++++++++--- .../core/src/RedirectHelper/RedirectHelper.ts | 20 +++++++-- packages/core/src/SDKConfig/SDKConfig.ts | 7 ++++ packages/core/src/SDKCore/SDKCore.ts | 22 ++++------ packages/core/src/index.ts | 1 + .../createFusionAuth/NuxtUseCookieAdapter.ts | 19 +++++++++ .../src/createFusionAuth/createFusionAuth.ts | 14 ++++++- packages/sdk-vue/src/types.ts | 7 ++++ 10 files changed, 136 insertions(+), 29 deletions(-) create mode 100644 packages/sdk-vue/src/createFusionAuth/NuxtUseCookieAdapter.ts 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..7596fde 100644 --- a/packages/core/src/RedirectHelper/RedirectHelper.ts +++ b/packages/core/src/RedirectHelper/RedirectHelper.ts @@ -1,24 +1,36 @@ /** 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 { + setItem(_key: string, _value: string) {}, + getItem(_key: string) {}, + removeItem(_key: string) {}, + } 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/src/createFusionAuth/NuxtUseCookieAdapter.ts b/packages/sdk-vue/src/createFusionAuth/NuxtUseCookieAdapter.ts new file mode 100644 index 0000000..2efc0b6 --- /dev/null +++ b/packages/sdk-vue/src/createFusionAuth/NuxtUseCookieAdapter.ts @@ -0,0 +1,19 @@ +import { CookieAdapter } from '@fusionauth-sdk/core'; + +/** + * See docs for more info [useCookie](https://nuxt.com/docs/api/composables/use-cookie). + */ +export class NuxtUseCookieAdapter implements CookieAdapter { + constructor(private useCookie: UseCookie) { + this.useCookie = useCookie; + } + + at_exp(cookieName: string = 'app.at_exp') { + // useCookie must be invoked with the cookie name every time to get the up-to-date value + return this.useCookie(cookieName).value; + } +} + +export type UseCookie = (key: string) => { + value: string | number | undefined; +}; diff --git a/packages/sdk-vue/src/createFusionAuth/createFusionAuth.ts b/packages/sdk-vue/src/createFusionAuth/createFusionAuth.ts index 63c7b55..8adff80 100644 --- a/packages/sdk-vue/src/createFusionAuth/createFusionAuth.ts +++ b/packages/sdk-vue/src/createFusionAuth/createFusionAuth.ts @@ -2,9 +2,17 @@ import { ref } from 'vue'; import { SDKCore } from '@fusionauth-sdk/core'; import { FusionAuth, FusionAuthConfig, UserInfo } from '#/types'; +import { NuxtUseCookieAdapter } from './NuxtUseCookieAdapter'; + export const createFusionAuth = (config: FusionAuthConfig): FusionAuth => { + let cookieAdapter; + if (config.nuxtUseCookie) { + cookieAdapter = new NuxtUseCookieAdapter(config.nuxtUseCookie); + } + const core = new SDKCore({ ...config, + cookieAdapter, onTokenExpiration: () => { isLoggedIn.value = false; }, @@ -33,6 +41,10 @@ export const createFusionAuth = (config: FusionAuthConfig): FusionAuth => { return await core.refreshToken(); } + function initAutoRefresh() { + return core.initAutoRefresh(); + } + function login(state?: string) { core.startLogin(state); } @@ -65,6 +77,6 @@ export const createFusionAuth = (config: FusionAuthConfig): FusionAuth => { register, logout, refreshToken, - initAutoRefresh: core.initAutoRefresh, + initAutoRefresh, }; }; diff --git a/packages/sdk-vue/src/types.ts b/packages/sdk-vue/src/types.ts index c536ffc..947d04c 100644 --- a/packages/sdk-vue/src/types.ts +++ b/packages/sdk-vue/src/types.ts @@ -1,4 +1,5 @@ import { Ref } from 'vue'; +import { UseCookie } from './createFusionAuth/NuxtUseCookieAdapter'; /** * Config for the FusionAuth Vue SDK @@ -47,6 +48,12 @@ export interface FusionAuthConfig { */ onAutoRefreshFailure?: (error: Error) => void; + /** + * Pass in `useCookie` from nuxt/app [useCookie](https://nuxt.com/docs/api/composables/use-cookie). + * This is needed for the Vue SDK to support Nuxt/SSR. + */ + nuxtUseCookie?: UseCookie; + /** * The path to the login endpoint. */ From 2675b1b36a88798fb87169e844fc25fbeadb61f3 Mon Sep 17 00:00:00 2001 From: Jake Loew Date: Tue, 14 May 2024 16:58:34 -0600 Subject: [PATCH 2/3] Vue SDK v1.1.0 -- docs updates and version bump --- .../core/src/RedirectHelper/RedirectHelper.ts | 2 + packages/sdk-vue/CHANGES.md | 5 +++ packages/sdk-vue/README.md | 16 +++++++ packages/sdk-vue/docs/README.md | 26 +++++++++--- .../docs/interfaces/types.FusionAuth.md | 20 ++++----- .../docs/interfaces/types.FusionAuthConfig.md | 42 ++++++++++++------- .../sdk-vue/docs/interfaces/types.UserInfo.md | 22 +++++----- .../docs/modules/composables_useFusionAuth.md | 2 +- packages/sdk-vue/package.json | 2 +- 9 files changed, 95 insertions(+), 42 deletions(-) diff --git a/packages/core/src/RedirectHelper/RedirectHelper.ts b/packages/core/src/RedirectHelper/RedirectHelper.ts index 7596fde..62c341e 100644 --- a/packages/core/src/RedirectHelper/RedirectHelper.ts +++ b/packages/core/src/RedirectHelper/RedirectHelper.ts @@ -7,9 +7,11 @@ export class RedirectHelper { } 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; } } 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..4c9f527 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). 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)