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 5 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: 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
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 cookies, 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';

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

export class CookiePersistence implements PersistenceInternal {
static type: 'COOKIE' = 'COOKIE';
readonly type = PersistenceType.COOKIE;
cookieStoreListeners: Map<
StorageEventListener,
(event: CookieChangeEvent) => void
> = new Map();
cookiePollingIntervals: Map<StorageEventListener, NodeJS.Timeout> = new Map();

async _isAvailable(): Promise<boolean> {
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;
}
if (window.cookieStore) {
const cookie = await window.cookieStore.get(key);
return cookie?.value as T;
} else {
return getDocumentCookie(key) as T;
}
}

async _remove(key: string): Promise<void> {
if (!this._isAvailable()) {
return;
}
if (window.cookieStore) {
const cookie = await window.cookieStore.get(key);
if (!cookie) {
return;
}
await window.cookieStore.delete(cookie);
} else {
// TODO how do I get the cookie properties?
document.cookie = `${key}=;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;
}
if (window.cookieStore) {
const cb = (event: CookieChangeEvent): void => {
const changedCookie = event.changed.find(change => change.name === key);
if (changedCookie) {
listener(changedCookie.value as PersistenceValue);
}
const deletedCookie = event.deleted.find(change => change.name === key);
if (deletedCookie) {
listener(null);
}
};
this.cookieStoreListeners.set(listener, cb);
window.cookieStore.addEventListener('change', cb as EventListener);
} else {
let lastValue = getDocumentCookie(key);
const interval = setInterval(() => {
const currentValue = getDocumentCookie(key);
if (currentValue !== lastValue) {
listener(currentValue as PersistenceValue | null);
lastValue = currentValue;
}
}, POLLING_INTERVAL_MS);
this.cookiePollingIntervals.set(listener, interval);
}
}

// TODO can we tidy this logic up into a single unsubscribe function? () => void;
_removeListener(_key: string, listener: StorageEventListener): void {
if (!this._isAvailable()) {
return;
}
if (window.cookieStore) {
const cb = this.cookieStoreListeners.get(listener);
if (!cb) {
return;
}
window.cookieStore.removeEventListener('change', cb as EventListener);
this.cookieStoreListeners.delete(listener);
} else {
const interval = this.cookiePollingIntervals.get(listener);
if (!interval) {
return;
}
clearInterval(interval);
this.cookiePollingIntervals.delete(listener);
}
}
}

/**
* An implementation of {@link Persistence} of type 'COOKIE'.
*
* @public
*/
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 @@ cookie-signature@1.0.6:
resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==

cookie-store@4.0.0-next.4:
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==

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