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 4 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
20 changes: 17 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 @@ -241,6 +242,9 @@ export async function _performSignInRequest<T, V extends IdTokenResponse>(
request?: T,
customErrorMap: Partial<ServerErrorMap<ServerError>> = {}
): Promise<V> {
// TODO(jamedaniels) if auth persistence is cookie, proxy through the server endpoint
// Q, do we want to allow signIn/Out to work normally on the server if cookie
// persistence is set and FirebaseServerApp authIdToken is not?
const serverResponse = await _performApiRequest<T, V | IdTokenMfaResponse>(
auth,
method,
Expand All @@ -265,11 +269,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
32 changes: 28 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,21 @@
}

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 await UserImpl._fromGetAccountInfoResponse(

Check failure on line 78 in packages/auth/src/core/persistence/persistence_user_manager.ts

View workflow job for this annotation

GitHub Actions / Lint

Redundant use of `await` on a return value
this.auth,
response,
blob
);
}
return UserImpl._fromJSON(this.auth, blob);
}

removeCurrentUser(): Promise<void> {
Expand Down Expand Up @@ -140,9 +154,19 @@
// 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
2 changes: 2 additions & 0 deletions packages/auth/src/core/user/user_credential_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export class UserCredentialImpl
this.operationType = params.operationType;
}

// TODO(jamesdaniels) fetch the user credential from the cookie and response returned from the
// proxy endpoint
static async _fromIdTokenResponse(
auth: AuthInternal,
operationType: OperationType,
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
2 changes: 2 additions & 0 deletions packages/auth/src/model/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export interface UserInternal extends User {

auth: AuthInternal;
providerId: ProviderId.FIREBASE;
// TODO(jamesdaniels): refreshToken should either be optional or a sentinel value for COOKIE
// persistence, if refresh token has an identifier maybe that?
refreshToken: string;
emailVerified: boolean;
tenantId: string | null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2019 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 {
PersistenceInternal,
PersistenceType,
PersistenceValue,
StorageEventListener
} from '../../core/persistence';

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

Check failure on line 30 in packages/auth/src/platform_browser/persistence/cookie_storage.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type

async _isAvailable(): Promise<boolean> {
return navigator.hasOwnProperty('cookieEnabled') ?
navigator.cookieEnabled :
true;
}

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

async _get<T extends PersistenceValue>(key: string): Promise<T | null> {
const cookie = await (window as any).cookieStore.get(key);

Check failure on line 43 in packages/auth/src/platform_browser/persistence/cookie_storage.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
return cookie?.value;
}

async _remove(key: string): Promise<void> {
const cookie = await (window as any).cookieStore.get(key);

Check failure on line 48 in packages/auth/src/platform_browser/persistence/cookie_storage.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
if (!cookie) {
return;
}
await (window as any).cookieStore.set({ ...cookie, value: "" });

Check failure on line 52 in packages/auth/src/platform_browser/persistence/cookie_storage.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
await fetch(`/__cookies__`, { method: 'DELETE' }).catch(() => undefined);
}

_addListener(_key: string, _listener: StorageEventListener): void {
// TODO fallback to polling if cookieStore is not available
const cb = (event: any) => {

Check failure on line 58 in packages/auth/src/platform_browser/persistence/cookie_storage.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type

Check failure on line 58 in packages/auth/src/platform_browser/persistence/cookie_storage.ts

View workflow job for this annotation

GitHub Actions / Lint

Missing return type on function
const cookie = event.changed.find((change: any) => change.name === _key);

Check failure on line 59 in packages/auth/src/platform_browser/persistence/cookie_storage.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
if (cookie) {
_listener(cookie.value);
}
};
this.listeners.set(_listener, cb);
(window as any).cookieStore.addEventListener('change', cb);

Check failure on line 65 in packages/auth/src/platform_browser/persistence/cookie_storage.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
}

_removeListener(_key: string, _listener: StorageEventListener): void {
const cb = this.listeners.get(_listener);
if (!cb) {
return;
}
(window as any).cookieStore.removeEventListener('change', cb);

Check failure on line 73 in packages/auth/src/platform_browser/persistence/cookie_storage.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
}
}

/**
* An implementation of {@link Persistence} of type 'NONE'.
*
* @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"
}
Loading