Skip to content

Commit fb5d422

Browse files
authored
Auth cookie persistence (#8839)
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.
1 parent 670eba6 commit fb5d422

File tree

20 files changed

+306
-29
lines changed

20 files changed

+306
-29
lines changed

Diff for: .changeset/orange-turtles-taste.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'firebase': minor
3+
'@firebase/auth': minor
4+
---
5+
6+
Adding `Persistence.COOKIE` a new persistence method backed by cookies. The
7+
`browserCookiePersistence` implementation is designed to be used in conjunction with middleware that
8+
ensures both your front and backend authentication state remains synchronized.

Diff for: common/api-review/auth.api.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,9 @@ export interface AuthSettings {
258258
// @public
259259
export function beforeAuthStateChanged(auth: Auth, callback: (user: User | null) => void | Promise<void>, onAbort?: () => void): Unsubscribe;
260260

261+
// @beta
262+
export const browserCookiePersistence: Persistence;
263+
261264
// @public
262265
export const browserLocalPersistence: Persistence;
263266

@@ -596,7 +599,7 @@ export interface PasswordValidationStatus {
596599

597600
// @public
598601
export interface Persistence {
599-
readonly type: 'SESSION' | 'LOCAL' | 'NONE';
602+
readonly type: 'SESSION' | 'LOCAL' | 'NONE' | 'COOKIE';
600603
}
601604

602605
// @public

Diff for: docs-devsite/auth.md

+16
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ Firebase Authentication
150150
| --- | --- |
151151
| [ActionCodeOperation](./auth.md#actioncodeoperation) | An enumeration of the possible email action types. |
152152
| [AuthErrorCodes](./auth.md#autherrorcodes) | A map of potential <code>Auth</code> error codes, for easier comparison with errors thrown by the SDK. |
153+
| [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. |
153154
| [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. |
154155
| [browserPopupRedirectResolver](./auth.md#browserpopupredirectresolver) | An implementation of [PopupRedirectResolver](./auth.popupredirectresolver.md#popupredirectresolver_interface) suitable for browser based applications. |
155156
| [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. |
@@ -1960,6 +1961,21 @@ AUTH_ERROR_CODES_MAP_DO_NOT_USE_INTERNALLY: {
19601961
}
19611962
```
19621963

1964+
## browserCookiePersistence
1965+
1966+
> 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.
1967+
>
1968+
1969+
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.
1970+
1971+
This persistence method requires companion middleware to function, such as that provided by [ReactFire](https://firebaseopensource.com/projects/firebaseextended/reactfire/) for NextJS.
1972+
1973+
<b>Signature:</b>
1974+
1975+
```typescript
1976+
browserCookiePersistence: Persistence
1977+
```
1978+
19631979
## browserLocalPersistence
19641980

19651981
An implementation of [Persistence](./auth.persistence.md#persistence_interface) of type `LOCAL` using `localStorage` for the underlying storage.

Diff for: docs-devsite/auth.persistence.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ export interface Persistence
2222

2323
| Property | Type | Description |
2424
| --- | --- | --- |
25-
| [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. |
25+
| [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. |
2626

2727
## Persistence.type
2828

29-
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.
29+
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.
3030

3131
<b>Signature:</b>
3232

3333
```typescript
34-
readonly type: 'SESSION' | 'LOCAL' | 'NONE';
34+
readonly type: 'SESSION' | 'LOCAL' | 'NONE' | 'COOKIE';
3535
```

Diff for: packages/auth-compat/src/auth.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe('auth compat', () => {
6565
it('saves the persistence into session storage if available', async () => {
6666
if (typeof self !== 'undefined') {
6767
underlyingAuth._initializationPromise = Promise.resolve();
68-
sinon.stub(underlyingAuth, '_getPersistence').returns('TEST');
68+
sinon.stub(underlyingAuth, '_getPersistenceType').returns('TEST');
6969
sinon
7070
.stub(underlyingAuth, '_initializationPromise')
7171
.value(Promise.resolve());
@@ -97,7 +97,7 @@ describe('auth compat', () => {
9797
}
9898
} as unknown as Window);
9999
const setItemSpy = sinon.spy(sessionStorage, 'setItem');
100-
sinon.stub(underlyingAuth, '_getPersistence').returns('TEST');
100+
sinon.stub(underlyingAuth, '_getPersistenceType').returns('TEST');
101101
sinon
102102
.stub(underlyingAuth, '_initializationPromise')
103103
.value(Promise.resolve());

Diff for: packages/auth-compat/src/persistence.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export async function _savePersistenceForRedirect(
9191
auth.name
9292
);
9393
if (session) {
94-
session.setItem(key, auth._getPersistence());
94+
session.setItem(key, auth._getPersistenceType());
9595
}
9696
}
9797

Diff for: packages/auth/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export * from './src';
4343

4444
// persistence
4545
import { browserLocalPersistence } from './src/platform_browser/persistence/local_storage';
46+
import { browserCookiePersistence } from './src/platform_browser/persistence/cookie_storage';
4647
import { browserSessionPersistence } from './src/platform_browser/persistence/session_storage';
4748
import { indexedDBLocalPersistence } from './src/platform_browser/persistence/indexed_db';
4849

@@ -83,6 +84,7 @@ import { getAuth } from './src/platform_browser';
8384

8485
export {
8586
browserLocalPersistence,
87+
browserCookiePersistence,
8688
browserSessionPersistence,
8789
indexedDBLocalPersistence,
8890
PhoneAuthProvider,

Diff for: packages/auth/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
"@rollup/plugin-strip": "2.1.0",
137137
"@types/express": "4.17.21",
138138
"chromedriver": "119.0.1",
139+
"cookie-store": "4.0.0-next.4",
139140
"rollup": "2.79.2",
140141
"rollup-plugin-sourcemaps": "0.6.3",
141142
"rollup-plugin-typescript2": "0.36.0",

Diff for: packages/auth/src/api/authentication/token.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export async function requestStsToken(
7474
'refresh_token': refreshToken
7575
}).slice(1);
7676
const { tokenApiHost, apiKey } = auth.config;
77-
const url = _getFinalTarget(
77+
const url = await _getFinalTarget(
7878
auth,
7979
tokenApiHost,
8080
Endpoint.TOKEN,

Diff for: packages/auth/src/api/index.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -509,17 +509,17 @@ describe('api/_performApiRequest', () => {
509509
});
510510

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

518-
it('works properly with an emulated environment', () => {
518+
it('works properly with an emulated environment', async () => {
519519
(auth.config as ConfigInternal).emulator = {
520520
url: 'http://localhost:5000/'
521521
};
522-
expect(_getFinalTarget(auth, 'host', '/path', 'query=test')).to.eq(
522+
expect(await _getFinalTarget(auth, 'host', '/path', 'query=test')).to.eq(
523523
'http://localhost:5000/host/path?query=test'
524524
);
525525
});

Diff for: packages/auth/src/api/index.ts

+32-6
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { AuthInternal, ConfigInternal } from '../model/auth';
3131
import { IdTokenResponse, TaggedWithTokenResponse } from '../model/id_token';
3232
import { IdTokenMfaResponse } from './authentication/mfa';
3333
import { SERVER_ERROR_MAP, ServerError, ServerErrorMap } from './errors';
34+
import { PersistenceType } from '../core/persistence';
35+
import { CookiePersistence } from '../platform_browser/persistence/cookie_storage';
3436

3537
export const enum HttpMethod {
3638
POST = 'POST',
@@ -73,6 +75,15 @@ export const enum Endpoint {
7375
REVOKE_TOKEN = '/v2/accounts:revokeToken'
7476
}
7577

78+
const CookieAuthProxiedEndpoints: string[] = [
79+
Endpoint.SIGN_IN_WITH_CUSTOM_TOKEN,
80+
Endpoint.SIGN_IN_WITH_EMAIL_LINK,
81+
Endpoint.SIGN_IN_WITH_IDP,
82+
Endpoint.SIGN_IN_WITH_PASSWORD,
83+
Endpoint.SIGN_IN_WITH_PHONE_NUMBER,
84+
Endpoint.TOKEN
85+
];
86+
7687
export const enum RecaptchaClientType {
7788
WEB = 'CLIENT_TYPE_WEB',
7889
ANDROID = 'CLIENT_TYPE_ANDROID',
@@ -167,7 +178,7 @@ export async function _performApiRequest<T, V>(
167178
}
168179

169180
return FetchProvider.fetch()(
170-
_getFinalTarget(auth, auth.config.apiHost, path, query),
181+
await _getFinalTarget(auth, auth.config.apiHost, path, query),
171182
fetchArgs
172183
);
173184
});
@@ -257,19 +268,34 @@ export async function _performSignInRequest<T, V extends IdTokenResponse>(
257268
return serverResponse as V;
258269
}
259270

260-
export function _getFinalTarget(
271+
export async function _getFinalTarget(
261272
auth: Auth,
262273
host: string,
263274
path: string,
264275
query: string
265-
): string {
276+
): Promise<string> {
266277
const base = `${host}${path}?${query}`;
267278

268-
if (!(auth as AuthInternal).config.emulator) {
269-
return `${auth.config.apiScheme}://${base}`;
279+
const authInternal = auth as AuthInternal;
280+
const finalTarget = authInternal.config.emulator
281+
? _emulatorUrl(auth.config as ConfigInternal, base)
282+
: `${auth.config.apiScheme}://${base}`;
283+
284+
// Cookie auth works by MiTMing the signIn and token endpoints from the developer's backend,
285+
// saving the idToken and refreshToken into cookies, and then redacting the refreshToken
286+
// from the response
287+
if (CookieAuthProxiedEndpoints.includes(path)) {
288+
// Persistence manager is async, we need to await it. We can't just wait for auth initialized
289+
// here since auth initialization calls this function.
290+
await authInternal._persistenceManagerAvailable;
291+
if (authInternal._getPersistenceType() === PersistenceType.COOKIE) {
292+
const cookiePersistence =
293+
authInternal._getPersistence() as CookiePersistence;
294+
return cookiePersistence._getFinalTarget(finalTarget).toString();
295+
}
270296
}
271297

272-
return _emulatorUrl(auth.config as ConfigInternal, base);
298+
return finalTarget;
273299
}
274300

275301
export function _parseEnforcementState(

Diff for: packages/auth/src/core/auth/auth_impl.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
120120
_tenantRecaptchaConfigs: Record<string, RecaptchaConfig> = {};
121121
_projectPasswordPolicy: PasswordPolicyInternal | null = null;
122122
_tenantPasswordPolicies: Record<string, PasswordPolicyInternal> = {};
123+
_resolvePersistenceManagerAvailable:
124+
| ((value: void | PromiseLike<void>) => void)
125+
| undefined = undefined;
126+
_persistenceManagerAvailable: Promise<void>;
123127
readonly name: string;
124128

125129
// Tracks the last notified UID for state change listeners to prevent
@@ -139,6 +143,11 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
139143
) {
140144
this.name = app.name;
141145
this.clientVersion = config.sdkClientVersion;
146+
// TODO(jamesdaniels) explore less hacky way to do this, cookie authentication needs
147+
// persistenceMananger to be available. see _getFinalTarget for more context
148+
this._persistenceManagerAvailable = new Promise<void>(
149+
resolve => (this._resolvePersistenceManagerAvailable = resolve)
150+
);
142151
}
143152

144153
_initializeWithPersistence(
@@ -160,6 +169,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
160169
this,
161170
persistenceHierarchy
162171
);
172+
this._resolvePersistenceManagerAvailable?.();
163173

164174
if (this._deleted) {
165175
return;
@@ -524,10 +534,14 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
524534
}
525535
}
526536

527-
_getPersistence(): string {
537+
_getPersistenceType(): string {
528538
return this.assertedPersistence.persistence.type;
529539
}
530540

541+
_getPersistence(): PersistenceInternal {
542+
return this.assertedPersistence.persistence;
543+
}
544+
531545
_updateErrorMap(errorMap: AuthErrorMap): void {
532546
this._errorFactory = new ErrorFactory<AuthErrorCode, AuthErrorParams>(
533547
'auth',

Diff for: packages/auth/src/core/auth/initialize.test.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ describe('core/auth/initialize', () => {
170170
sdkClientVersion: expectedSdkClientVersion,
171171
tokenApiHost: 'securetoken.googleapis.com'
172172
});
173-
expect(auth._getPersistence()).to.eq('NONE');
173+
expect(auth._getPersistenceType()).to.eq('NONE');
174174
});
175175

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

182-
expect(auth._getPersistence()).to.eq('SESSION');
182+
expect(auth._getPersistenceType()).to.eq('SESSION');
183183
});
184184

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

191-
expect(auth._getPersistence()).to.eq('SESSION');
191+
expect(auth._getPersistenceType()).to.eq('SESSION');
192192
});
193193

194194
it('should set resolver', async () => {

Diff for: packages/auth/src/core/persistence/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import { Persistence } from '../../model/public_types';
1919
export const enum PersistenceType {
2020
SESSION = 'SESSION',
2121
LOCAL = 'LOCAL',
22-
NONE = 'NONE'
22+
NONE = 'NONE',
23+
COOKIE = 'COOKIE'
2324
}
2425

2526
export type PersistedBlob = Record<string, unknown>;

Diff for: packages/auth/src/core/persistence/persistence_user_manager.ts

+34-4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* limitations under the License.
1616
*/
1717

18+
import { getAccountInfo } from '../../api/account_management/account';
1819
import { ApiKey, AppName, AuthInternal } from '../../model/auth';
1920
import { UserInternal } from '../../model/user';
2021
import { PersistedBlob, PersistenceInternal } from '../persistence';
@@ -66,8 +67,22 @@ export class PersistenceUserManager {
6667
}
6768

6869
async getCurrentUser(): Promise<UserInternal | null> {
69-
const blob = await this.persistence._get<PersistedBlob>(this.fullUserKey);
70-
return blob ? UserImpl._fromJSON(this.auth, blob) : null;
70+
const blob = await this.persistence._get<PersistedBlob | string>(
71+
this.fullUserKey
72+
);
73+
if (!blob) {
74+
return null;
75+
}
76+
if (typeof blob === 'string') {
77+
const response = await getAccountInfo(this.auth, { idToken: blob }).catch(
78+
() => undefined
79+
);
80+
if (!response) {
81+
return null;
82+
}
83+
return UserImpl._fromGetAccountInfoResponse(this.auth, response, blob);
84+
}
85+
return UserImpl._fromJSON(this.auth, blob);
7186
}
7287

7388
removeCurrentUser(): Promise<void> {
@@ -140,9 +155,24 @@ export class PersistenceUserManager {
140155
// persistence, we will (but only if that persistence supports migration).
141156
for (const persistence of persistenceHierarchy) {
142157
try {
143-
const blob = await persistence._get<PersistedBlob>(key);
158+
const blob = await persistence._get<PersistedBlob | string>(key);
144159
if (blob) {
145-
const user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
160+
let user: UserInternal;
161+
if (typeof blob === 'string') {
162+
const response = await getAccountInfo(auth, {
163+
idToken: blob
164+
}).catch(() => undefined);
165+
if (!response) {
166+
break;
167+
}
168+
user = await UserImpl._fromGetAccountInfoResponse(
169+
auth,
170+
response,
171+
blob
172+
);
173+
} else {
174+
user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
175+
}
146176
if (persistence !== selectedPersistence) {
147177
userToMigrate = user;
148178
}

0 commit comments

Comments
 (0)