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.
This commit is contained in:
James Daniels
2025-03-27 17:53:59 -04:00
committed by GitHub
parent 1d8fe37a04
commit f2b02db66a
20 changed files with 306 additions and 29 deletions
+8
View File
@@ -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.
+4 -1
View File
@@ -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;
@@ -596,7 +599,7 @@ export interface PasswordValidationStatus {
// @public
export interface Persistence {
readonly type: 'SESSION' | 'LOCAL' | 'NONE';
readonly type: 'SESSION' | 'LOCAL' | 'NONE' | 'COOKIE';
}
// @public
+16
View File
@@ -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. |
@@ -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.
+3 -3
View File
@@ -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 -2
View File
@@ -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());
@@ -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());
+1 -1
View File
@@ -91,7 +91,7 @@ export async function _savePersistenceForRedirect(
auth.name
);
if (session) {
session.setItem(key, auth._getPersistence());
session.setItem(key, auth._getPersistenceType());
}
}
+2
View File
@@ -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';
@@ -83,6 +84,7 @@ import { getAuth } from './src/platform_browser';
export {
browserLocalPersistence,
browserCookiePersistence,
browserSessionPersistence,
indexedDBLocalPersistence,
PhoneAuthProvider,
+1
View File
@@ -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",
"rollup": "2.79.2",
"rollup-plugin-sourcemaps": "0.6.3",
"rollup-plugin-typescript2": "0.36.0",
@@ -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,
+4 -4
View File
@@ -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'
);
});
+32 -6
View File
@@ -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',
@@ -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',
@@ -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
);
});
@@ -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(
+15 -1
View File
@@ -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
@@ -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)
);
}
_initializeWithPersistence(
@@ -160,6 +169,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
this,
persistenceHierarchy
);
this._resolvePersistenceManagerAvailable?.();
if (this._deleted) {
return;
@@ -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',
@@ -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 () => {
@@ -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 () => {
@@ -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 () => {
+2 -1
View File
@@ -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>;
@@ -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';
@@ -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> {
@@ -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;
}
+4 -1
View File
@@ -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;
@@ -71,6 +72,7 @@ export interface AuthInternal extends Auth {
_canInitEmulator: boolean;
_isInitialized: boolean;
_initializationPromise: Promise<void> | null;
_persistenceManagerAvailable: Promise<void>;
_updateCurrentUser(user: UserInternal | null): Promise<void>;
_onStorageEvent(): void;
@@ -86,7 +88,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>;
+2 -1
View File
@@ -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';
}
/**
@@ -0,0 +1,166 @@
/**
* @license
* Copyright 2025 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 type { CookieChangeEvent } from 'cookie-store';
const POLLING_INTERVAL_MS = 1_000;
import {
PersistenceInternal,
PersistenceType,
PersistenceValue,
StorageEventListener
} from '../../core/persistence';
// Pull a cookie value from document.cookie
function getDocumentCookie(name: string): string | null {
const escapedName = name.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
const matcher = RegExp(`${escapedName}=([^;]+)`);
return document.cookie.match(matcher)?.[1] ?? null;
}
// Produce a sanitized cookie name from the persistence key
function getCookieName(key: string): string {
// __HOST- doesn't work in localhost https://issues.chromium.org/issues/40196122 but it has
// desirable security properties, so lets use a different cookie name while in dev-mode.
// Already checked isSecureContext in _isAvailable, so if it's http we're hitting local.
const isDevMode = window.location.protocol === 'http:';
return `${isDevMode ? '__dev_' : '__HOST-'}FIREBASE_${key.split(':')[3]}`;
}
export class CookiePersistence implements PersistenceInternal {
static type: 'COOKIE' = 'COOKIE';
readonly type = PersistenceType.COOKIE;
listenerUnsubscribes: Map<StorageEventListener, () => void> = new Map();
// used to get the URL to the backend to proxy to
_getFinalTarget(originalUrl: string): URL | string {
if (typeof window === undefined) {
return originalUrl;
}
const url = new URL(`${window.location.origin}/__cookies__`);
url.searchParams.set('finalTarget', originalUrl);
return url;
}
// To be a usable persistence method in a chain browserCookiePersistence ensures that
// prerequisites have been met, namely that we're in a secureContext, navigator and document are
// available and cookies are enabled. Not all UAs support these method, so fallback accordingly.
async _isAvailable(): Promise<boolean> {
if (typeof isSecureContext === 'boolean' && !isSecureContext) {
return false;
}
if (typeof navigator === 'undefined' || typeof document === 'undefined') {
return false;
}
return navigator.cookieEnabled ?? true;
}
// Set should be a noop as we expect middleware to handle this
async _set(_key: string, _value: PersistenceValue): Promise<void> {
return;
}
// Attempt to get the cookie from cookieStore, fallback to document.cookie
async _get<T extends PersistenceValue>(key: string): Promise<T | null> {
if (!this._isAvailable()) {
return null;
}
const name = getCookieName(key);
if (window.cookieStore) {
const cookie = await window.cookieStore.get(name);
return cookie?.value as T;
}
return getDocumentCookie(name) as T;
}
// Log out by overriding the idToken with a sentinel value of ""
async _remove(key: string): Promise<void> {
if (!this._isAvailable()) {
return;
}
// To make sure we don't hit signout over and over again, only do this operation if we need to
// with the logout sentinel value of "" this can cause race conditions. Unnecessary set-cookie
// headers will reduce CDN hit rates too.
const existingValue = await this._get(key);
if (!existingValue) {
return;
}
const name = getCookieName(key);
document.cookie = `${name}=;Max-Age=34560000;Partitioned;Secure;SameSite=Strict;Path=/;Priority=High`;
await fetch(`/__cookies__`, { method: 'DELETE' }).catch(() => undefined);
}
// Listen for cookie changes, both cookieStore and fallback to polling document.cookie
_addListener(key: string, listener: StorageEventListener): void {
if (!this._isAvailable()) {
return;
}
const name = getCookieName(key);
if (window.cookieStore) {
const cb = ((event: CookieChangeEvent): void => {
const changedCookie = event.changed.find(
change => change.name === name
);
if (changedCookie) {
listener(changedCookie.value as PersistenceValue);
}
const deletedCookie = event.deleted.find(
change => change.name === name
);
if (deletedCookie) {
listener(null);
}
}) as EventListener;
const unsubscribe = (): void =>
window.cookieStore.removeEventListener('change', cb);
this.listenerUnsubscribes.set(listener, unsubscribe);
return window.cookieStore.addEventListener('change', cb as EventListener);
}
let lastValue = getDocumentCookie(name);
const interval = setInterval(() => {
const currentValue = getDocumentCookie(name);
if (currentValue !== lastValue) {
listener(currentValue as PersistenceValue | null);
lastValue = currentValue;
}
}, POLLING_INTERVAL_MS);
const unsubscribe = (): void => clearInterval(interval);
this.listenerUnsubscribes.set(listener, unsubscribe);
}
_removeListener(_key: string, listener: StorageEventListener): void {
const unsubscribe = this.listenerUnsubscribes.get(listener);
if (!unsubscribe) {
return;
}
unsubscribe();
this.listenerUnsubscribes.delete(listener);
}
}
/**
* An implementation of {@link Persistence} of type `COOKIE`, for use on the client side in
* applications leveraging hybrid rendering and middleware.
*
* @remarks This persistence method requires companion middleware to function, such as that provided
* by {@link https://firebaseopensource.com/projects/firebaseextended/reactfire/ | ReactFire} for
* NextJS.
* @beta
*/
export const browserCookiePersistence: Persistence = CookiePersistence;
+1
View File
@@ -81,6 +81,7 @@ class FailClass {
export const browserLocalPersistence = inMemoryPersistence;
export const browserSessionPersistence = inMemoryPersistence;
export const browserCookiePersistence = inMemoryPersistence;
export const indexedDBLocalPersistence = inMemoryPersistence;
export const browserPopupRedirectResolver = NOT_AVAILABLE_ERROR;
export const PhoneAuthProvider = FailClass;
+5
View File
@@ -5875,6 +5875,11 @@ [email protected]:
resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
[email protected]:
version "4.0.0-next.4"
resolved "https://registry.npmjs.org/cookie-store/-/cookie-store-4.0.0-next.4.tgz#8b13981bfd93e10e30694e9816928f8c478a326b"
integrity sha512-RVcIK13cCiAa+rsxAbFhrIThn1eBcgt9WTyLq539zMafDnhdGb6u/O5JdMTC3/pcJVqqHJmctiWxAYPpwT/fxw==
[email protected]:
version "0.6.0"
resolved "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"