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

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft

Conversation

jamesdaniels
Copy link
Member

@jamesdaniels jamesdaniels commented Mar 10, 2025

Proof-of-concept of go/firebase-auth-cookie-persistence, pair with this NextJS middleware.

Demo here—login restricted to @google.com Google accounts. Only confirmed working in desktop Chrome ATM.

Principles of operation:

  1. New persistence option COOKIE
  2. When persistence is cookie, sign-in and token refresh requests are proxied to /__cookie__, which is handled by the NextJS middleware. The middleware this redacts the refreshToken and stores it in an HTTP-only cookie, the idToken is stored in an JS-readable cookie
  3. getCurrentUser() internally can accept an idToken and will initiated a fetch request to get the user-details
  4. The NextJS middleware can itself freshen the idToken, if it's expired on a full-page load
  5. During logout the client will set the JS-readable cookie to "" and will make a best-effort attempt to hit /__cookie__, the middleware treats the blank string as a logout and will delete the refreshToken cookie if seen

There's a lot to clean up here, some high level things that need to be addressed

  • Types
  • Tests
  • Don't hardcode the /__cookie__ URL, this should come from the persistence manager and be configurable
  • The cookie persistence class depends on cookieStore, we need to fall back to navigator.cookie for UAs that don't have cookieStore
  • Allow get/set cookie to be overridden, allowing for NextJS / Express-use
  • Need to test with other sign in methods
  • setPersistence(auth, cookiePersistence) was acting up, need to look into this, probably something to do with upgrades
  • allow use in a chain of persistence methods and allow upgrade
  • I'm probably using cookie names that are gonna fail in some UAs, look into the rules and sanitize/hash
  • Double check this functions correctly with diff tenants

For the middleware:

  • The middleware needs to be packaged for use in an NextJS app and released in reactfire
  • Drop jose in favor of WIP authIdTokenVerified #8008
  • Create middleware helpers to be released in @firebase/auth
  • Release stable express-style middleware (might not be realistic before Cloud Next)
  • What should the default max-age be?

Copy link

changeset-bot bot commented Mar 10, 2025

⚠️ No Changeset found

Latest commit: aba574a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@@ -241,6 +242,9 @@ export async function _performSignInRequest<T, V extends IdTokenResponse>(
request?: T,
customErrorMap: Partial<ServerErrorMap<ServerError>> = {}
): Promise<V> {
// TODO(jamedaniels) if auth persistence is cookie, proxy through the server endpoint
Copy link
Member Author

Choose a reason for hiding this comment

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

done.

Copy link
Contributor

Choose a reason for hiding this comment

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

Could you remove the completed TODO comments from the source if they're actually done. It would help with the review.

Copy link
Member Author

Choose a reason for hiding this comment

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

will do

@@ -44,6 +44,8 @@ export class UserCredentialImpl
this.operationType = params.operationType;
}

// TODO(jamesdaniels) fetch the user credential from the cookie and response returned from the
Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

@@ -62,6 +62,8 @@ export interface UserInternal extends User {

auth: AuthInternal;
providerId: ProviderId.FIREBASE;
// TODO(jamesdaniels): refreshToken should either be optional or a sentinel value for COOKIE
Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2019 Google LLC
Copy link
Contributor

Choose a reason for hiding this comment

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

2025

export class CookiePersistence implements PersistenceInternal {
static type: 'COOKIE' = 'COOKIE';
readonly type = PersistenceType.COOKIE;
listeners: Map<StorageEventListener, (e: any) => void> = new Map();
Copy link
Contributor

Choose a reason for hiding this comment

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

I think our linter frowns up on the use of any.

readonly type = PersistenceType.COOKIE;
listeners: Map<StorageEventListener, (e: any) => void> = new Map();

async _isAvailable(): Promise<boolean> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you add class and method comments?

}

/**
* An implementation of {@link Persistence} of type 'NONE'.
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't this of type COOKIE?

Copy link
Member Author

Choose a reason for hiding this comment

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

heh, yeah copy-pasta

@@ -1,5 +1,5 @@
{
"extends": "../../config/api-extractor.json",
// Point it to your entry point d.ts file.
"mainEntryPointFilePath": "<projectFolder>/dist/rules-unit-testing/index.d.ts"
"mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts"
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this change in here?

Copy link
Member Author

Choose a reason for hiding this comment

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

No idea, I should have left a comment this was to fix the build

@@ -241,6 +242,9 @@ export async function _performSignInRequest<T, V extends IdTokenResponse>(
request?: T,
customErrorMap: Partial<ServerErrorMap<ServerError>> = {}
): Promise<V> {
// TODO(jamedaniels) if auth persistence is cookie, proxy through the server endpoint
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you remove the completed TODO comments from the source if they're actually done. It would help with the review.

return null;
}
if (typeof blob === 'string') {
const response = await getAccountInfo(this.auth, { idToken: blob });
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we check if the token is still valid and return null if it's not? We already have validateTokenTTL in app/src/firebaseServerApp.ts which could be brought into our utils package.

Copy link
Contributor

Choose a reason for hiding this comment

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

Have you tested this with an invalid token to see how it behaves?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good call on both

Copy link
Member Author

Choose a reason for hiding this comment

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

Thinking about this, in normal operation this shouldn't happen, as the server-side route should be covered by middleware which will return set-cookie—but if the developer made a mistake and didn't cover the route with middleware then this could expire—so we should handle & log a sensible error.

if (blob) {
const user = UserImpl._fromJSON(auth, blob); // throws for unparsable blob (wrong format)
let user: UserInternal;
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here, what if the token isn't valid or has expired?

@@ -845,6 +845,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
}

async _getAppCheckToken(): Promise<string | undefined> {
// @ts-ignore
Copy link
Member Author

Choose a reason for hiding this comment

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

Should not be needed, my local build was broken without it.

listeners: Map<StorageEventListener, (e: any) => void> = new Map();

async _isAvailable(): Promise<boolean> {
return navigator.hasOwnProperty('cookieEnabled') ?
Copy link
Contributor

Choose a reason for hiding this comment

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

Probably need a check on typeof navigator !== 'undefined' just in case. IndexedDB persistence has a check on if (!indexedDB) inside a try/catch which I think also works.

Copy link
Member Author

Choose a reason for hiding this comment

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

good call, will do

(path.startsWith('/v1/accounts:signIn') || path === Endpoint.TOKEN)
) {
const params = new URLSearchParams({ finalTarget });
return `${window.location.origin}/__cookies__?${params.toString()}`;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this function can be called in Node so we may have to make sure window doesn't throw. I'm not sure if the === PersistenceType.COOKIE is a guarantee that we're in browser, maybe safer to explicitly check for window. What's the "don't use startsWith v1/accounts" comment about?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants