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