Skip to content
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

Auth cookie persistence #8839

Merged
merged 26 commits into from
Mar 27, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions .changeset/orange-turtles-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'firebase': minor
'@firebase/auth': minor
---

Adding `Persistence.COOKIE` a new persistence method backed by cookies. The
`browserCookiePersistence` implementation is designed to be used in conjunction with middleware that
ensures both your front and backend authentication state remains synchronized.
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';
```
4 changes: 2 additions & 2 deletions packages/auth-compat/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe('auth compat', () => {
it('saves the persistence into session storage if available', async () => {
if (typeof self !== 'undefined') {
underlyingAuth._initializationPromise = Promise.resolve();
sinon.stub(underlyingAuth, '_getPersistence').returns('TEST');
sinon.stub(underlyingAuth, '_getPersistenceType').returns('TEST');
sinon
.stub(underlyingAuth, '_initializationPromise')
.value(Promise.resolve());
Expand Down Expand Up @@ -97,7 +97,7 @@ describe('auth compat', () => {
}
} as unknown as Window);
const setItemSpy = sinon.spy(sessionStorage, 'setItem');
sinon.stub(underlyingAuth, '_getPersistence').returns('TEST');
sinon.stub(underlyingAuth, '_getPersistenceType').returns('TEST');
sinon
.stub(underlyingAuth, '_initializationPromise')
.value(Promise.resolve());
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-compat/src/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export async function _savePersistenceForRedirect(
auth.name
);
if (session) {
session.setItem(key, auth._getPersistence());
session.setItem(key, auth._getPersistenceType());
}
}

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 { 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
2 changes: 1 addition & 1 deletion packages/auth/src/api/authentication/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export async function requestStsToken(
'refresh_token': refreshToken
}).slice(1);
const { tokenApiHost, apiKey } = auth.config;
const url = _getFinalTarget(
const url = await _getFinalTarget(
auth,
tokenApiHost,
Endpoint.TOKEN,
Expand Down
8 changes: 4 additions & 4 deletions packages/auth/src/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,17 +509,17 @@ describe('api/_performApiRequest', () => {
});

context('_getFinalTarget', () => {
it('works properly with a non-emulated environment', () => {
expect(_getFinalTarget(auth, 'host', '/path', 'query=test')).to.eq(
it('works properly with a non-emulated environment', async () => {
expect(await _getFinalTarget(auth, 'host', '/path', 'query=test')).to.eq(
'mock://host/path?query=test'
);
});

it('works properly with an emulated environment', () => {
it('works properly with an emulated environment', async () => {
(auth.config as ConfigInternal).emulator = {
url: 'http://localhost:5000/'
};
expect(_getFinalTarget(auth, 'host', '/path', 'query=test')).to.eq(
expect(await _getFinalTarget(auth, 'host', '/path', 'query=test')).to.eq(
'http://localhost:5000/host/path?query=test'
);
});
Expand Down
38 changes: 32 additions & 6 deletions packages/auth/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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 +75,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 @@ -167,7 +178,7 @@ export async function _performApiRequest<T, V>(
}

return FetchProvider.fetch()(
_getFinalTarget(auth, auth.config.apiHost, path, query),
await _getFinalTarget(auth, auth.config.apiHost, path, query),
fetchArgs
);
});
Expand Down Expand Up @@ -257,19 +268,34 @@ export async function _performSignInRequest<T, V extends IdTokenResponse>(
return serverResponse as V;
}

export function _getFinalTarget(
export async function _getFinalTarget(
auth: Auth,
host: string,
path: string,
query: string
): string {
): Promise<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 (CookieAuthProxiedEndpoints.includes(path)) {
// Persistence manager is async, we need to await it. We can't just wait for auth initialized
// here since auth initialization calls this function.
await authInternal._persistenceManagerAvailable;
if (authInternal._getPersistenceType() === PersistenceType.COOKIE) {
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
16 changes: 15 additions & 1 deletion packages/auth/src/core/auth/auth_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
_tenantRecaptchaConfigs: Record<string, RecaptchaConfig> = {};
_projectPasswordPolicy: PasswordPolicyInternal | null = null;
_tenantPasswordPolicies: Record<string, PasswordPolicyInternal> = {};
_resolvePersistenceManagerAvailable:
| ((value: void | PromiseLike<void>) => void)
| undefined = undefined;
_persistenceManagerAvailable: Promise<void>;
readonly name: string;

// Tracks the last notified UID for state change listeners to prevent
Expand All @@ -139,6 +143,11 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
) {
this.name = app.name;
this.clientVersion = config.sdkClientVersion;
// TODO(jamesdaniels) explore less hacky way to do this, cookie authentication needs
// persistenceMananger to be available. see _getFinalTarget for more context
this._persistenceManagerAvailable = new Promise<void>(
resolve => (this._resolvePersistenceManagerAvailable = resolve)
);
Copy link
Member Author

@jamesdaniels jamesdaniels Mar 27, 2025

Choose a reason for hiding this comment

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

Can i haz Promise.withResolvers yet? 🤣

}

_initializeWithPersistence(
Expand All @@ -160,6 +169,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
this,
persistenceHierarchy
);
this._resolvePersistenceManagerAvailable?.();

if (this._deleted) {
return;
Expand Down Expand Up @@ -524,10 +534,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
Loading
Loading