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 18 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
6 changes: 6 additions & 0 deletions .changeset/orange-turtles-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'firebase': minor
'@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 @@ -258,6 +258,9 @@ export interface AuthSettings {
// @public
export function beforeAuthStateChanged(auth: Auth, callback: (user: User | null) => void | Promise<void>, onAbort?: () => void): Unsubscribe;

// @beta
export const browserCookiePersistence: Persistence;

// @public
export const browserLocalPersistence: Persistence;

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
16 changes: 16 additions & 0 deletions docs-devsite/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Firebase Authentication
| --- | --- |
| [ActionCodeOperation](./auth.md#actioncodeoperation) | An enumeration of the possible email action types. |
| [AuthErrorCodes](./auth.md#autherrorcodes) | A map of potential <code>Auth</code> error codes, for easier comparison with errors thrown by the SDK. |
| [browserCookiePersistence](./auth.md#browsercookiepersistence) | <b><i>(Public Preview)</i></b> An implementation of [Persistence](./auth.persistence.md#persistence_interface) of type <code>COOKIE</code>, for use on the client-side in applications leveraging hybrid rendering and middleware. |
| [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. |
Expand Down Expand Up @@ -1960,6 +1961,21 @@ AUTH_ERROR_CODES_MAP_DO_NOT_USE_INTERNALLY: {
}
```

## browserCookiePersistence

> 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 on the client-side in applications leveraging hybrid rendering and middleware.

This persistence method requires companion middleware to function, such as that provided by [ReactFire](https://firebaseopensource.com/projects/firebaseextended/reactfire/) for NextJS.

<b>Signature:</b>

```typescript
browserCookiePersistence: Persistence
```

## browserLocalPersistence

An implementation of [Persistence](./auth.persistence.md#persistence_interface) of type `LOCAL` using `localStorage` for the underlying storage.
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 { browserCookiePersistence } 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,
browserCookiePersistence,
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
36 changes: 32 additions & 4 deletions packages/auth/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
* limitations under the License.
*/

import { FirebaseError, isCloudflareWorker, querystring } from '@firebase/util';
import {
FirebaseError,
isCloudflareWorker,
querystring
} from '@firebase/util';

import { AuthErrorCode, NamedErrorParams } from '../core/errors';
import {
Expand All @@ -31,6 +35,8 @@ 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';
import { CookiePersistence } from '../platform_browser/persistence/cookie_storage';

export const enum HttpMethod {
POST = 'POST',
Expand Down Expand Up @@ -73,6 +79,15 @@ export const enum Endpoint {
REVOKE_TOKEN = '/v2/accounts:revokeToken'
}

const CookieAuthProxiedEndpoints: string[] = [
Endpoint.SIGN_IN_WITH_CUSTOM_TOKEN,
Endpoint.SIGN_IN_WITH_EMAIL_LINK,
Endpoint.SIGN_IN_WITH_IDP,
Endpoint.SIGN_IN_WITH_PASSWORD,
Endpoint.SIGN_IN_WITH_PHONE_NUMBER,
Endpoint.TOKEN
];

export const enum RecaptchaClientType {
WEB = 'CLIENT_TYPE_WEB',
ANDROID = 'CLIENT_TYPE_ANDROID',
Expand Down Expand Up @@ -265,11 +280,24 @@ export function _getFinalTarget(
): string {
const base = `${host}${path}?${query}`;

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

// Cookie auth works by MiTMing the signIn and token endpoints from the developer's backend,
// saving the idToken and refreshToken into cookies, and then redacting the refreshToken
// from the response
if (
authInternal._getPersistenceType() === PersistenceType.COOKIE &&
CookieAuthProxiedEndpoints.includes(path)
) {
const cookiePersistence =
authInternal._getPersistence() as CookiePersistence;
return cookiePersistence._getFinalTarget(finalTarget).toString();
}

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

export function _parseEnforcementState(
Expand Down
6 changes: 5 additions & 1 deletion packages/auth/src/core/auth/auth_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,10 +524,14 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
}
}

_getPersistence(): string {
_getPersistenceType(): string {
return this.assertedPersistence.persistence.type;
}

_getPersistence(): PersistenceInternal {
return this.assertedPersistence.persistence;
}

_updateErrorMap(errorMap: AuthErrorMap): void {
this._errorFactory = new ErrorFactory<AuthErrorCode, AuthErrorParams>(
'auth',
Expand Down
6 changes: 3 additions & 3 deletions packages/auth/src/core/auth/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ describe('core/auth/initialize', () => {
sdkClientVersion: expectedSdkClientVersion,
tokenApiHost: 'securetoken.googleapis.com'
});
expect(auth._getPersistence()).to.eq('NONE');
expect(auth._getPersistenceType()).to.eq('NONE');
});

it('should set persistence', async () => {
Expand All @@ -179,7 +179,7 @@ describe('core/auth/initialize', () => {
}) as AuthInternal;
await auth._initializationPromise;

expect(auth._getPersistence()).to.eq('SESSION');
expect(auth._getPersistenceType()).to.eq('SESSION');
});

it('should set persistence with fallback', async () => {
Expand All @@ -188,7 +188,7 @@ describe('core/auth/initialize', () => {
}) as AuthInternal;
await auth._initializationPromise;

expect(auth._getPersistence()).to.eq('SESSION');
expect(auth._getPersistenceType()).to.eq('SESSION');
});

it('should set resolver', async () => {
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
38 changes: 34 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,22 @@ 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 }).catch(
() => undefined
);
if (!response) {
return null;
}
return UserImpl._fromGetAccountInfoResponse(this.auth, response, blob);
}
return UserImpl._fromJSON(this.auth, blob);
}

removeCurrentUser(): Promise<void> {
Expand Down Expand Up @@ -140,9 +155,24 @@ 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
}).catch(() => undefined);
if (!response) {
break;
}
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
4 changes: 3 additions & 1 deletion packages/auth/src/model/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { UserInternal } from './user';
import { ClientPlatform } from '../core/util/version';
import { RecaptchaConfig } from '../platform_browser/recaptcha/recaptcha';
import { PasswordPolicyInternal } from './password_policy';
import { PersistenceInternal } from '../core/persistence';

export type AppName = string;
export type ApiKey = string;
Expand Down Expand Up @@ -86,7 +87,8 @@ export interface AuthInternal extends Auth {
_key(): string;
_startProactiveRefresh(): void;
_stopProactiveRefresh(): void;
_getPersistence(): string;
_getPersistenceType(): string;
_getPersistence(): PersistenceInternal;
_getRecaptchaConfig(): RecaptchaConfig | null;
_getPasswordPolicyInternal(): PasswordPolicyInternal | null;
_updatePasswordPolicy(): Promise<void>;
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
Loading
Loading