Skip to content

Auth cookie persistence #8839

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

Merged
merged 26 commits into from
Mar 27, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/orange-turtles-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@firebase/auth': minor
---

Adding Persistence.COOKIE for use in frameworks that utilize hybrid rendering
5 changes: 4 additions & 1 deletion common/api-review/auth.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,9 @@ export function connectAuthEmulator(auth: Auth, url: string, options?: {
disableWarnings: boolean;
}): void;

// @public
export const cookiePersistence: Persistence;

// @public
export function createUserWithEmailAndPassword(auth: Auth, email: string, password: string): Promise<UserCredential>;

Expand Down Expand Up @@ -596,7 +599,7 @@ export interface PasswordValidationStatus {

// @public
export interface Persistence {
readonly type: 'SESSION' | 'LOCAL' | 'NONE';
readonly type: 'SESSION' | 'LOCAL' | 'NONE' | 'COOKIE';
}

// @public
Expand Down
14 changes: 14 additions & 0 deletions docs-devsite/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ Firebase Authentication
| [browserLocalPersistence](./auth.md#browserlocalpersistence) | An implementation of [Persistence](./auth.persistence.md#persistence_interface) of type <code>LOCAL</code> using <code>localStorage</code> for the underlying storage. |
| [browserPopupRedirectResolver](./auth.md#browserpopupredirectresolver) | An implementation of [PopupRedirectResolver](./auth.popupredirectresolver.md#popupredirectresolver_interface) suitable for browser based applications. |
| [browserSessionPersistence](./auth.md#browsersessionpersistence) | An implementation of [Persistence](./auth.persistence.md#persistence_interface) of <code>SESSION</code> using <code>sessionStorage</code> for the underlying storage. |
| [cookiePersistence](./auth.md#cookiepersistence) | <b><i>(Public Preview)</i></b> An implementation of [Persistence](./auth.persistence.md#persistence_interface) of type 'COOKIE', for use in applications leveraging server-side rendering and middleware. |
| [cordovaPopupRedirectResolver](./auth.md#cordovapopupredirectresolver) | An implementation of [PopupRedirectResolver](./auth.popupredirectresolver.md#popupredirectresolver_interface) suitable for Cordova based applications. |
| [debugErrorMap](./auth.md#debugerrormap) | A verbose error map with detailed descriptions for most error codes.<!-- -->See discussion at [AuthErrorMap](./auth.autherrormap.md#autherrormap_interface) |
| [FactorId](./auth.md#factorid) | An enum of factors that may be used for multifactor authentication. |
Expand Down Expand Up @@ -1992,6 +1993,19 @@ An implementation of [Persistence](./auth.persistence.md#persistence_interface)
browserSessionPersistence: Persistence
```

## cookiePersistence

> This API is provided as a preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.
>

An implementation of [Persistence](./auth.persistence.md#persistence_interface) of type 'COOKIE', for use in applications leveraging server-side rendering and middleware.

<b>Signature:</b>

```typescript
cookiePersistence: Persistence
```

## cordovaPopupRedirectResolver

An implementation of [PopupRedirectResolver](./auth.popupredirectresolver.md#popupredirectresolver_interface) suitable for Cordova based applications.
Expand Down
6 changes: 3 additions & 3 deletions docs-devsite/auth.persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ export interface Persistence

| Property | Type | Description |
| --- | --- | --- |
| [type](./auth.persistence.md#persistencetype) | 'SESSION' \| 'LOCAL' \| 'NONE' | Type of Persistence. - 'SESSION' is used for temporary persistence such as <code>sessionStorage</code>. - 'LOCAL' is used for long term persistence such as <code>localStorage</code> or <code>IndexedDB</code>. - 'NONE' is used for in-memory, or no persistence. |
| [type](./auth.persistence.md#persistencetype) | 'SESSION' \| 'LOCAL' \| 'NONE' \| 'COOKIE' | Type of Persistence. - 'SESSION' is used for temporary persistence such as <code>sessionStorage</code>. - 'LOCAL' is used for long term persistence such as <code>localStorage</code> or <code>IndexedDB</code>. - 'NONE' is used for in-memory, or no persistence. - 'COOKIE' is used for Cookie persistence, useful for server-side rendering. |

## Persistence.type

Type of Persistence. - 'SESSION' is used for temporary persistence such as `sessionStorage`<!-- -->. - 'LOCAL' is used for long term persistence such as `localStorage` or `IndexedDB`<!-- -->. - 'NONE' is used for in-memory, or no persistence.
Type of Persistence. - 'SESSION' is used for temporary persistence such as `sessionStorage`<!-- -->. - 'LOCAL' is used for long term persistence such as `localStorage` or `IndexedDB`<!-- -->. - 'NONE' is used for in-memory, or no persistence. - 'COOKIE' is used for Cookie persistence, useful for server-side rendering.

<b>Signature:</b>

```typescript
readonly type: 'SESSION' | 'LOCAL' | 'NONE';
readonly type: 'SESSION' | 'LOCAL' | 'NONE' | 'COOKIE';
```
2 changes: 2 additions & 0 deletions packages/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export * from './src';

// persistence
import { browserLocalPersistence } from './src/platform_browser/persistence/local_storage';
import { cookiePersistence } from './src/platform_browser/persistence/cookie_storage';
import { browserSessionPersistence } from './src/platform_browser/persistence/session_storage';
import { indexedDBLocalPersistence } from './src/platform_browser/persistence/indexed_db';

Expand Down Expand Up @@ -83,6 +84,7 @@ import { getAuth } from './src/platform_browser';

export {
browserLocalPersistence,
cookiePersistence,
browserSessionPersistence,
indexedDBLocalPersistence,
PhoneAuthProvider,
Expand Down
1 change: 1 addition & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@
"@rollup/plugin-strip": "2.1.0",
"@types/express": "4.17.21",
"chromedriver": "119.0.1",
"cookie-store": "4.0.0-next.4",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI using this dev-dep for types, somewhere else I tried this ponyfill and found it unsuitable for prod as it can't be webpacked.

"rollup": "2.79.2",
"rollup-plugin-sourcemaps": "0.6.3",
"rollup-plugin-typescript2": "0.36.0",
Expand Down
17 changes: 14 additions & 3 deletions packages/auth/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { AuthInternal, ConfigInternal } from '../model/auth';
import { IdTokenResponse, TaggedWithTokenResponse } from '../model/id_token';
import { IdTokenMfaResponse } from './authentication/mfa';
import { SERVER_ERROR_MAP, ServerError, ServerErrorMap } from './errors';
import { PersistenceType } from '../core/persistence';

export const enum HttpMethod {
POST = 'POST',
Expand Down Expand Up @@ -265,11 +266,21 @@ export function _getFinalTarget(
): string {
const base = `${host}${path}?${query}`;

if (!(auth as AuthInternal).config.emulator) {
return `${auth.config.apiScheme}://${base}`;
const finalTarget = (auth as AuthInternal).config.emulator
? _emulatorUrl(auth.config as ConfigInternal, base)
: `${auth.config.apiScheme}://${base}`;

// TODO get the exchange URL from the persistence method
// don't use startsWith v1/accounts...
if (
(auth as AuthInternal)._getPersistence() === PersistenceType.COOKIE &&
(path.startsWith('/v1/accounts:signIn') || path === Endpoint.TOKEN)
) {
const params = new URLSearchParams({ finalTarget });
return `${window.location.origin}/__cookies__?${params.toString()}`;
}

return _emulatorUrl(auth.config as ConfigInternal, base);
return finalTarget;
}

export function _parseEnforcementState(
Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/core/auth/auth_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
}

async _getAppCheckToken(): Promise<string | undefined> {
// @ts-ignore
if (_isFirebaseServerApp(this.app) && this.app.settings.appCheckToken) {
return this.app.settings.appCheckToken;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/auth/src/core/persistence/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import { Persistence } from '../../model/public_types';
export const enum PersistenceType {
SESSION = 'SESSION',
LOCAL = 'LOCAL',
NONE = 'NONE'
NONE = 'NONE',
COOKIE = 'COOKIE'
}

export type PersistedBlob = Record<string, unknown>;
Expand Down
28 changes: 24 additions & 4 deletions packages/auth/src/core/persistence/persistence_user_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* limitations under the License.
*/

import { getAccountInfo } from '../../api/account_management/account';
import { ApiKey, AppName, AuthInternal } from '../../model/auth';
import { UserInternal } from '../../model/user';
import { PersistedBlob, PersistenceInternal } from '../persistence';
Expand Down Expand Up @@ -66,8 +67,17 @@ export class PersistenceUserManager {
}

async getCurrentUser(): Promise<UserInternal | null> {
const blob = await this.persistence._get<PersistedBlob>(this.fullUserKey);
return blob ? UserImpl._fromJSON(this.auth, blob) : null;
const blob = await this.persistence._get<PersistedBlob | string>(
this.fullUserKey
);
if (!blob) {
return null;
}
if (typeof blob === 'string') {
const response = await getAccountInfo(this.auth, { idToken: blob });
return UserImpl._fromGetAccountInfoResponse(this.auth, response, blob);
}
return UserImpl._fromJSON(this.auth, blob);
}

removeCurrentUser(): Promise<void> {
Expand Down Expand Up @@ -140,9 +150,19 @@ export class PersistenceUserManager {
// persistence, we will (but only if that persistence supports migration).
for (const persistence of persistenceHierarchy) {
try {
const blob = await persistence._get<PersistedBlob>(key);
const blob = await persistence._get<PersistedBlob | string>(key);
if (blob) {
const user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
let user: UserInternal;
if (typeof blob === 'string') {
const response = await getAccountInfo(auth, { idToken: blob });
user = await UserImpl._fromGetAccountInfoResponse(
auth,
response,
blob
);
} else {
user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
}
if (persistence !== selectedPersistence) {
userToMigrate = user;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/auth/src/model/public_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,8 +341,9 @@ export interface Persistence {
* - 'SESSION' is used for temporary persistence such as `sessionStorage`.
* - 'LOCAL' is used for long term persistence such as `localStorage` or `IndexedDB`.
* - 'NONE' is used for in-memory, or no persistence.
* - 'COOKIE' is used for Cookie persistence, useful for server-side rendering.
*/
readonly type: 'SESSION' | 'LOCAL' | 'NONE';
readonly type: 'SESSION' | 'LOCAL' | 'NONE' | 'COOKIE';
}

/**
Expand Down
143 changes: 143 additions & 0 deletions packages/auth/src/platform_browser/persistence/cookie_storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* @license
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Persistence } from '../../model/public_types';
import type { CookieChangeEvent } from 'cookie-store';

const POLLING_INTERVAL_MS = 1_000;

import {
PersistenceInternal,
PersistenceType,
PersistenceValue,
StorageEventListener
} from '../../core/persistence';

function getDocumentCookie(name: string): string | null {
const escapedName = name.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
const matcher = RegExp(`${escapedName}=([^;]+)`);
return document.cookie.match(matcher)?.[1] ?? null;
}

function getCookieName(key: string): string {
// TODO at least remove apikey from the cookie name
// good to prefix with __Host- or __Secure- too
// we may want to produce a public API to be able to get the idToken cookie name
return key;
}

export class CookiePersistence implements PersistenceInternal {
static type: 'COOKIE' = 'COOKIE';
readonly type = PersistenceType.COOKIE;
listenerUnsubscribes: Map<StorageEventListener, () => void> = new Map();

async _isAvailable(): Promise<boolean> {
// TODO isSecureContext
if (typeof navigator === 'undefined' || typeof document === 'undefined') {
return false;
}
return navigator.cookieEnabled ?? true;
}

async _set(_key: string, _value: PersistenceValue): Promise<void> {
return;
}

async _get<T extends PersistenceValue>(key: string): Promise<T | null> {
if (!this._isAvailable()) {
return null;
}
const name = getCookieName(key);
if (window.cookieStore) {
const cookie = await window.cookieStore.get(name);
return cookie?.value as T;
}
return getDocumentCookie(name) as T;
}

async _remove(key: string): Promise<void> {
if (!this._isAvailable()) {
return;
}
// TODO migrate to a third cookie for logout
const name = getCookieName(key);
if (window.cookieStore) {
const cookie = await window.cookieStore.get(name);
if (!cookie) {
return;
}
await window.cookieStore.delete(cookie);
} else {
document.cookie = `${name}=;Max-Age=34560000;Partitioned;Secure;SameSite=Strict;Path=/`;
}
await fetch(`/__cookies__`, { method: 'DELETE' }).catch(() => undefined);
}

_addListener(key: string, listener: StorageEventListener): void {
if (!this._isAvailable()) {
return;
}
const name = getCookieName(key);
if (window.cookieStore) {
const cb = ((event: CookieChangeEvent): void => {
const changedCookie = event.changed.find(
change => change.name === name
);
if (changedCookie) {
listener(changedCookie.value as PersistenceValue);
}
const deletedCookie = event.deleted.find(
change => change.name === name
);
if (deletedCookie) {
listener(null);
}
}) as EventListener;
const unsubscribe = (): void =>
window.cookieStore.removeEventListener('change', cb);
this.listenerUnsubscribes.set(listener, unsubscribe);
return window.cookieStore.addEventListener('change', cb as EventListener);
}
let lastValue = getDocumentCookie(name);
const interval = setInterval(() => {
const currentValue = getDocumentCookie(name);
if (currentValue !== lastValue) {
listener(currentValue as PersistenceValue | null);
lastValue = currentValue;
}
}, POLLING_INTERVAL_MS);
const unsubscribe = (): void => clearInterval(interval);
this.listenerUnsubscribes.set(listener, unsubscribe);
}

_removeListener(_key: string, listener: StorageEventListener): void {
const unsubscribe = this.listenerUnsubscribes.get(listener);
if (!unsubscribe) {
return;
}
unsubscribe();
this.listenerUnsubscribes.delete(listener);
}
}

/**
* An implementation of {@link Persistence} of type 'COOKIE', for use in applications leveraging
* server-side rendering and middleware.
*
* @beta
*/
export const cookiePersistence: Persistence = CookiePersistence;
1 change: 1 addition & 0 deletions packages/auth/src/platform_node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class FailClass {

export const browserLocalPersistence = inMemoryPersistence;
export const browserSessionPersistence = inMemoryPersistence;
export const cookiePersistence = inMemoryPersistence;
export const indexedDBLocalPersistence = inMemoryPersistence;
export const browserPopupRedirectResolver = NOT_AVAILABLE_ERROR;
export const PhoneAuthProvider = FailClass;
Expand Down
2 changes: 1 addition & 1 deletion packages/rules-unit-testing/api-extractor.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"extends": "../../config/api-extractor.json",
// Point it to your entry point d.ts file.
"mainEntryPointFilePath": "<projectFolder>/dist/rules-unit-testing/index.d.ts"
"mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts"
}
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5893,6 +5893,11 @@ [email protected]:
resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==

[email protected]:
version "4.0.0-next.4"
resolved "https://registry.npmjs.org/cookie-store/-/cookie-store-4.0.0-next.4.tgz#8b13981bfd93e10e30694e9816928f8c478a326b"
integrity sha512-RVcIK13cCiAa+rsxAbFhrIThn1eBcgt9WTyLq539zMafDnhdGb6u/O5JdMTC3/pcJVqqHJmctiWxAYPpwT/fxw==

[email protected]:
version "0.6.0"
resolved "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
Expand Down
Loading