Repository: ensite-in/next-firebase-auth-edge Branch: main Commit: a3721a8b460a Files: 295 Total size: 606.8 KB Directory structure: gitextract_erfprbrg/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── main.yml │ └── release.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .npmignore ├── .releaserc.yaml ├── .swcrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── docs/ │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── components/ │ │ ├── Chip.tsx │ │ ├── CommunityLink.tsx │ │ ├── Example.tsx │ │ ├── FeaturePanel.tsx │ │ ├── Footer.tsx │ │ ├── FooterLink.tsx │ │ ├── FooterSeparator.tsx │ │ ├── Hero.tsx │ │ ├── HeroCode.tsx │ │ ├── Link.tsx │ │ ├── LinkButton.tsx │ │ ├── Section.tsx │ │ ├── Steps.module.css │ │ ├── Steps.tsx │ │ └── Wrapper.tsx │ ├── config.js │ ├── next-env.d.ts │ ├── next-sitemap.config.js │ ├── next.config.js │ ├── package.json │ ├── pages/ │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── _meta.json │ │ ├── docs/ │ │ │ ├── _meta.json │ │ │ ├── app-check.mdx │ │ │ ├── emulator.mdx │ │ │ ├── errors.mdx │ │ │ ├── faq.mdx │ │ │ ├── getting-started/ │ │ │ │ ├── _meta.json │ │ │ │ ├── auth-context.mdx │ │ │ │ ├── auth-provider.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── layout.mdx │ │ │ │ ├── login-page.mdx │ │ │ │ ├── login-with-server-action.mdx │ │ │ │ ├── logout-with-server-action.mdx │ │ │ │ └── middleware.mdx │ │ │ └── usage/ │ │ │ ├── _meta.json │ │ │ ├── advanced-usage.mdx │ │ │ ├── app-router-api-routes.mdx │ │ │ ├── client-side-apis.mdx │ │ │ ├── cloud-run.mdx │ │ │ ├── debug-mode.mdx │ │ │ ├── domain-restriction.mdx │ │ │ ├── firebase-hosting.mdx │ │ │ ├── get-server-side-props.mdx │ │ │ ├── index.mdx │ │ │ ├── middleware.mdx │ │ │ ├── pages-router-api-routes.mdx │ │ │ ├── redirect-functions.mdx │ │ │ ├── refresh-credentials.mdx │ │ │ ├── remove-credentials.mdx │ │ │ └── server-components.mdx │ │ ├── examples/ │ │ │ └── index.mdx │ │ └── index.mdx │ ├── postcss.config.js │ ├── prettier.config.js │ ├── public/ │ │ └── favicon/ │ │ └── site.webmanifest │ ├── services/ │ │ ├── BrowserTracker.tsx │ │ └── ServerTracker.tsx │ ├── styles.css │ ├── tailwind.config.js │ ├── theme.config.tsx │ └── tsconfig.json ├── eslint.config.mjs ├── examples/ │ ├── next-typescript-minimal/ │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── README.md │ │ ├── app/ │ │ │ ├── HomePage.tsx │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ ├── login/ │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ └── register/ │ │ │ └── page.tsx │ │ ├── config.ts │ │ ├── firebase.ts │ │ ├── middleware.ts │ │ ├── next.config.mjs │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── tailwind.config.ts │ │ └── tsconfig.json │ └── next-typescript-starter/ │ ├── .dockerignore │ ├── .firebaserc │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── api/ │ │ └── index.ts │ ├── app/ │ │ ├── actions/ │ │ │ ├── login.ts │ │ │ ├── refresh-cookies.ts │ │ │ └── user-counters.ts │ │ ├── api/ │ │ │ ├── check-email-verification/ │ │ │ │ └── route.ts │ │ │ ├── custom-claims/ │ │ │ │ └── route.ts │ │ │ ├── test-app-check/ │ │ │ │ └── route.ts │ │ │ ├── token-test/ │ │ │ │ └── route.ts │ │ │ └── user-counters/ │ │ │ └── route.ts │ │ ├── auth/ │ │ │ ├── AuthContext.ts │ │ │ ├── AuthProvider.tsx │ │ │ └── firebase.ts │ │ ├── firebase.ts │ │ ├── globals.css │ │ ├── layout.module.css │ │ ├── layout.tsx │ │ ├── login/ │ │ │ ├── LoginPage.tsx │ │ │ ├── firebase.ts │ │ │ ├── login.module.css │ │ │ └── page.tsx │ │ ├── page.module.css │ │ ├── page.tsx │ │ ├── profile/ │ │ │ ├── UserProfile/ │ │ │ │ ├── UserProfile.module.css │ │ │ │ ├── UserProfile.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── user-counters-server.ts │ │ │ │ └── user-counters.ts │ │ │ ├── page.module.css │ │ │ └── page.tsx │ │ ├── register/ │ │ │ ├── RegisterPage.tsx │ │ │ ├── firebase.ts │ │ │ ├── page.tsx │ │ │ └── register.module.css │ │ ├── reset-password/ │ │ │ ├── ResetPasswordPage.module.css │ │ │ ├── ResetPasswordPage.tsx │ │ │ ├── firebase.ts │ │ │ └── page.tsx │ │ └── shared/ │ │ ├── redirect.ts │ │ ├── useRedirectAfterLogin.ts │ │ ├── useRedirectParam.ts │ │ └── user.ts │ ├── app-check/ │ │ └── index.ts │ ├── config/ │ │ ├── client-config.ts │ │ └── server-config.ts │ ├── eslint.config.mjs │ ├── firebase.json │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages/ │ │ └── api/ │ │ └── tokens.ts │ ├── proxy.ts │ ├── tsconfig.json │ └── ui/ │ ├── Badge/ │ │ ├── Badge.module.css │ │ ├── Badge.tsx │ │ └── index.ts │ ├── Button/ │ │ ├── Button.module.css │ │ ├── Button.tsx │ │ └── index.ts │ ├── ButtonGroup/ │ │ ├── ButtonGroup.module.css │ │ ├── ButtonGroup.tsx │ │ └── index.ts │ ├── Card/ │ │ ├── Card.module.css │ │ ├── Card.tsx │ │ └── index.ts │ ├── FormError/ │ │ ├── FormError.module.css │ │ ├── FormError.tsx │ │ └── index.ts │ ├── HomeLink/ │ │ ├── HomeLink.module.css │ │ ├── HomeLink.tsx │ │ └── index.ts │ ├── IconButton/ │ │ ├── IconButton.module.css │ │ ├── IconButton.tsx │ │ └── index.ts │ ├── Input/ │ │ ├── Input.module.css │ │ ├── Input.tsx │ │ └── index.ts │ ├── MainTitle/ │ │ ├── MainTitle.module.css │ │ ├── MainTitle.tsx │ │ └── index.ts │ ├── PasswordForm/ │ │ ├── PasswordForm.module.css │ │ ├── PasswordForm.tsx │ │ └── index.ts │ ├── Switch/ │ │ ├── Switch.module.css │ │ ├── Switch.tsx │ │ ├── index.ts │ │ └── vars.css │ ├── classNames.ts │ └── icons/ │ ├── HiddenIcon.tsx │ ├── HomeIcon.tsx │ ├── LoadingIcon.tsx │ ├── VisibleIcon.tsx │ ├── icons.module.css │ └── index.ts ├── jest.config.js ├── jest.setup.ts ├── package.json ├── prettier.config.js ├── src/ │ ├── app-check/ │ │ ├── api-client.ts │ │ ├── index.ts │ │ ├── test/ │ │ │ └── app-check.integration.test.ts │ │ ├── token-generator.ts │ │ ├── token-verifier.ts │ │ └── types.ts │ ├── auth/ │ │ ├── auth-request-handler.ts │ │ ├── claims.ts │ │ ├── credential.ts │ │ ├── custom-token/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── default-credential.ts │ │ ├── error.ts │ │ ├── firebase.ts │ │ ├── index.ts │ │ ├── jwt/ │ │ │ ├── consts.ts │ │ │ ├── crypto-signer.ts │ │ │ ├── sign.test.ts │ │ │ ├── sign.ts │ │ │ ├── verify.test.ts │ │ │ └── verify.ts │ │ ├── rotating-credential.test.ts │ │ ├── rotating-credential.ts │ │ ├── signature-verifier.test.ts │ │ ├── signature-verifier.ts │ │ ├── test/ │ │ │ ├── create-custom-token.integration.test.ts │ │ │ ├── no-matching-kid.integration.test.ts │ │ │ ├── session-cookie.test.ts │ │ │ ├── set-custom-user-claims.integration.test.ts │ │ │ ├── user.integration.test.ts │ │ │ └── verify-token.integration.test.ts │ │ ├── token-generator.ts │ │ ├── token-verifier.ts │ │ ├── types.ts │ │ ├── user-record.ts │ │ ├── utils.ts │ │ └── validator.ts │ ├── debug/ │ │ └── index.ts │ ├── index.ts │ └── next/ │ ├── api.ts │ ├── client.ts │ ├── cookies/ │ │ ├── AuthCookies.test.ts │ │ ├── AuthCookies.ts │ │ ├── builder/ │ │ │ ├── CookieBuilder.ts │ │ │ ├── CookieBuilderFactory.ts │ │ │ ├── MultipleCookieBuilder.test.ts │ │ │ ├── MultipleCookieBuilder.ts │ │ │ ├── SingleCookieBuilder.test.ts │ │ │ └── SingleCookieBuilder.ts │ │ ├── expiration/ │ │ │ ├── CombinedCookieExpiration.ts │ │ │ ├── CookieExpiration.ts │ │ │ ├── CookieExpirationFactory.test.ts │ │ │ ├── CookieExpirationFactory.ts │ │ │ ├── MultipleCookieExpiration.test.ts │ │ │ ├── MultipleCookieExpiration.ts │ │ │ ├── SingleCookieExpiration.test.ts │ │ │ └── SingleCookieExpiration.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── parser/ │ │ │ ├── CookieParser.ts │ │ │ ├── CookieParserFactory.test.ts │ │ │ ├── CookieParserFactory.ts │ │ │ ├── CookiesProvider.ts │ │ │ ├── MultipleCookiesParser.test.ts │ │ │ ├── MultipleCookiesParser.ts │ │ │ ├── ObjectCookiesProvider.ts │ │ │ ├── RequestCookiesProvider.test.ts │ │ │ ├── RequestCookiesProvider.ts │ │ │ ├── SingleCookieParser.test.ts │ │ │ └── SingleCookieParser.ts │ │ ├── remover/ │ │ │ ├── CombinedCookieRemover.ts │ │ │ ├── CookieRemover.ts │ │ │ ├── CookieRemoverFactory.test.ts │ │ │ ├── CookieRemoverFactory.ts │ │ │ ├── MultipleCookieRemover.test.ts │ │ │ ├── MultipleCookieRemover.ts │ │ │ └── SingleCookieRemover.ts │ │ ├── setter/ │ │ │ ├── CookieSetter.ts │ │ │ ├── CookieSetterFactory.ts │ │ │ ├── HeadersCookieSetter.test.ts │ │ │ ├── HeadersCookieSetter.ts │ │ │ ├── NextApiResponseHeadersCookieSetter.ts │ │ │ ├── RequestCookieSetter.test.ts │ │ │ └── RequestCookieSetter.ts │ │ └── types.ts │ ├── metadata.ts │ ├── middleware.ts │ ├── refresh-token.ts │ ├── tokens.ts │ └── utils.ts ├── tsconfig.base.json ├── tsconfig.browser.json ├── tsconfig.esm.json ├── tsconfig.json └── tsconfig.test.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: awinogrodzki ================================================ FILE: .github/workflows/main.yml ================================================ name: Main on: pull_request: paths: - "**" - "!*.md" - "!**/*.md" - "!.gitignore" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: FIREBASE_API_KEY: AIzaSyAXYgJha6lO_L4qfWpnhf3KijeKYDhuFzQ FIREBASE_PROJECT_ID: next-firebase-auth-edge-demo FIREBASE_ADMIN_CLIENT_EMAIL: ${{ secrets.FIREBASE_ADMIN_CLIENT_EMAIL }} FIREBASE_ADMIN_PRIVATE_KEY: ${{ secrets.FIREBASE_ADMIN_PRIVATE_KEY }} FIREBASE_AUTH_TENANT_ID: ${{ secrets.FIREBASE_AUTH_TENANT_ID }} FIREBASE_APP_CHECK_KEY: ${{ secrets.FIREBASE_APP_CHECK_KEY }} FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }} jobs: install: name: Install dependencies timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js 22 uses: actions/setup-node@v4 with: cache: "yarn" cache-dependency-path: yarn.lock node-version: 22 - name: Cache node_modules uses: actions/cache@v4 with: path: | node_modules key: ${{ runner.os }}-node_modules-cache-v2-${{ hashFiles('./yarn.lock') }}-${{github.sha}} restore-keys: | ${{ runner.os }}-node_modules-cache-v2-${{ hashFiles('./yarn.lock') }}- - run: yarn install build: name: Build packages timeout-minutes: 15 needs: install runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js 22 uses: actions/setup-node@v4 with: node-version: 22 - name: Cache node_modules uses: actions/cache@v4 with: path: | node_modules key: ${{ runner.os }}-node_modules-cache-v2-${{ hashFiles('./yarn.lock') }}-${{github.sha}} - name: Cache build uses: actions/cache@v4 with: path: | lib key: ${{ runner.os }}-build-cache-v2-${{ github.ref_name }}-${{github.sha}} restore-keys: | ${{ runner.os }}-build-cache-v2-${{ github.ref_name }}- - run: yarn build lint: name: Lint packages timeout-minutes: 10 needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js 22 uses: actions/setup-node@v4 with: node-version: 22 - name: Cache node_modules uses: actions/cache@v4 with: path: | node_modules key: ${{ runner.os }}-node_modules-cache-v2-${{ hashFiles('./yarn.lock') }}-${{github.sha}} - run: yarn lint tests: name: Tests timeout-minutes: 15 runs-on: ubuntu-latest needs: build steps: - uses: actions/checkout@v4 - name: Use Node.js 22 uses: actions/setup-node@v4 with: node-version: 22 - name: Cache node_modules uses: actions/cache@v4 with: path: | node_modules key: ${{ runner.os }}-node_modules-cache-v2-${{ hashFiles('./yarn.lock') }}-${{github.sha}} - name: Cache build uses: actions/cache@v4 with: path: | lib key: ${{ runner.os }}-build-cache-v2-${{ github.ref_name }}-${{github.sha}} - run: yarn test ================================================ FILE: .github/workflows/release.yml ================================================ name: Release env: FIREBASE_API_KEY: AIzaSyAXYgJha6lO_L4qfWpnhf3KijeKYDhuFzQ FIREBASE_PROJECT_ID: next-firebase-auth-edge-demo FIREBASE_ADMIN_CLIENT_EMAIL: ${{ secrets.FIREBASE_ADMIN_CLIENT_EMAIL }} FIREBASE_ADMIN_PRIVATE_KEY: ${{ secrets.FIREBASE_ADMIN_PRIVATE_KEY }} FIREBASE_AUTH_TENANT_ID: ${{ secrets.FIREBASE_AUTH_TENANT_ID }} FIREBASE_APP_CHECK_KEY: ${{ secrets.FIREBASE_APP_CHECK_KEY }} FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }} concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true on: push: branches: [main, canary] paths: - "**" - "!*.md" - "!**/*.md" - "!.gitignore" jobs: install: name: Install dependencies timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js 22 uses: actions/setup-node@v4 with: cache: "yarn" cache-dependency-path: yarn.lock node-version: 22 - name: Cache node_modules uses: actions/cache@v4 with: path: | node_modules key: ${{ runner.os }}-node_modules-cache-v2-${{ hashFiles('./yarn.lock') }}-${{github.sha}} restore-keys: | ${{ runner.os }}-node_modules-cache-v2-${{ hashFiles('./yarn.lock') }}- - run: yarn install build: name: Build packages timeout-minutes: 15 needs: install runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js 22 uses: actions/setup-node@v4 with: node-version: 22 - name: Cache node_modules uses: actions/cache@v4 with: path: | node_modules key: ${{ runner.os }}-node_modules-cache-v2-${{ hashFiles('./yarn.lock') }}-${{github.sha}} - name: Cache build uses: actions/cache@v4 with: path: | lib browser esm key: ${{ runner.os }}-build-cache-v2-${{ github.ref_name }}-${{github.sha}} restore-keys: | ${{ runner.os }}-build-cache-v2-${{ github.ref_name }}- - run: yarn build semantic-release: if: "!contains(github.event.head_commit.message, '[skip ci]')" name: Semantic Release runs-on: ubuntu-latest needs: - build permissions: contents: write id-token: write steps: - uses: actions/checkout@v4 - name: Use Node.js 22 uses: actions/setup-node@v4 with: node-version: 22 - name: Cache node_modules uses: actions/cache@v4 with: path: | node_modules key: ${{ runner.os }}-node_modules-cache-v2-${{ hashFiles('./yarn.lock') }}-${{github.sha}} - name: Cache build uses: actions/cache@v4 with: path: | lib browser esm key: ${{ runner.os }}-build-cache-v2-${{ github.ref_name }}-${{github.sha}} restore-keys: | ${{ runner.os }}-build-cache-v2-${{ github.ref_name }}- - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npx semantic-release ================================================ FILE: .gitignore ================================================ node_modules .vscode yarn-error.log .yarn .next .env tmp package-lock.json coverage webpack-stats.json .turbo .DS_Store .idea *.log dist .fleet .history #dist lib browser esm cjs ================================================ FILE: .husky/commit-msg ================================================ npx --no -- commitlint --edit "${1}" ================================================ FILE: .husky/pre-commit ================================================ yarn test yarn lint --fix yarn check-circular-imports git add --all ================================================ FILE: .npmignore ================================================ .env .env.dist examples src !lib !browser !esm ================================================ FILE: .releaserc.yaml ================================================ plugins: - "@semantic-release/commit-analyzer" - "@semantic-release/release-notes-generator" - "@semantic-release/changelog" - "@semantic-release/npm" - "@semantic-release/git" - - "@semantic-release/github" - successComment: false failTitle: false branches: - main - name: canary prerelease: true ================================================ FILE: .swcrc ================================================ { "jsc": { "parser": { "syntax": "typescript", "decorators": true }, "transform": { "legacyDecorator": true, "decoratorMetadata": true } } } ================================================ FILE: CHANGELOG.md ================================================ # [1.12.0](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.11.5...v1.12.0) (2026-02-26) ### Bug Fixes * **release:** update canary release after rebase ([f80caf1](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f80caf15dde88da8164bcef3be643a60c4380065)) ### Features * **next:** support Next.js 16 ([7f69f89](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/7f69f893223fdd2297563d408601999890b079b6)) # [1.12.0-canary.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.11.5...v1.12.0-canary.1) (2026-02-26) ### Bug Fixes * **release:** update canary release after rebase ([f80caf1](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f80caf15dde88da8164bcef3be643a60c4380065)) ### Features * **next:** support Next.js 16 ([7f69f89](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/7f69f893223fdd2297563d408601999890b079b6)) ## [1.11.5](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.11.4...v1.11.5) (2026-02-16) ### Bug Fixes * update node version to 22 in github workflows ([ce45f99](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/ce45f99daa167e1871ba3794937ad9f10aafa850)) ## [1.11.4](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.11.3...v1.11.4) (2026-02-16) ### Bug Fixes * another attempt to fix semantic release ([1397c7b](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/1397c7b880a0bc158fcbd79fb034f63c9ac2c1dd)) ## [1.11.3](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.11.2...v1.11.3) (2026-02-16) ### Bug Fixes * insignificant change to trigger and test release flow ([d90764d](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d90764d956cf7c0d8a727afaefb27610cae76859)) * trigger release to test semantic release integration ([e5dd2f0](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/e5dd2f0909920262d53b0ae27765fbe1f336b7ff)) ## [1.11.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.11.1...v1.11.2) (2026-02-16) ### Bug Fixes * upgrade semantic release version to support provenance mode ([a37975b](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/a37975b403791671f10a96d8508fa3d72b7ef903)) * use provenance with semantic release ([d2031e5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d2031e5875606a8c73201174cd52af0546fe612e)) ## [1.11.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.11.0...v1.11.1) (2025-09-14) ### Bug Fixes * remove cookie verification warning ([910808e](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/910808ea03dcf0da986218ae56182bc1f265d17d)) # [1.11.0](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.10.1...v1.11.0) (2025-08-21) ### Bug Fixes * **refresh-token:** respond with 401: Unauthorized when verify fails with InvalidTokenError ([cf84cec](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/cf84cecf77ae14bbadf5c1441abe74a2e0c65b58)) ### Features * **enableTokenRefreshOnExpiredKidHeader:** token refresh on expired kid is no longer experimental ([6800605](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6800605360aae0d45680c8252ad432f453d1c4f5)) * **refresh-token:** handle 401: Unauthorized in getValidIdToken and getValidCustomToken ([d96d89f](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d96d89f3be6edbcd0d10174017e62aea886fbd33)) ## [1.10.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.10.0...v1.10.1) (2025-08-18) ### Bug Fixes * pass dynamicCustomClaimsKeys in refreshCookiesWithIdToken ([cbae23f](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/cbae23f461b84fc1642805f1602bb81f99b4dc63)) # [1.10.0](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.9.1...v1.10.0) (2025-08-11) ### Bug Fixes * allow to use `/` as private path ([00eaeca](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/00eaeca9b2ea73a736fab5d2ba60f9f22a764f2b)) * encode redirect param ([2afeef3](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/2afeef38e0fab0214b0c06da2b258871c909414f)) ### Features * **middleware:** redirectToLogin supports privatePaths ([1a5450c](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/1a5450cade9266275c036b23752b66f0327d4669)) * exposing token-verifier for public use ([d4a3796](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d4a379692f41376608ecf4b865ed249f07daffd1)) * update firebase, firebase-admin and react dependencies ([41b94f7](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/41b94f7b16a9012b3707bf6942dee75eea1ea5bc)) * **metadata:** added metadata support ([9dda6dc](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/9dda6dc8ea88291b286cb6a6be47899f0e647d90)) * **metadata:** clear metadata cookies after logout ([d148629](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d148629daa226314fcd994ffafe094fc226b890a)) * **metadata:** improved getTokens warning readability ([6945e55](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6945e55f4b6157d5d46799a07d0bb437954b594a)) * **metadata:** integrate metadata with starter example ([5ed8cf3](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/5ed8cf3880bf15fd153dcd3c00cbe44faeeb7145)) * **metadata:** update types with getMetadata method ([5d9dbaa](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/5d9dbaa4f692dc76b667197e3f20ed7a83d10310)) # [1.10.0-canary.7](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.10.0-canary.6...v1.10.0-canary.7) (2025-08-11) ### Bug Fixes * allow to use `/` as private path ([00eaeca](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/00eaeca9b2ea73a736fab5d2ba60f9f22a764f2b)) # [1.10.0-canary.6](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.10.0-canary.5...v1.10.0-canary.6) (2025-08-11) ### Features * **middleware:** redirectToLogin supports privatePaths ([1a5450c](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/1a5450cade9266275c036b23752b66f0327d4669)) # [1.10.0-canary.5](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.10.0-canary.4...v1.10.0-canary.5) (2025-08-11) ### Features * update firebase, firebase-admin and react dependencies ([41b94f7](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/41b94f7b16a9012b3707bf6942dee75eea1ea5bc)) # [1.10.0-canary.4](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.10.0-canary.3...v1.10.0-canary.4) (2025-05-29) ### Bug Fixes * encode redirect param ([2afeef3](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/2afeef38e0fab0214b0c06da2b258871c909414f)) # [1.10.0-canary.3](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.10.0-canary.2...v1.10.0-canary.3) (2025-04-08) ### Features * exposing token-verifier for public use ([d4a3796](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d4a379692f41376608ecf4b865ed249f07daffd1)) # [1.10.0-canary.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.10.0-canary.1...v1.10.0-canary.2) (2025-03-11) ### Features * **metadata:** clear metadata cookies after logout ([d148629](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d148629daa226314fcd994ffafe094fc226b890a)) # [1.10.0-canary.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.9.1...v1.10.0-canary.1) (2025-03-11) ### Features * **metadata:** added metadata support ([9dda6dc](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/9dda6dc8ea88291b286cb6a6be47899f0e647d90)) * **metadata:** improved getTokens warning readability ([6945e55](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6945e55f4b6157d5d46799a07d0bb437954b594a)) * **metadata:** integrate metadata with starter example ([5ed8cf3](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/5ed8cf3880bf15fd153dcd3c00cbe44faeeb7145)) * **metadata:** update types with getMetadata method ([5d9dbaa](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/5d9dbaa4f692dc76b667197e3f20ed7a83d10310)) ## [1.9.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.9.0...v1.9.1) (2025-02-18) ### Bug Fixes * **dynamic-custom-claims:** allow to update claims after token refresh ([475e767](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/475e76709ece48294746b334522c0c16dbc5ce6d)) # [1.9.0](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2...v1.9.0) (2025-02-18) ### Bug Fixes * **#297:** propagate custom claims when exchanging id token for custom, id and refresh tokens ([55254b8](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/55254b87196c9fb7f16c36785131b34edd3b219e)), closes [#297](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/297) * **#303:** support npm 11 ([88328e5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/88328e51abfebf2eef63895b37d91784a0e982ce)), closes [#303](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/303) * **#306:** support Node.js 23 ([f27d210](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f27d21023b8f0120fd7ddfd17e9c9d42b2a28f31)), closes [#306](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/306) * return cached token or server token ([c1a04a9](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/c1a04a96f12aea574aa5c44b2d41a053bf746f6c)) * return cached valid token ([a73f9ec](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/a73f9ecd04860c72613664c3ab857fe4efd46954)) ### Features * **#300:** added removeServerCookies method to logout from Server Actions ([cab2d23](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/cab2d238012ed7fb20cdbb09da7e69eab3867c14)), closes [#300](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/300) * full firebase emulator support ([9dcf5e9](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/9dcf5e94be548b0f3bb0277bee6abce43592a7d2)) # [1.9.0-canary.6](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.9.0-canary.5...v1.9.0-canary.6) (2025-01-28) ### Bug Fixes * **#306:** support Node.js 23 ([f27d210](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f27d21023b8f0120fd7ddfd17e9c9d42b2a28f31)), closes [#306](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/306) # [1.9.0-canary.5](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.9.0-canary.4...v1.9.0-canary.5) (2025-01-23) ### Bug Fixes * **#303:** support npm 11 ([88328e5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/88328e51abfebf2eef63895b37d91784a0e982ce)), closes [#303](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/303) # [1.9.0-canary.4](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.9.0-canary.3...v1.9.0-canary.4) (2025-01-22) ### Features * **#300:** added removeServerCookies method to logout from Server Actions ([cab2d23](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/cab2d238012ed7fb20cdbb09da7e69eab3867c14)), closes [#300](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/300) # [1.9.0-canary.3](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.9.0-canary.2...v1.9.0-canary.3) (2025-01-21) ### Bug Fixes * **#297:** propagate custom claims when exchanging id token for custom, id and refresh tokens ([55254b8](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/55254b87196c9fb7f16c36785131b34edd3b219e)), closes [#297](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/297) # [1.9.0-canary.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.9.0-canary.1...v1.9.0-canary.2) (2024-12-16) ### Bug Fixes * return cached token or server token ([c1a04a9](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/c1a04a96f12aea574aa5c44b2d41a053bf746f6c)) * return cached valid token ([a73f9ec](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/a73f9ecd04860c72613664c3ab857fe4efd46954)) # [1.9.0-canary.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2...v1.9.0-canary.1) (2024-11-15) ### Features * full firebase emulator support ([9dcf5e9](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/9dcf5e94be548b0f3bb0277bee6abce43592a7d2)) ## [1.8.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.1...v1.8.2) (2024-11-07) ### Bug Fixes * **docs:** added `await` before calling `cookies` and `headers` due to change in Next.js 15 ([d14c9df](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d14c9df55aa3476ea30d56b884599f4e8af9e3ff)) * add logs to invalid token comparator func ([11eaede](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/11eaedee236b4cf93f5b2542ae10eebbe0c86884)) * added additional logs around cookie parser ([1550c80](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/1550c80e504e7e49ff44a89dd20c59c2878b6dbc)) * added additional logs to debug a failed verification in auth middleware ([30ddc5e](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/30ddc5e6f4cb7c7d713cc210d77648d8722d924c)) * await on parse cookie result to work around [#271](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/271) ([f6b5106](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f6b51062b4308e32d1ef1d123912d21dc93d3f85)) * debug Vercel logging by removing inheritance from Error ([46ca356](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/46ca35632f46d92dc4f0229552c09e4f455fee58)) * export error module explicitly ([575281c](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/575281c45e037eccdf48c833ae605cd373388896)) * remove console.log and improve debug logs around token fetching ([31dfbd2](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/31dfbd2226dcd775bd4a76cdb4a5c5f04f72954e)) * remove debug logs from cookie parser ([2ce3190](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/2ce3190cbffa476fd228f6cc1bf578cae6c8591f)) * remove unnecessary async in get tokens functions ([c0f530c](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/c0f530c3ecfe9c0e120f20d4a9e3bcab264a28db)) * work around [#271](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/271) in getCookiesTokens ([5fef799](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/5fef799648daa2a0fcf20829dd82b443b9f511e7)) * **#271:** use runtime flag to identify invalid token error ([d7220b0](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d7220b0e1dd642385d3320efd812b2e08117e51e)), closes [#271](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/271) ## [1.8.2-canary.11](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2-canary.10...v1.8.2-canary.11) (2024-11-07) ### Bug Fixes * **docs:** added `await` before calling `cookies` and `headers` due to change in Next.js 15 ([d14c9df](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d14c9df55aa3476ea30d56b884599f4e8af9e3ff)) ## [1.8.2-canary.10](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2-canary.9...v1.8.2-canary.10) (2024-11-06) ### Bug Fixes * remove unnecessary async in get tokens functions ([c0f530c](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/c0f530c3ecfe9c0e120f20d4a9e3bcab264a28db)) * work around [#271](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/271) in getCookiesTokens ([5fef799](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/5fef799648daa2a0fcf20829dd82b443b9f511e7)) ## [1.8.2-canary.9](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2-canary.8...v1.8.2-canary.9) (2024-11-06) ### Bug Fixes * await on parse cookie result to work around [#271](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/271) ([f6b5106](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f6b51062b4308e32d1ef1d123912d21dc93d3f85)) ## [1.8.2-canary.8](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2-canary.7...v1.8.2-canary.8) (2024-11-06) ### Bug Fixes * remove debug logs from cookie parser ([2ce3190](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/2ce3190cbffa476fd228f6cc1bf578cae6c8591f)) ## [1.8.2-canary.7](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2-canary.6...v1.8.2-canary.7) (2024-11-06) ### Bug Fixes * added additional logs around cookie parser ([1550c80](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/1550c80e504e7e49ff44a89dd20c59c2878b6dbc)) ## [1.8.2-canary.6](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2-canary.5...v1.8.2-canary.6) (2024-11-06) ### Bug Fixes * debug Vercel logging by removing inheritance from Error ([46ca356](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/46ca35632f46d92dc4f0229552c09e4f455fee58)) ## [1.8.2-canary.5](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2-canary.4...v1.8.2-canary.5) (2024-11-06) ### Bug Fixes * remove console.log and improve debug logs around token fetching ([31dfbd2](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/31dfbd2226dcd775bd4a76cdb4a5c5f04f72954e)) ## [1.8.2-canary.4](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2-canary.3...v1.8.2-canary.4) (2024-11-06) ### Bug Fixes * add logs to invalid token comparator func ([11eaede](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/11eaedee236b4cf93f5b2542ae10eebbe0c86884)) ## [1.8.2-canary.3](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2-canary.2...v1.8.2-canary.3) (2024-11-06) ### Bug Fixes * **#271:** use runtime flag to identify invalid token error ([d7220b0](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d7220b0e1dd642385d3320efd812b2e08117e51e)), closes [#271](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/271) ## [1.8.2-canary.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.2-canary.1...v1.8.2-canary.2) (2024-11-06) ### Bug Fixes * export error module explicitly ([575281c](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/575281c45e037eccdf48c833ae605cd373388896)) ## [1.8.2-canary.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.1...v1.8.2-canary.1) (2024-11-06) ### Bug Fixes * added additional logs to debug a failed verification in auth middleware ([30ddc5e](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/30ddc5e6f4cb7c7d713cc210d77648d8722d924c)) ## [1.8.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.0...v1.8.1) (2024-11-05) ### Bug Fixes * update cookie library to avoid vulnerability in cookie < 0.7.0 ([0940e28](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/0940e2875a3f8d0b9769317914df074d70caa741)) # [1.8.0](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.1...v1.8.0) (2024-10-28) ### Bug Fixes * added circular import validation ([deaa2e3](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/deaa2e393b4e7630090d0dce96ed9e7e6b7fa8e0)) * automated release build cache ([b6abf5a](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/b6abf5aea77efdfb3827b18864fe0d08c8a2f7e6)) * create request cookies provider from cloned headers ([d17c376](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d17c3762807ccba180116612c0463dec4357b0e9)) * include missing directories in package.json exports ([668ae8b](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/668ae8bc4dc55b05623de71e97a2faf1eb417928)) * remove declarations from esm build ([025e4c8](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/025e4c8a501201618df4fb77a6532d22aed9ddaf)) ### Features * make custom token optional ([4a18cb7](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/4a18cb7ed6aca3e8e72819437939fae1bc9eeffc)) * refactor cookies to separate multiple from single type ([9aba786](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/9aba786f04ab20c8e57e1daeacab670d59c770f0)) * support esm, commonjs and browser build targets ([93a17bd](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/93a17bde917817d7191cfaf5bce4f17a836c454b)) * validate tenantId when verifying id token ([798d0f1](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/798d0f1037b387a691284b22f6a092dfbfd0d156)) # [1.8.0-canary.9](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.0-canary.8...v1.8.0-canary.9) (2024-10-09) ### Features * make custom token optional ([4a18cb7](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/4a18cb7ed6aca3e8e72819437939fae1bc9eeffc)) # [1.8.0-canary.8](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.0-canary.7...v1.8.0-canary.8) (2024-09-30) ### Bug Fixes * create request cookies provider from cloned headers ([d17c376](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d17c3762807ccba180116612c0463dec4357b0e9)) # [1.8.0-canary.7](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.0-canary.6...v1.8.0-canary.7) (2024-09-30) ### Bug Fixes * added circular import validation ([deaa2e3](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/deaa2e393b4e7630090d0dce96ed9e7e6b7fa8e0)) # [1.8.0-canary.6](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.0-canary.5...v1.8.0-canary.6) (2024-09-29) ### Features * refactor cookies to separate multiple from single type ([9aba786](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/9aba786f04ab20c8e57e1daeacab670d59c770f0)) # [1.8.0-canary.5](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.0-canary.4...v1.8.0-canary.5) (2024-09-22) ### Bug Fixes * include missing directories in package.json exports ([668ae8b](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/668ae8bc4dc55b05623de71e97a2faf1eb417928)) # [1.8.0-canary.4](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.0-canary.3...v1.8.0-canary.4) (2024-09-22) ### Bug Fixes * automated release build cache ([b6abf5a](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/b6abf5aea77efdfb3827b18864fe0d08c8a2f7e6)) # [1.8.0-canary.3](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.0-canary.2...v1.8.0-canary.3) (2024-09-22) ### Bug Fixes * remove declarations from esm build ([025e4c8](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/025e4c8a501201618df4fb77a6532d22aed9ddaf)) # [1.8.0-canary.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.8.0-canary.1...v1.8.0-canary.2) (2024-09-22) ### Features * support esm, commonjs and browser build targets ([93a17bd](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/93a17bde917817d7191cfaf5bce4f17a836c454b)) # [1.8.0-canary.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.1...v1.8.0-canary.1) (2024-09-21) ### Features * validate tenantId when verifying id token ([798d0f1](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/798d0f1037b387a691284b22f6a092dfbfd0d156)) ## [1.7.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0...v1.7.1) (2024-09-13) ### Bug Fixes * handle switch from multiple to single cookie ([9b18bd5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/9b18bd58ed0b765c19727d8aaf8f1f45299623d0)) # [1.7.0](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.2...v1.7.0) (2024-09-09) ### Bug Fixes * add debug logs for experimental feature ([41ef1df](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/41ef1dfcf6fe23a7dabfa4e8d3cc5e2c1172b31e)) * **#242:** use TextEncoder when mapping token to UInt8Array ([23b04dc](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/23b04dcd8867fd7c6b108c41496cb19930e5cc16)), closes [#242](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/242) * **#246:** re-throw invalid PKCS8 error as AuthError with user-friendly message ([a7d7a22](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/a7d7a228733e67525b001cff70a523880d858e01)), closes [#246](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/246) * **#249:** merge error stack trace in token verifier to improve visibility on fetch errors ([6bce756](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6bce7564216dff60fe736ef85e8508d2df686eaf)), closes [#249](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/249) * add missing name property to decoded id token type ([39b086d](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/39b086db222f619a8b4cf0365895f33c6832e3fc)) * pass cookie serialization options to cookie setter ([b28ce7a](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/b28ce7a866318f958e58b14e4adfcc85a47e5bef)) * recreate canary tags after force push ([c9b7c18](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/c9b7c18e5cb4f8a31e5388e0bfd23665e8b5674e)) * semantic-release rate exceeded error ([676b602](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/676b6021a013c0afdddd75a0cea71b2a8b4786e2)) * semantic-version git history issue ([d514f57](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d514f5713883e1713f265b07a4670518af646a6b)) * update next.js peer dependency to rc ([f2953fd](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f2953fd38bdd6df9b4b535a21abb47793249752b)) ### Features * **middleware:** introduced `redirectToPath` method and RegExp support in `redirectToLogin` method ([21024bb](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/21024bb02f6f0300301e7822751e047caef745c0)) * added `path` option to `redirectToHome` helper function ([54f07f4](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/54f07f4a09fad3e46fc089e5d762afa4df5eb1f5)) * allow setAuthCookies to accept custom auth headers or fall back ([b1d169b](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/b1d169b13d1c6132799aed23ef1c6da3698ba080)) * experimental option to refresh token on expired kid header ([2869531](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/28695315164fffee7b3a08879e95033c44b8a197)) * introduced `refreshCookiesWithIdToken` function to enable login using Server Actions ([#212](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/212)) ([6cd0b13](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6cd0b138036ff0f4fcfa91d786fca5255cfa2654)) * next.js 15 rc support ([a994dd0](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/a994dd07bce5420049573b2651b08ecb1a82b63c)) * pass custom auth header from authMiddleware ([71286af](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/71286afe6c7faebf2cdcd568e507a5e0739720f0)) * **getTokens:** introduced optional `cookieSerializeOptions` option ([e041542](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/e041542c6b2f4380fcc7f803f7e1c8d5c14bc6e1)) * replaced no matching kid auth error with invalid token error ([9d2d0fc](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/9d2d0fcb49374d0bb6b260c43d8a2409377b0144)) * support Node.js 22 ([6c7f435](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6c7f435485391a4d987f0bc3d0653536d4ef93ff)) # [1.7.0-canary.17](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.16...v1.7.0-canary.17) (2024-09-07) ### Features * **middleware:** introduced `redirectToPath` method and RegExp support in `redirectToLogin` method ([21024bb](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/21024bb02f6f0300301e7822751e047caef745c0)) # [1.7.0-canary.16](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.15...v1.7.0-canary.16) (2024-09-06) ### Features * allow setAuthCookies to accept custom auth headers or fall back ([b1d169b](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/b1d169b13d1c6132799aed23ef1c6da3698ba080)) * pass custom auth header from authMiddleware ([71286af](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/71286afe6c7faebf2cdcd568e507a5e0739720f0)) # [1.7.0-canary.15](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.14...v1.7.0-canary.15) (2024-09-06) ### Bug Fixes * add debug logs for experimental feature ([41ef1df](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/41ef1dfcf6fe23a7dabfa4e8d3cc5e2c1172b31e)) # [1.7.0-canary.14](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.13...v1.7.0-canary.14) (2024-09-06) ### Features * experimental option to refresh token on expired kid header ([2869531](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/28695315164fffee7b3a08879e95033c44b8a197)) # [1.7.0-canary.13](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.12...v1.7.0-canary.13) (2024-09-03) ### Bug Fixes * **#249:** merge error stack trace in token verifier to improve visibility on fetch errors ([6bce756](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6bce7564216dff60fe736ef85e8508d2df686eaf)), closes [#249](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/249) # [1.7.0-canary.12](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.11...v1.7.0-canary.12) (2024-09-03) ### Bug Fixes * **#242:** use TextEncoder when mapping token to UInt8Array ([23b04dc](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/23b04dcd8867fd7c6b108c41496cb19930e5cc16)), closes [#242](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/242) # [1.7.0-canary.11](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.10...v1.7.0-canary.11) (2024-08-30) ### Bug Fixes * **#246:** re-throw invalid PKCS8 error as AuthError with user-friendly message ([a7d7a22](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/a7d7a228733e67525b001cff70a523880d858e01)), closes [#246](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/246) # [1.7.0-canary.10](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.9...v1.7.0-canary.10) (2024-08-22) ### Features * **getTokens:** introduced optional `cookieSerializeOptions` option ([e041542](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/e041542c6b2f4380fcc7f803f7e1c8d5c14bc6e1)) # [1.7.0-canary.9](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.8...v1.7.0-canary.9) (2024-08-21) ### Bug Fixes * pass cookie serialization options to cookie setter ([b28ce7a](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/b28ce7a866318f958e58b14e4adfcc85a47e5bef)) # [1.7.0-canary.8](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.7...v1.7.0-canary.8) (2024-08-21) ### Features * replaced no matching kid auth error with invalid token error ([9d2d0fc](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/9d2d0fcb49374d0bb6b260c43d8a2409377b0144)) # [1.7.0-canary.7](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.6...v1.7.0-canary.7) (2024-08-21) ### Features * support Node.js 22 ([6c7f435](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6c7f435485391a4d987f0bc3d0653536d4ef93ff)) # [1.7.0-canary.6](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.5...v1.7.0-canary.6) (2024-08-10) ### Bug Fixes * semantic-release rate exceeded error ([676b602](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/676b6021a013c0afdddd75a0cea71b2a8b4786e2)) # [1.7.0-canary.5](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.4...v1.7.0-canary.5) (2024-08-10) ### Bug Fixes * update next.js peer dependency to rc ([f2953fd](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f2953fd38bdd6df9b4b535a21abb47793249752b)) # [1.7.0-canary.4](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.3...v1.7.0-canary.4) (2024-08-10) ### Bug Fixes * add missing name property to decoded id token type ([39b086d](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/39b086db222f619a8b4cf0365895f33c6832e3fc)) ### Features * next.js 15 rc support ([a994dd0](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/a994dd07bce5420049573b2651b08ecb1a82b63c)) # [1.7.0-canary.3](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.7.0-canary.2...v1.7.0-canary.3) (2024-08-08) ### Bug Fixes * recreate canary tags after force push ([c9b7c18](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/c9b7c18e5cb4f8a31e5388e0bfd23665e8b5674e)) * semantic-version git history issue ([d514f57](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d514f5713883e1713f265b07a4670518af646a6b)) # [1.7.0-canary.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.2...v1.7.0-canary.2) (2024-07-25) ### Features * added `path` option to `redirectToHome` helper function ([54f07f4](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/54f07f4a09fad3e46fc089e5d762afa4df5eb1f5)) # [1.7.0-canary.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.1...v1.7.0-canary.1) (2024-07-16) ### Features * introduced `refreshCookiesWithIdToken` function to enable login using Server Actions ([#212](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/212)) ([fd6b193](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/fd6b193d345af85e7cca502640b98e2c93aebadc)) ## [1.6.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.1...v1.6.2) (2024-07-16) ### Bug Fixes * fix `JWSInvalid: Invalid Compact JWS` error when migrating between token formats ([#214](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/214)) ([5b6b0c3](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/5b6b0c3c0eeb62e1f28c7e48c73ad93bee3c0bbc)) ## [1.6.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.0...v1.6.1) (2024-07-15) ### Bug Fixes * rename appendEmptyResponseHeaders to removeCookies ([498d044](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/498d0443b7981776cc7091049ac83a92a4d8d81b)) # [1.6.0](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.5.3...v1.6.0) (2024-07-15) ### Bug Fixes * enable refresh token route ([d081c22](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d081c22f67bdde49211ac6053011901c616f99d6)) * fix "process is not defined" error in cloudflare worker [#192](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/192) ([6a94587](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6a9458774da1ec8a026a223ffd9204eb5c11915f)) * return null from getValidIdToken if provided server token is empty ([613f230](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/613f230504e30e8329eb1c1be008fadbf4347c96)) * store latest valid id token on client ([5764a33](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/5764a33ae8cadff6e48f5e7cb6d31e977e4d8ab9)) * suppress unknown headers property error ([1459ba9](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/1459ba99703ba7a6b3e9f10f59304d0974ccc652)) ### Features * added `getValidCustomToken` method and documented client-side SDK usage ([2261ef9](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/2261ef9321a0e3974456af2db11915a128d69421)) * exposed customToken in handleValidToken, getTokens and getFirebaseAuth methods ([f95c34c](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f95c34cafb5f87b3afe60130a1631e3c337f2d34)) * introduced `enableMultipleCookies` auth middleware option to increase token capacity ([23ee02f](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/23ee02f2160faee133127dfb8808b1977dba4593)) * introduced refreshTokenPath middleware option and getValidIdToken client method ([56e07c5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/56e07c59cc9b6da45fd818c0600638bb9258bafa)) * introduced removeCookie method ([f108984](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f108984a9c74ed8cf2cf26133a8f3f8f65c905f9)) * support for async response factory in refreshCredentials method ([25bf5c4](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/25bf5c46f68bc0f8cdd6cfd480802f3d23922a4d)) # [1.6.0-canary.9](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.0-canary.8...v1.6.0-canary.9) (2024-07-14) ### Features * introduced `enableMultipleCookies` auth middleware option to increase token capacity ([23ee02f](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/23ee02f2160faee133127dfb8808b1977dba4593)) # [1.6.0-canary.8](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.0-canary.7...v1.6.0-canary.8) (2024-07-14) ### Features * added `getValidCustomToken` method and documented client-side SDK usage ([2261ef9](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/2261ef9321a0e3974456af2db11915a128d69421)) # [1.6.0-canary.7](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.0-canary.6...v1.6.0-canary.7) (2024-07-07) ### Bug Fixes * suppress unknown headers property error ([1459ba9](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/1459ba99703ba7a6b3e9f10f59304d0974ccc652)) ### Features * exposed customToken in handleValidToken, getTokens and getFirebaseAuth methods ([f95c34c](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f95c34cafb5f87b3afe60130a1631e3c337f2d34)) # [1.6.0-canary.6](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.0-canary.5...v1.6.0-canary.6) (2024-06-17) ### Bug Fixes * return null from getValidIdToken if provided server token is empty ([613f230](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/613f230504e30e8329eb1c1be008fadbf4347c96)) # [1.6.0-canary.5](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.0-canary.4...v1.6.0-canary.5) (2024-06-15) ### Bug Fixes * store latest valid id token on client ([5764a33](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/5764a33ae8cadff6e48f5e7cb6d31e977e4d8ab9)) # [1.6.0-canary.4](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.0-canary.3...v1.6.0-canary.4) (2024-06-15) ### Bug Fixes * enable refresh token route ([d081c22](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/d081c22f67bdde49211ac6053011901c616f99d6)) # [1.6.0-canary.3](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.0-canary.2...v1.6.0-canary.3) (2024-06-15) ### Features * introduced refreshTokenPath middleware option and getValidIdToken client method ([56e07c5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/56e07c59cc9b6da45fd818c0600638bb9258bafa)) # [1.6.0-canary.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.6.0-canary.1...v1.6.0-canary.2) (2024-06-05) ### Features * introduced removeCookie method ([f108984](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f108984a9c74ed8cf2cf26133a8f3f8f65c905f9)) # [1.6.0-canary.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.5.4-canary.1...v1.6.0-canary.1) (2024-06-05) ### Features * support for async response factory in refreshCredentials method ([25bf5c4](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/25bf5c46f68bc0f8cdd6cfd480802f3d23922a4d)) ## [1.5.4-canary.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.5.3...v1.5.4-canary.1) (2024-06-01) ### Bug Fixes * fix "process is not defined" error in cloudflare worker [#192](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/192) ([6a94587](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6a9458774da1ec8a026a223ffd9204eb5c11915f)) ## [1.5.3](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.5.2...v1.5.3) (2024-05-31) ### Bug Fixes * referer is now based on caller host ([2f75386](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/2f75386de3d91aea42345771c006221eff819104)) ## [1.5.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.5.1...v1.5.2) (2024-05-30) ### Bug Fixes * expose tokens in refreshCredentials response factory callback ([644b8a2](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/644b8a272cb48e830d21344f12bae9e3082ae1f4)) ## [1.5.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.5.0...v1.5.1) (2024-05-30) ### Bug Fixes * reintroduce refreshAuthCookies as refreshNextResponseCookiesWithToken ([620f986](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/620f98682b9002837bfca287d32ea0371f2b2017)) # [1.5.0](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.4.5...v1.5.0) (2024-05-30) ### Bug Fixes * remove fetch `cache: no-store` due to https://github.com/awinogrodzki/next-firebase-auth-edge/issues/173 ([6fb8143](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6fb81430b580b586f5a27c5b36624a441aa68e82)) ### Features * added refreshCredentials method that allows to pass modified request headers to NextResponse constructor ([2bf2877](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/2bf2877f5b12456c5e8125d5fa1babfc0074edaf)) * extract referer from Next.js request headers ([bc666fa](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/bc666fa887b81adbf91681faa7d1974417b20988)) * introduced Firebase API Key domain restriction support. Introduced changes to advanced methods and removed APIs deprecated in 1.0 ([67dbb9a](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/67dbb9a2908d62d90fb40a5a154cd2a7d8b14626)) ### Performance Improvements * **refreshCredentials:** slightly improve performance by generating signed tokens only once ([da2fc3e](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/da2fc3e164da0d5015e4d484813cafce2f033ea2)) # [1.5.0-canary.5](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.5.0-canary.4...v1.5.0-canary.5) (2024-05-30) ### Features * extract referer from Next.js request headers ([bc666fa](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/bc666fa887b81adbf91681faa7d1974417b20988)) # [1.5.0-canary.4](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.5.0-canary.3...v1.5.0-canary.4) (2024-05-27) ### Performance Improvements * **refreshCredentials:** slightly improve performance by generating signed tokens only once ([da2fc3e](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/da2fc3e164da0d5015e4d484813cafce2f033ea2)) # [1.5.0-canary.3](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.5.0-canary.2...v1.5.0-canary.3) (2024-05-27) ### Features * added refreshCredentials method that allows to pass modified request headers to NextResponse constructor ([2bf2877](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/2bf2877f5b12456c5e8125d5fa1babfc0074edaf)) # [1.5.0-canary.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.5.0-canary.1...v1.5.0-canary.2) (2024-05-26) ### Bug Fixes * remove fetch `cache: no-store` due to https://github.com/awinogrodzki/next-firebase-auth-edge/issues/173 ([6fb8143](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/6fb81430b580b586f5a27c5b36624a441aa68e82)) # [1.5.0-canary.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.4.5...v1.5.0-canary.1) (2024-05-26) ### Features * introduced Firebase API Key domain restriction support. Introduced changes to advanced methods and removed APIs deprecated in 1.0 ([67dbb9a](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/67dbb9a2908d62d90fb40a5a154cd2a7d8b14626)) ## [1.4.5](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.4.4...v1.4.5) (2024-05-26) ### Bug Fixes * /api/login endpoint now fails with 400: Missing Token error when called without credentials ([2997fc5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/2997fc51c503400fb9068750374797993f4a61d8)) * exclude lib folder from npmignore file ([f7ef2d5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f7ef2d5249d7183f3f1204a34c540e03392943a4)) * fix build cache path in github workflows ([df4c98d](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/df4c98dfe7176029743a04513aa5b67c60a453a3)) * remove .env.dist from npm package ([5c136f9](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/5c136f9e2e7d2f3bc0427f21a91c7ff36a87d0d0)) * remove tests and lint steps from semantic release pipeline ([160662d](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/160662d53077e7cfdd69f194eb1d89e31a7e8d55)) * semantic release npm publish initialization ([3ed6ef5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/3ed6ef591ced2613d3936aea7dd28140605ca167)) * semantic release package configuration ([ec93cc6](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/ec93cc67ed5a8a2a624cef526de88d7601829aec)) * set correct pkgRoot in semantic releases configuration ([9c36948](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/9c3694839088fee50e1362537cc7ad3e345d7763)) ## [1.4.5-canary.7](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.4.5-canary.6...v1.4.5-canary.7) (2024-05-26) ### Bug Fixes * fix build cache path in github workflows ([df4c98d](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/df4c98dfe7176029743a04513aa5b67c60a453a3)) ## [1.4.5-canary.6](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.4.5-canary.5...v1.4.5-canary.6) (2024-05-26) ### Bug Fixes * exclude lib folder from npmignore file ([f7ef2d5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/f7ef2d5249d7183f3f1204a34c540e03392943a4)) ## [1.4.5-canary.5](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.4.5-canary.4...v1.4.5-canary.5) (2024-05-26) ### Bug Fixes * remove tests and lint steps from semantic release pipeline ([160662d](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/160662d53077e7cfdd69f194eb1d89e31a7e8d55)) ## [1.4.5-canary.4](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.4.5-canary.3...v1.4.5-canary.4) (2024-05-26) ### Bug Fixes * set correct pkgRoot in semantic releases configuration ([9c36948](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/9c3694839088fee50e1362537cc7ad3e345d7763)) ## [1.4.5-canary.2](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.4.5-canary.1...v1.4.5-canary.2) (2024-05-26) ### Bug Fixes * remove .env.dist from npm package ([5c136f9](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/5c136f9e2e7d2f3bc0427f21a91c7ff36a87d0d0)) ## [1.4.5-canary.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.4.4...v1.4.5-canary.1) (2024-05-26) ### Bug Fixes * /api/login endpoint now fails with 400: Missing Token error when called without credentials ([2997fc5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/2997fc51c503400fb9068750374797993f4a61d8)) * semantic release npm publish initialization ([3ed6ef5](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/3ed6ef591ced2613d3936aea7dd28140605ca167)) ## [1.4.4](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.4.3...v1.4.4) (2024-05-26) ### Bug Fixes * disable default tag behavior in yarn publish ([1661468](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/1661468a501ad759ac55ce66d3eb0c8bab496b13)) * lint ([c703cfb](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/c703cfb9a4c5afc67165366fd1bcaa3651c67a73)) * semantic release publish step authorization ([232f624](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/232f6244e0126b0112cc4a0255780b070049910d)) * semantic release publish step git author ([c917de4](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/c917de4227f432e0aeefdcdc1fd6b38a0d79d7bf)) ## [1.4.4-canary.1](https://github.com/awinogrodzki/next-firebase-auth-edge/compare/v1.4.3...v1.4.4-canary.1) (2024-05-26) ### Bug Fixes * disable default tag behavior in yarn publish ([1661468](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/1661468a501ad759ac55ce66d3eb0c8bab496b13)) * lint ([c703cfb](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/c703cfb9a4c5afc67165366fd1bcaa3651c67a73)) * semantic release publish step authorization ([232f624](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/232f6244e0126b0112cc4a0255780b070049910d)) * semantic release publish step git author ([c917de4](https://github.com/awinogrodzki/next-firebase-auth-edge/commit/c917de4227f432e0aeefdcdc1fd6b38a0d79d7bf)) ## 1.4.3 ### Patch Changes - Remove digest from debug logs ## 1.4.2 ### Patch Changes - Fetch Google public keys with cache: "no-store" to fix #159 ## 1.4.1 ### Patch Changes - Improve cookieSignatureKeys input validation ## 1.4.0 ### Minor Changes - `handleInvalidToken` is now called with `InvalidTokenReason` as the first argument. It gives developers more inslight and control over authentication flow ## 1.3.0 ### Minor Changes - The library now stores tokens and signature in a single cookie, allowing to run in Firebase Hosting environment - Use the library without service account in authenticated Google Cloud Run environment - Added debug mode option ## 1.2.0 ### Minor Changes - Introduced refreshServerCookies method to refresh credentials from inside Server Actions ## 1.1.0 ### Minor Changes - Deprecated refreshAuthCookies methods in favor of refreshNextResponseCookies and refreshApiResponseCookies ## 1.0.1 ### Patch Changes - Update middleware token verification caching doc link ## 1.0.0 ### Major Changes - Reworked APIs ## 0.11.2 ### Patch Changes - Added getUserByEmail method ## 0.11.1 ### Patch Changes - Added Node.js 20 support ## 0.11.0 ### Patch Changes - Added App Check support ## 0.10.2 ### Patch Changes - Stop displaying middleware verification cache warning on prefetched routes ## 0.10.1 ### Patch Changes - Remove internal verification cookie on middleware request instead throwing an error - Remove internal verification cookie on middleware request instead of throwing an error ## 0.10.0 ### Minor Changes - Next.js 14 support ## 0.9.5 ### Patch Changes - Skip response headers validation on redirect ## 0.9.4 ### Patch Changes - Add list users function support ## 0.9.3 ### Patch Changes - 964c04c: Check if the FIREBASE_AUTH_EMULATOR_HOST has already http:// added to it, otherwise you will get a cryptic fetch failed error. ## 0.9.2 ### Patch Changes - Support tenantId in refreshAuthCookies ## 0.9.1 ### Patch Changes - Return null if user was deleted from Firebase ## 0.9.0 ### Minor Changes - Added middleware token verification caching ## 0.8.8 ### Patch Changes - Add support for specifying tenantId in middleware ## 0.8.7 ### Patch Changes - Convert signature key to UInt8Array directly instead using base64url.decode due to #92 ## 0.8.6 ### Patch Changes - Throw user friendly error on no matching kid in public keys response ## 0.8.5 ### Patch Changes - Revalidate token against all public keys if kid is missing ## 0.8.4 ### Patch Changes - Fix https://github.com/awinogrodzki/next-firebase-auth-edge/issues/90 by validating token against all returned public keys in case of not matching kid header ## 0.8.3 ### Patch Changes - Fix no "kid" claim in idToken error when using emulator ## 0.8.2 ### Patch Changes - Added createUser and updateUser methods ## 0.8.1 ### Patch Changes - Remove 'cache: no-store' header from refreshExpiredIdToken ## 0.8.0 ### Minor Changes - Refactor: remove custom JSON Web Token and Signature implementation in favor of jose ## 0.7.7 ### Patch Changes - Fix Node.js 18.17 native WebCrypto ArrayBuffer compatibility issue ## 0.7.6 ### Patch Changes - Import Next.js request cookie interfaces as type ## 0.7.5 ### Patch Changes - Make caches optional due to Vercel Edge middleware error https://github.com/vercel/next.js/issues/50102 ## 0.7.4 ### Patch Changes - Set global cache before using ResponseCache ## 0.7.3 ### Patch Changes - Use polyfill only if runtime is defined ## 0.7.2 ### Patch Changes - Fix "body already used" error by cloning response upon rewriting ## 0.7.1 ### Patch Changes - Added @edge-runtime/primitives to dependencies ## 0.7.0 ### Minor Changes - Updated Next.js to 13.4 with stable app directory. Integrated edge-runtime and removed direct dependency to @peculiar/web-crypto. Integrated ServiceAccountCredential and PublicKeySignatureVerifier with Web APIs CacheStorage. ## 0.6.2 ### Patch Changes - Update engines to support Node 19 ## 0.6.1 ### Patch Changes - Fix ReadonlyRequestCookies imports after update to Next.js 13.3.0 ## 0.6.0 ### Minor Changes - Added setCustomUserClaims, getUser and refreshAuthCookies Edge-runtime compatible methods ## 0.5.1 ### Patch Changes - Handle refresh token error using handleError function - Updated dependencies - next-firebase-auth-edge@0.5.1 ## 0.5.0 ### Minor Changes - Rename methods from getAuthenticatedResponse, getUnauthenticatedResponse and getErrorResponse to more readable handleValidToken, handleInvalidToken and handleError functions ## 0.4.4 ### Patch Changes - Added refreshAuthCookies method to refresh cookie headers in api middleware ## 0.4.3 ### Patch Changes - Introduced getUnauthenticatedResponse middleware option to handle redirects for unauthenticated users ## 0.4.2 ### Patch Changes - getAuthenticatedResponse and getErrorResponse options are now async ## 0.4.1 ### Patch Changes - Optional redirectOptions for use-cases where authentication happens in more than one contexts ## 0.4.0 ### Minor Changes - Added authentication middleware to automatically handle redirection and authentication cookie refresh ## 0.3.1 ### Patch Changes - Re-throw INVALID_CREDENTIALS FirebaseAuthError with error details on token refresh error ## 0.3.0 ### Minor Changes - Updated peer next peer dependency to ^13.1.1 and removed allowMiddlewareResponseBody flag' ## 0.2.15 ### Patch Changes - Handle "USER_NOT_FOUND" error during token refresh ## 0.2.14 ### Patch Changes - Added Firebase Authentication Emulator support ## 0.2.13 ### Patch Changes - Fix incorrect HMAC algorithm key buffer size ## 0.2.12 ### Patch Changes - Update rotating credential HMAC key algorithm to SHA-512 ## 0.2.11 ### Patch Changes - Update rotating credential HMAC key algorithm to SHA-256 ## 0.2.10 ### Patch Changes - Support Next.js 18 LTS ## 0.2.9 ### Patch Changes - Update Next.js peerDependency version to ^13.0.5 to allow future minor/patch versions ## 0.2.8 ### Patch Changes - Integrated with changesets and eslint to improve transparency and legibility ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Amadeusz Winogrodzki Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ next-firebase-auth-edge --- Next.js Firebase Authentication for Edge and Node.js runtimes. Use Firebase Authentication with latest Next.js features. [![npm version](https://badge.fury.io/js/next-firebase-auth-edge.svg)](https://badge.fury.io/js/next-firebase-auth-edge) ## Example Check out a working demo here: [next-firebase-auth-edge-starter.vercel.app](https://next-firebase-auth-edge-starter.vercel.app/) You can find the source code for this demo at [examples/next-typescript-starter](https://github.com/ensite-in/next-firebase-auth-edge/tree/main/examples/next-typescript-starter) ## Guide New to Firebase or Next.js? No worries! Follow this easy, step-by-step guide to set up Firebase Authentication in Next.js app using the **next-firebase-auth-edge** library: https://hackernoon.com/using-firebase-authentication-with-the-latest-nextjs-features ## Docs The official documentation is available here: https://next-firebase-auth-edge-docs.vercel.app ## Why? The official `firebase-admin` library depends heavily on Node.js’s internal `crypto` library, which isn’t available in [Next.js Edge Runtime](https://nextjs.org/docs/api-reference/edge-runtime). This library solves that problem by handling the creation and verification of [Custom ID Tokens](https://firebase.google.com/docs/auth/admin/verify-id-tokens) using the Web Crypto API, which works in Edge runtimes. ## Features `next-firebase-auth-edge` supports all the latest Next.js features, like the [App Router](https://nextjs.org/docs/app) and [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components). To make adopting the newest Next.js features easier, this library works seamlessly with both [getServerSideProps](https://nextjs.org/docs/pages/building-your-application/data-fetching/get-server-side-props) and legacy [API Routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes). ### Key Features: * **Supports Next.js's latest features** * **Zero bundle size** * **Minimal setup**: Unlike other libraries, you won’t need to create your own API routes or modify your `next.config.js`. Everything’s handled by [middleware](https://next-firebase-auth-edge-docs.vercel.app/docs/usage/middleware). * **Secure**: Uses [jose](https://github.com/panva/jose) for JWT validation, and signs user cookies with rotating keys to prevent cryptanalysis attacks. ### What's New Key updates in latest release include: * New `enableTokenRefreshOnExpiredKidHeader` option in `authMiddleware`, which refreshes user tokens when Google’s public certificates expire (instead of throwing an error) * Added `privatePaths` option to [redirectToLogin](https://next-firebase-auth-edge-docs.vercel.app/docs/usage/redirect-functions#redirecttologin) helper function * Added [Metadata](https://next-firebase-auth-edge-docs.vercel.app/docs/usage/middleware#metadata) feature that allows to store custom data inside session cookies * Added `removeServerCookies` method to handle logout from inside Server Action * Added `experimental_createAnonymousUserIfUserNotFound` option to create anonymous user if no user was found * Full Firebase Emulator Support. The library now fully supports the Firebase Emulator, enabling you to run your development app without needing to create a Firebase Project. Follow [starter example README](https://github.com/awinogrodzki/next-firebase-auth-edge/tree/main/examples/next-typescript-starter#emulator-support) for details * Custom token is now optional. To enable custom token support use [enableCustomToken](https://next-firebase-auth-edge-docs.vercel.app/docs/usage/middleware#custom-token) option * Support ESM, Browser and Node.js imports for better tree-shaking features * Support for **Node.js 24+** and **NPM 11** * Support for **Next.js 16** * Support for **React 19** ## Installation To install, run one of the following: With **npm** ```shell npm install next-firebase-auth-edge ``` With **yarn** ```shell yarn add next-firebase-auth-edge ``` With **pnpm** ```shell pnpm add next-firebase-auth-edge ``` ## [→ Read the docs](https://next-firebase-auth-edge-docs.vercel.app/) ================================================ FILE: commitlint.config.js ================================================ module.exports = { extends: ['@commitlint/config-conventional'] }; ================================================ FILE: docs/.eslintrc.js ================================================ module.exports = { extends: ['prettier'], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 2018, sourceType: 'module', }, plugins: ['@typescript-eslint', 'prettier'], root: true, rules: { 'prettier/prettier': 'error', }, }; ================================================ FILE: docs/.gitignore ================================================ /node_modules /.next/ .DS_Store .vercel public/.nextra tsconfig.tsbuildinfo .env public/robots.txt public/sitemap.xml public/sitemap-0.xml ================================================ FILE: docs/README.md ================================================ # docs Website for `next-firebase-auth-edge` You can run it locally like this: ``` yarn install yarn dev ``` ================================================ FILE: docs/components/Chip.tsx ================================================ import clsx from 'clsx'; import {ReactNode} from 'react'; type Props = { children: ReactNode; className?: string; color?: 'green' | 'yellow'; }; export default function Chip({children, className, color = 'green'}: Props) { return ( {children} ); } ================================================ FILE: docs/components/CommunityLink.tsx ================================================ import Link from 'next/link'; import {ComponentProps} from 'react'; import Chip from './Chip'; type Props = Omit, 'children'> & { date: string; author: string; title: string; type?: 'article' | 'video'; }; export default function CommunityLink({ author, date, title, type, ...rest }: Props) { return (

{title}

{type && ( {{article: 'Article', video: 'Video'}[type]} )}

{date}

{' ・ '}

{author}

); } ================================================ FILE: docs/components/Example.tsx ================================================ import Chip from './Chip'; type Props = { demoLink?: string; sourceLink: string; hash: string; name: string; description: string; featured?: boolean; }; export default function Example({ demoLink, description, featured, hash, name, sourceLink }: Props) { return (

{name} {featured && Featured}

{description}

Source {demoLink && ( <> {' ・ '} Demo )}
); } ================================================ FILE: docs/components/FeaturePanel.tsx ================================================ type Props = { code?: string; description: string; title: string; }; export default function FeaturePanel({code, description, title}: Props) { return (
{code && (
{code}
)}

{title}

{description}

); } ================================================ FILE: docs/components/Footer.tsx ================================================ import config from 'config'; import {useRouter} from 'next/router'; import FooterLink from './FooterLink'; import FooterSeparator from './FooterSeparator'; export default function Footer() { const router = useRouter(); // Unfortunately, Nextra renders the footer incorrectly here const isHidden = router.pathname.startsWith('/examples'); if (isHidden) return null; return (
Docs Examples
GitHub ♥ Sponsor
); } ================================================ FILE: docs/components/FooterLink.tsx ================================================ import Link from 'next/link'; import {ComponentProps} from 'react'; type Props = ComponentProps; export default function FooterLink({children, ...rest}: Props) { return (

{children}

); } ================================================ FILE: docs/components/FooterSeparator.tsx ================================================ export default function FooterSeparator() { return (  ·  ); } ================================================ FILE: docs/components/Hero.tsx ================================================ import HeroCode from './HeroCode'; import LinkButton from './LinkButton'; import Wrapper from './Wrapper'; type Props = { description: string; getStarted: string; titleRegular: string; titleStrong: string; viewExample: string; }; export default function Hero({ description, getStarted, titleRegular, titleStrong, viewExample }: Props) { return (

{titleStrong}{' '} {titleRegular}

{description}

{getStarted} {viewExample}
); } ================================================ FILE: docs/components/HeroCode.tsx ================================================ import clsx from 'clsx'; import {ReactNode, useState} from 'react'; function Tab({ active, children, onClick }: { active: boolean; children: ReactNode; onClick(): void; }) { return ( ); } const files = [ { name: 'proxy.ts', code: ( import type {' '} {'{'} NextRequest {'}'}{' '} from "next/server" ; {'\n'} import {' '} {'{'} authMiddleware {'}'}{' '} from "next-firebase-auth-edge" ; {'\n'} {'\n'} export async function proxy (request : NextRequest ) {'{'} {'\n'} {' '} return authMiddleware (request , {'{'} {'\n'} {' '}loginPath : "/api/login" , {'\n'} {' '}logoutPath : "/api/logout" , {'\n'} {' '}apiKey : "XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX" , {'\n'} {' '}cookieName : "AuthToken" , {'\n'} {' '}cookieSignatureKeys : [ "Key-Should-Be-at-least-32-bytes-in-length" ] , {'\n'} {' '}cookieSerializeOptions : {'{'} {'\n'} {' '}path : "/" , {'\n'} {' '}httpOnly : true , {'\n'} {' '}secure : false , // Set this to true on HTTPS environments {'\n'} {' '}sameSite : "lax" as const , {'\n'} {' '}maxAge : 12 * 60 * 60 * 24 , // Twelve days {'\n'} {' '} {'}'} , {'\n'} {' '}serviceAccount : {'{'} {'\n'} {' '}projectId : "your-firebase-project-id" , {'\n'} {' '}clientEmail : "firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com" , {'\n'} {' '}privateKey : "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" , {'\n'} {' '} {'}'} , {'\n'} {' '} {'}'}); {'\n'} {'}'} {'\n'} {'\n'} export const config = {'{'} {'\n'} {' '}matcher : [ "/api/login" , "/api/logout" , "/" , "/((?!_next|favicon.ico|api|.*\\.).*)" ] , {'\n'} {'}'}; ) }, { name: 'app/layout.tsx', code: ( import {' '} {'{'} cookies {'}'}{' '} from "next/headers" ; {'\n'} import {'{'} {' '} getTokens {'}'}{' '} from "next-firebase-auth-edge" ; {'\n'} {'\n'} export default async function RootLayout ({'{'} {'\n'} {' '}children , {'\n'} {'}'} : {'{'} {'\n'} {' '}children : JSX . Element {'\n'} {'}'}) {'{'} {'\n'} {' '} const tokens = await getTokens ( cookies () , {'{'} {'\n'} {' '}apiKey : 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX' , {'\n'} {' '}cookieName : 'AuthToken' , {'\n'} {' '}cookieSignatureKeys : [ 'Key-Should-Be-at-least-32-bytes-in-length' ] , {'\n'} {' '}serviceAccount : {'{'} {'\n'} {' '}projectId : 'your-firebase-project-id' , {'\n'} {' '}clientEmail : 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com' , {'\n'} {' '}privateKey : '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' {'\n'} {' '} {'}'} {'\n'} {' '} {'}'}); {'\n'} {'\n'} {' '} return ( {'\n'} {' '}< html lang = "en" > {'\n'} {' '}< head /> {'\n'} {' '}< body > {'\n'} {' '} {'{'} /* ... */ {'}'} {'\n'} {' '}</ body > {'\n'} {' '}</ html > {'\n'} {' '}); {'\n'} {'}'} ) } ]; export default function HeroCode() { const [fileIndex, setFileIndex] = useState(0); return (
{files.map((file) => ( setFileIndex(files.indexOf(file))} > {file.name} ))}
              {files[fileIndex].code}
            
); } ================================================ FILE: docs/components/Link.tsx ================================================ import NextLink from 'next/link'; import {ComponentProps} from 'react'; type Props = Omit, 'className'>; export default function Link(props: Props) { return ( ); } ================================================ FILE: docs/components/LinkButton.tsx ================================================ import clsx from 'clsx'; import Link from 'next/link'; import {ComponentProps} from 'react'; type Props = { variant?: 'primary' | 'secondary'; } & Omit, 'className'>; export default function LinkButton({ children, variant = 'primary', ...rest }: Props) { return ( {children} ); } ================================================ FILE: docs/components/Section.tsx ================================================ import {ReactNode} from 'react'; import Wrapper from './Wrapper'; type Props = { children: ReactNode; description: string; title: string; }; export default function Section({children, description, title}: Props) { return (

{title}

{description}
{children}
); } ================================================ FILE: docs/components/Steps.module.css ================================================ .root { @apply ml-4 border-l border-slate-200 pl-8; counter-reset: step; } .root h3 { counter-increment: step; @apply text-lg; } .root h3:before { content: counter(step); @apply absolute mt-[-6px] ml-[-52px] inline-block h-10 w-10 rounded-full border-4 border-white bg-slate-100 pt-[4px] text-center text-base font-bold text-slate-500; } :global(.dark) .root { @apply border-slate-800; } :global(.dark) .root h3:before { @apply bg-slate-800 text-white/75; border-color: rgba(17, 17, 17, var(--tw-bg-opacity)); /* nx-bg-dark */ } ================================================ FILE: docs/components/Steps.tsx ================================================ import {ReactNode} from 'react'; import styles from './Steps.module.css'; type Props = { children: ReactNode; }; export default function Steps({children}: Props) { return
{children}
; } ================================================ FILE: docs/components/Wrapper.tsx ================================================ import clsx from 'clsx'; import {ReactNode} from 'react'; type Props = { children: ReactNode; className?: string; }; export default function Wrapper({children, className}: Props) { return (
{children}
); } ================================================ FILE: docs/config.js ================================================ module.exports = { baseUrl: 'https://next-firebase-auth-edge-docs.vercel.app', githubUrl: 'https://github.com/awinogrodzki/next-firebase-auth-edge' }; ================================================ FILE: docs/next-env.d.ts ================================================ /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. ================================================ FILE: docs/next-sitemap.config.js ================================================ const config = require('./config'); /** @type {import('next-sitemap').IConfig} */ module.exports = { siteUrl: config.baseUrl, generateRobotsTxt: true }; ================================================ FILE: docs/next.config.js ================================================ const withNextra = require('nextra')({ theme: 'nextra-theme-docs', themeConfig: './theme.config.tsx', staticImage: true, defaultShowCopyCode: true, flexsearch: { codeblocks: false } }); module.exports = withNextra({ redirects: () => [ // Index pages { source: '/docs', destination: '/docs/getting-started', permanent: false }, // Legacy pages { source: '/docs/usage/refresh-auth-cookies', destination: '/docs/usage/refresh-credentials', permanent: true }, ], }); ================================================ FILE: docs/package.json ================================================ { "name": "docs", "version": "2.14.3", "private": true, "scripts": { "dev": "next dev", "lint": "eslint ./pages ./components --ext .ts,.tsx", "test": "echo 'No tests yet'", "build": "next build", "sitemap": "next-sitemap", "start": "next start" }, "dependencies": { "@heroicons/react": "^2.0.17", "@tailwindcss/typography": "^0.5.9", "@vercel/analytics": "1.1.0", "clsx": "^1.2.1", "http-status-codes": "^2.2.0", "next": "^14.0.4", "nextra": "^2.13.2", "nextra-theme-docs": "^2.13.2", "react": "^18.2.0", "react-dom": "^18.2.0", "tailwindcss": "^3.3.2" }, "devDependencies": { "@types/node": "^20.1.2", "@types/react": "^18.2.29", "autoprefixer": "^10.4.0", "eslint": "^8.54.0", "eslint-config-molindo": "^7.0.0", "eslint-config-next": "^14.0.3", "eslint-config-prettier": "^9.1.0", "next-sitemap": "^4.0.7", "typescript": "^5.2.2" }, "resolutions": { "strip-ansi": "6.0.1", "next-mdx-remote": "6.0.0" }, "funding": "https://github.com/awinogrodzki/next-firebase-auth-edge?sponsor=1" } ================================================ FILE: docs/pages/_app.tsx ================================================ import {AppProps} from 'next/app'; import {Inter} from 'next/font/google'; import {ReactNode} from 'react'; import 'nextra-theme-docs/style.css'; import '../styles.css'; const inter = Inter({subsets: ['latin']}); type Props = AppProps & { Component: {getLayout?(page: ReactNode): ReactNode}; }; export default function App({Component, pageProps}: Props) { const getLayout = Component.getLayout || ((page: ReactNode) => page); return (
{getLayout()}
); } ================================================ FILE: docs/pages/_document.tsx ================================================ import {Html, Head, Main, NextScript} from 'next/document'; import {SkipNavLink} from 'nextra-theme-docs'; import React from 'react'; export default function Document() { return (
); } ================================================ FILE: docs/pages/_meta.json ================================================ { "index": { "title": "Introduction", "type": "page", "display": "hidden", "theme": {"layout": "raw"} }, "docs": { "title": "Docs", "type": "page" }, "examples": { "title": "Examples", "type": "page", "theme": { "sidebar": false, "toc": false } } } ================================================ FILE: docs/pages/docs/_meta.json ================================================ { "getting-started": "Getting started", "usage": "Usage guide", "emulator": "Emulator", "app-check": "App Check", "errors": "Handling errors", "faq": "FAQ" } ================================================ FILE: docs/pages/docs/app-check.mdx ================================================ # App Check Support This library provides support for [Firebase App Check](https://firebase.google.com/docs/app-check). To learn how to integrate App Check into your app, follow the instructions in the [starter example README](https://github.com/awinogrodzki/next-firebase-auth-edge/tree/main/examples/next-typescript-starter). To use `next-firebase-auth-edge` with App Check, you need to include the `X-Firebase-AppCheck` header with the App Check token when making a call to the `/api/login` endpoint. You can see how this works in [this example](https://github.com/awinogrodzki/next-firebase-auth-edge/blob/main/examples/next-typescript-starter/api/index.ts#L10-L14). ```tsx import { getToken } from "@firebase/app-check"; import { getAppCheck } from "../app-check"; const appCheckTokenResponse = await getToken(getAppCheck(), false); await fetch("/api/login", { method: "GET", headers: { Authorization: `Bearer ${token}`, "X-Firebase-AppCheck": appCheckTokenResponse.token, }, }); ``` ## Advanced Usage If you need to explicitly create or verify an App Check token, you can use the `getAppCheck` function from `next-firebase-auth-edge/app-check`. You can see an example of how to do this below: ```tsx import { getAppCheck } from "next-firebase-auth-edge/app-check"; // Optional in authenticated Google Cloud Run environment. Otherwise required. const serviceAccount = { projectId: "firebase-project-id", privateKey: "firebase service account private key", clientEmail: "firebase service account client email", }; // Optional. Specify if your project supports multi-tenancy // https://cloud.google.com/identity-platform/docs/multi-tenancy-authentication const tenantId = "You tenant id"; const { createToken, verifyToken } = getAppCheck({ serviceAccount, tenantId }); ``` ```tsx const appId = "your-app-id"; // Optional const createTokenOptions = { ttlMillis: 3600 * 1000, }; const token = await createToken(appId, createTokenOptions); // Optional const verifyTokenOptions = { currentDate: new Date(), }; const response = await verifyToken(token, verifyTokenOptions); ``` ================================================ FILE: docs/pages/docs/emulator.mdx ================================================ # Emulator Support This library supports the Firebase Authentication Emulator. For more details on how to set it up, check out the [starter example README](https://github.com/awinogrodzki/next-firebase-auth-edge/tree/main/examples/next-typescript-starter). ================================================ FILE: docs/pages/docs/errors.mdx ================================================ # Handling Errors ## handleInvalidToken The [Auth middleware](/docs/usage/middleware) provides a `handleInvalidToken` function, which is called with an `InvalidTokenReason` as the first argument. The `InvalidTokenReason` is primarily for informational purposes. The `handleInvalidToken` function is typically called when something **expected** happens, allowing the user to be safely redirected to the login page. One common **expected** event is when a user visits your app for the first time. ### InvalidTokenReason The table below describes the different types of `InvalidTokenReason`: | Name | Description | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MISSING_CREDENTIALS` | The request does not contain an authentication cookie | | `MISSING_REFRESH_TOKEN` | Credentials have expired, and no refresh token is available | | `MALFORMED_CREDENTIALS` | The cookies cannot be parsed or the structure has changed | | `INVALID_SIGNATURE` | The cookie signature cannot be verified or the signature keys have changed | | `INVALID_CREDENTIALS` | The cookies have a valid structure, but the `idToken` cannot be verified | | `INVALID_KID` | This error usually means the certificate used to sign the token has expired, which is **expected**. Google periodically refreshes certificates as part of [key rotation](https://developer.okta.com/docs/concepts/key-rotation/) | ## handleError Unlike `handleInvalidToken`, which handles **expected** issues, `handleError` is called when something **unexpected** occurs that a developer should investigate. The `handleError` function receives an `AuthError` object as the first argument. This object includes `code` and `message` properties, which describe the type and meaning of the error. ### AuthError The error codes are divided as follows: | Code | Description | | ---- | ----------- | | `USER_NOT_FOUND` | The user cannot be found, possibly because they were removed after generating or refreshing the custom token | | `INVALID_CREDENTIAL` | The token could not be refreshed due to an invalid refresh token or service account credentials | | `TOKEN_EXPIRED` | Handled internally to refresh the token. Occurs when the custom `idToken` has expired | | `USER_DISABLED` | Thrown when `authMiddleware` is called with `checkRevoked: true` and the user has been disabled | | `TOKEN_REVOKED` | Thrown when `authMiddleware` is called with `checkRevoked: true` and the token has been revoked | | `INVALID_ARGUMENT` | The token has an incorrect structure or the certificate used to sign it has expired | | `INTERNAL_ERROR` | An internal error occurred. Check the error message for more details | | `NO_KID_IN_HEADER` | Handled internally to verify the token against all public certificates. Re-throws `INVALID_SIGNATURE` if none of the public keys match the token's signature | | `INVALID_SIGNATURE` | The token signature cannot be verified | | `MISMATCHING_TENANT_ID` | Provided tenant ID does not match Firebase tenant ID from the token | ================================================ FILE: docs/pages/docs/faq.mdx ================================================ # FAQ ## Where are the login and logout API routes defined? Unlike the [next-firebase-auth](https://github.com/gladly-team/next-firebase-auth?tab=readme-ov-file#get-started) library, `next-firebase-auth-edge` does not require you to manually define your own `/api/login` or `/api/logout` routes. These routes are automatically handled by the [authMiddleware](/docs/getting-started/middleware). For more details, check out this [explanation](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/34#issuecomment-1588032612). ================================================ FILE: docs/pages/docs/getting-started/_meta.json ================================================ { "index": "Welcome!", "middleware": "Middleware", "auth-context": "AuthContext", "auth-provider": "AuthProvider", "layout": "Layout", "login-page": "Usage with Firebase Auth", "login-with-server-action": "Sign in with Server Action", "logout-with-server-action": "Sign out with Server Action" } ================================================ FILE: docs/pages/docs/getting-started/auth-context.mdx ================================================ # AuthContext The library doesn't include any client-side code or built-in authentication state context. It's up to the developer to decide how to manage user data throughout the application. Check out the example of an `AuthContext` below: ## Example AuthContext The following is an example implementation of custom `AuthContext` using React's [createContext](https://react.dev/reference/react/createContext). ```tsx filename="AuthContext.ts" import {createContext, useContext} from 'react'; import {UserInfo} from 'firebase/auth'; import {Claims} from 'next-firebase-auth-edge/auth/claims'; export interface User extends UserInfo { emailVerified: boolean; customClaims: Claims; } export interface AuthContextValue { user: User | null; } export const AuthContext = createContext({ user: null }); export const useAuth = () => useContext(AuthContext); ``` ================================================ FILE: docs/pages/docs/getting-started/auth-provider.mdx ================================================ # AuthProvider To share user data between the server and client components, we can use the custom `AuthContext` we created in the [previous step](/docs/getting-started/auth-context). ## Example AuthProvider Below is an example of how to implement a custom `AuthProvider` component that uses `AuthContext` to pass user data between the server and client components. ```tsx filename="AuthProvider.tsx" 'use client'; import * as React from 'react'; import {AuthContext, User} from './AuthContext'; export interface AuthProviderProps { user: User | null; children: React.ReactNode; } export const AuthProvider: React.FunctionComponent = ({ user, children }) => { return ( {children} ); }; ``` ================================================ FILE: docs/pages/docs/getting-started/index.mdx ================================================ import {Card} from 'nextra-theme-docs'; import {ChevronRightIcon} from '@heroicons/react/24/outline'; # Next.js Firebase Authentication Welcome to the `next-firebase-auth-edge` docs! In this guide you will learn how to set up Firebase Authentication in your Next.js app.
} title="Setup Next.js Middleware" href="/docs/getting-started/middleware" /> } title="Setup custom AuthContext" href="/docs/getting-started/auth-context" /> } title="Setup custom AuthProvider" href="/docs/getting-started/auth-provider" /> } title="Setup App Router Layout" href="/docs/getting-started/layout" /> } title="Usage with Firebase Auth" href="/docs/getting-started/login-page" /> } title="Sign in with Server Action" href="/docs/getting-started/login-with-server-action" />
================================================ FILE: docs/pages/docs/getting-started/layout.mdx ================================================ # Layout We can use the `getTokens` function from `next-firebase-auth-edge` to pull user information from request cookies. Once we have the token details, we can map them to a `User` object and pass it to the `AuthProvider` we created in the [previous step](/docs/getting-started/auth-provider). You can use `getTokens` in any React Server Component, whether it's `page.tsx` or `layout.tsx`. Learn more about the Next.js App Router in the [official docs](https://nextjs.org/docs/app). ## Example RootLayout Here’s an example of how to implement the `RootLayout` React Server Component. It uses the `getTokens` function to create a user object from cookies and passes it to the `AuthProvider` client component. ```tsx filename="app/layout.tsx" import { filterStandardClaims } from "next-firebase-auth-edge/auth/claims"; import { Tokens, getTokens } from "next-firebase-auth-edge"; import { cookies } from "next/headers"; import { User } from "./AuthContext"; import { AuthProvider } from "./AuthProvider"; const toUser = ({ decodedToken }: Tokens): User => { const { uid, email, picture: photoURL, email_verified: emailVerified, phone_number: phoneNumber, name: displayName, source_sign_in_provider: signInProvider, } = decodedToken; const customClaims = filterStandardClaims(decodedToken); return { uid, email: email ?? null, displayName: displayName ?? null, photoURL: photoURL ?? null, phoneNumber: phoneNumber ?? null, emailVerified: emailVerified ?? false, providerId: signInProvider, customClaims, }; }; export default async function RootLayout({ children, }: { children: JSX.Element }) { // Since Next.js 15, `cookies()` returns a Promise and must be preceded with `await`. // In Next.js 14, `cookies()` is synchronous — use `getTokens(cookies(), ...)` without `await` on `cookies()`. const tokens = await getTokens(await cookies(), { apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: [ 'Key-Should-Be-at-least-32-bytes-in-length' ], serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' } }); const user = tokens ? toUser(tokens) : null; return (
{children}
); } ``` ================================================ FILE: docs/pages/docs/getting-started/login-page.mdx ================================================ # Using Firebase Auth Follow the official `firebase/auth` [guide](https://firebase.google.com/docs/auth/web/password-auth) to handle different operations like `signInWithEmailAndPassword` or `signOut`. Check out the [starter example](/examples#starter) for more complete examples, including login, registration, and password reset flows. ## Example LoginPage Here’s a simple login page client component that lets users sign in with their `email` and `password`: ```tsx filename="page.tsx" 'use client'; import * as React from 'react'; import {getAuth, signInWithEmailAndPassword} from 'firebase/auth'; import {useRouter} from 'next/navigation'; export default function LoginPage() { const [email, setEmail] = React.useState(''); const [password, setPassword] = React.useState(''); const router = useRouter(); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); event.stopPropagation(); const credential = await signInWithEmailAndPassword( getAuth(), email, password ); const idToken = await credential.user.getIdToken(); // Sets authenticated browser cookies await fetch('/api/login', { headers: { Authorization: `Bearer ${idToken}` } }); // Refresh page after updating browser cookies router.refresh(); } return (

Login

setEmail(e.target.value)} name="email" type="email" placeholder="Email address" />
setPassword(e.target.value)} type="password" placeholder="Password" minLength={8} />
); } ``` Let's focus on the form submission handler: ```tsx async function handleSubmit(event: React.FormEvent) { event.preventDefault(); event.stopPropagation(); const credential = await signInWithEmailAndPassword( getAuth(), email, password ); const idToken = await credential.user.getIdToken(); // Sets authenticated browser cookies await fetch('/api/login', { headers: { Authorization: `Bearer ${idToken}` } }); // Refresh page after updating browser cookies router.refresh(); } ``` It can be broken down to following actions: 1. We sign user in using email and password using `signInWithEmailAndPassword` from Firebase Client SDK 2. We extract `idToken` using `credential` returned by `signInWithEmailAndPassword` 3. We call `/api/login` endpoint exposed by the middleware to update browser cookies with authentication token 4. We call `router.refresh()` to re-render server components with updated credentials ================================================ FILE: docs/pages/docs/getting-started/login-with-server-action.mdx ================================================ # Sign in with Server Action You can follow the official `firebase/auth` [guide](https://firebase.google.com/docs/auth/web/password-auth) to handle operations like `signInWithEmailAndPassword` or `signOut`. ## refreshCookiesWithIdToken The `refreshCookiesWithIdToken` method updates browser cookies with the latest authenticated credentials based on the `idToken`. This method works with **Server Actions** and **Middleware**. After performing Firebase operations in a Server Action, you can use this method to refresh the browser cookies with updated credentials. ## Example loginAction and LoginPage **Note for Vercel users:** The `firebase/auth` library isn't fully compatible with **Vercel** environments when used inside Server Actions. If you get a `ReferenceError: document is not defined`, move the `firebase/auth` import to a client component. Below is a simple example of a login page client component that lets users sign in using a Server Action and the `refreshCookiesWithIdToken` method. First, let’s define our Server Action: ```tsx filename="login.tsx" 'use server'; import {refreshCookiesWithIdToken} from 'next-firebase-auth-edge/next/cookies'; import {signInWithEmailAndPassword} from 'firebase/auth'; import {cookies, headers} from 'next/headers'; import {redirect} from 'next/navigation'; // See starter example for implementation: https://github.com/awinogrodzki/next-firebase-auth-edge/tree/main/examples/next-typescript-starter import {getFirebaseAuth} from '@/app/auth/firebase'; import {authConfig} from '@/config/server-config'; export async function loginAction(username: string, password: string) { const credential = await signInWithEmailAndPassword( getFirebaseAuth(), username, password ); const idToken = await credential.user.getIdToken(); // Since Next.js 15, `headers()` and `cookies()` return a Promise and must be preceded with `await`. // In Next.js 14, these functions are synchronous — call them without `await`. await refreshCookiesWithIdToken( idToken, await headers(), await cookies(), authConfig ); redirect('/'); } ``` Next, create LoginPage client component that will call login action: ```tsx filename="LoginPage.tsx" 'use client'; import * as React from 'react'; interface LoginPageProps { loginAction: (email: string, password: string) => void; } export default function LoginPage({loginAction}: LoginPageProps) { const [email, setEmail] = React.useState(''); const [password, setPassword] = React.useState(''); let [isLoginActionPending, startTransition] = React.useTransition(); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); event.stopPropagation(); startTransition(() => loginAction(email, password)); } return (

Login

setEmail(e.target.value)} name="email" type="email" placeholder="Email address" />
setPassword(e.target.value)} type="password" placeholder="Password" minLength={8} />
); } ``` Lastly, let's use LoginPage inside `page.tsx` Server Component: ```tsx filename="page.tsx" import LoginPage from './LoginPage'; import {loginAction} from './login'; export default function Page() { return ; } ``` ================================================ FILE: docs/pages/docs/getting-started/logout-with-server-action.mdx ================================================ # Sign out with Server Action ## removeServerCookies [v1.9.0] The `removeServerCookies` method removes server cookies from the browser. This method works with **Server Actions** and **Middleware**. After logging out with Firebase in a Server Action, you can use this method to remove the auth cookies from the browser. Prior to version `1.9.0`, the `removeServerCookies` method was not available. Instead, you had to manually remove the cookies from the browser. With multiple cookies enabled: ```tsx cookies.delete(authConfig.cookieName + ".id"); cookies.delete(authConfig.cookieName + ".refresh"); cookies.delete(authConfig.cookieName + ".sig"); // Optionally, if you enabled custom token: cookies.delete(authConfig.cookieName + ".custom"); ``` Without multiple cookies enabled: ```tsx cookies.delete(authConfig.cookieName); ``` ## Example logoutAction and LogoutPage Below is a simple example of a logout page component that lets users sign out using a Server Action and the `removeServerCookies` method. First, let’s define our Server Action: ```tsx filename="logout.tsx" 'use server'; import {removeServerCookies} from "next-firebase-auth-edge/next/cookies"; import {signOut} from 'firebase/auth'; import {cookies} from 'next/headers'; import {redirect} from 'next/navigation'; import {getFirebaseAuth} from '@/app/auth/firebase'; import {authConfig} from '@/config/server-config'; export async function logoutAction() { await signOut(getFirebaseAuth()); // Since Next.js 15, `cookies()` returns a Promise and must be preceded with `await`. // In Next.js 14, `cookies()` is synchronous — call it without `await`. removeServerCookies(await cookies(), { cookieName: authConfig.cookieName }); redirect('/'); } ``` Next, create LogoutPage component that will call logout action: ```tsx filename="LogoutPage.tsx" interface LogoutPageProps { logoutAction: () => void; } export default function LogoutPage({logoutAction}: LogoutPageProps) { return (

Logout

); } ``` ================================================ FILE: docs/pages/docs/getting-started/middleware.mdx ================================================ # Authentication Middleware The library offers an `authMiddleware` function that's meant to be used with [Next.js Proxy](https://nextjs.org/docs/app/getting-started/proxy) (or [Middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware) in Next.js 14-15). For more details, check out the [Authentication Middleware usage docs](/docs/usage/middleware). Here's a basic example of how to use `authMiddleware` in `proxy.ts`: > **Next.js 14-15:** Use `middleware.ts` with `export async function middleware(...)` instead. See the [Next.js 16 migration note](/docs/usage/middleware#nextjs-14-15-compatibility). ```tsx filename="proxy.ts" import type { NextRequest } from "next/server"; import { authMiddleware } from "next-firebase-auth-edge"; export async function proxy(request: NextRequest) { return authMiddleware(request, { loginPath: "/api/login", logoutPath: "/api/logout", apiKey: "XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX", cookieName: "AuthToken", cookieSignatureKeys: ["Key-Should-Be-at-least-32-bytes-in-length"], cookieSerializeOptions: { path: "/", httpOnly: true, secure: false, // Set this to true on HTTPS environments sameSite: "lax" as const, maxAge: 12 * 60 * 60 * 24, // Twelve days }, serviceAccount: { projectId: "your-firebase-project-id", clientEmail: "firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com", privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", }, }); } export const config = { matcher: ["/api/login", "/api/logout", "/", "/((?!_next|favicon.ico|api|.*\\.).*)"], }; ``` ================================================ FILE: docs/pages/docs/usage/_meta.json ================================================ { "index": "Start", "middleware": "Middleware", "server-components": "Server Components", "redirect-functions": "Redirect Helper Functions", "app-router-api-routes": "App Router API Route Handlers", "pages-router-api-routes": "Pages Router API Routes", "get-server-side-props": "Usage in getServerSideProps", "refresh-credentials": "Refreshing credentials", "remove-credentials": "Removing credentials", "client-side-apis": "Using Client-Side APIs", "domain-restriction": "Firebase API Key domain restriction", "advanced-usage": "Advanced usage", "cloud-run": "Usage in Google Cloud Run", "firebase-hosting": "Usage in Firebase Hosting", "debug-mode": "Debug mode" } ================================================ FILE: docs/pages/docs/usage/advanced-usage.mdx ================================================ # Advanced Usage The authentication middleware may not cover every use case. To support more complex authentication flows, `next-firebase-auth-edge` offers a set of low-level tools: ## getFirebaseAuth The `getFirebaseAuth` function provides several server-side methods to manage more advanced authentication scenarios. ```tsx import {getFirebaseAuth} from 'next-firebase-auth-edge'; const { getCustomIdAndRefreshTokens, verifyIdToken, createCustomToken, handleTokenRefresh, getUser, getUserByEmail, createUser, updateUser, deleteUser, verifyAndRefreshExpiredIdToken, setCustomUserClaims } = getFirebaseAuth({ apiKey: 'YOUR FIREBASE API KEY', serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' } }); ``` ### Options | Name | Type | Required? | Description | | ---------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | apiKey | `string` | **Required** | Firebase Web API Key from the Firebase Project settings page. This key becomes visible only after you enable Firebase Authentication in your Firebase project. | | serviceAccount | `{ projectId: string; clientEmail: string; privateKey: string }` | Optional (required unless in [Google Cloud Run](https://cloud.google.com/run) environment) | Firebase Service Account credentials. | | tenantId | `string` | Optional | Specify this if your project supports [multi-tenancy](https://cloud.google.com/identity-platform/docs/multi-tenancy-authentication). | | serviceAccountId | `string` | Optional | Used to specify a service account ID in a [Google Cloud Run](https://cloud.google.com/run) environment. | ### Methods | Name | Type | Description | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | getCustomIdAndRefreshTokens | `(idToken: string, options?: {appCheckToken?: string, referer?: string}) => Promise` | Generates new ID and refresh tokens for the user identified by the given `idToken`. Optionally accepts an `appCheckToken` if your app supports [App Check](https://firebase.google.com/docs/app-check). Can also accept a `referer` if your API key is domain-restricted. | | verifyIdToken | `(idToken: string, options?: {checkRevoked?: boolean, referer?: string, currentDate?: Date}) => Promise` | Verifies the given `idToken` and throws an `AuthError` if verification fails. You can check for revoked tokens by passing `checkRevoked`. The `referer` is used for domain-restricted API keys. Optionally set a `currentDate` to control when the token is validated against. | | verifyAndRefreshExpiredIdToken | `(tokens: {idToken: string, refreshToken: string, customToken?: string}, options?: {checkRevoked?: boolean, referer?: string, currentDate?: Date}) => Promise` | Verifies the `idToken`, and if it's expired, uses the `refreshToken` to revalidate it. Throws `InvalidTokenError` if the credentials are invalid. The options are the same as in `verifyIdToken`. Returns `VerifiedCookies` which includes the decoded token and the new tokens. Custom token can be enabled by setting `enableCustomToken` option to `true` in `authMiddleware`. | | createCustomToken | `(uid: string, developerClaims?: object) => Promise` | Creates a custom token for the specified Firebase user. You can also pass optional `developerClaims` to include additional data. | | handleTokenRefresh | `(refreshToken: string, options?: {referer?: string, enableCustomToken?: boolean}) => Promise` | Returns a new ID token and its decoded form using the given `refreshToken`. The `referer` option is used if the API key is domain-restricted. `enableCustomToken` should match the value you pass to `authMiddleware`. | | getUser | `(uid: string) => Promise` | Retrieves a Firebase `UserRecord` by the user's `uid`. | | getUserByEmail | `(email: string) => Promise` | Retrieves a Firebase `UserRecord` by the user's email address. | | createUser | `(request: CreateRequest) => Promise` | Creates a new user and returns the `UserRecord`. Refer to Firebase’s [Create a user](https://firebase.google.com/docs/auth/admin/manage-users#create_a_user) documentation for details on the request structure. | | updateUser | `(uid: string, request: UpdateRequest) => Promise` | Updates an existing user by `uid` and returns the updated `UserRecord`. See Firebase’s [Update a user](https://firebase.google.com/docs/auth/admin/manage-users#update_a_user) documentation for request examples. | | deleteUser | `(uid: string) => Promise` | Deletes the user associated with the provided `uid`. | | setCustomUserClaims | `(uid: string, customClaims: object ∣ null) => Promise` | Sets custom claims for the specified user, overwriting existing values. Use `getUser` to retrieve the current claims. | ================================================ FILE: docs/pages/docs/usage/app-router-api-routes.mdx ================================================ # App Router API Route Handlers Here’s an example of how to use the [getTokens](/docs/usage/server-components) function in [API Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers). ```tsx import { NextRequest, NextResponse } from "next/server"; import { getTokens } from "next-firebase-auth-edge"; export async function GET(request: NextRequest) { const tokens = await getTokens(request.cookies, { apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' } }); if (!tokens) { throw new Error("Unauthenticated"); } const headers: Record = { "Content-Type": "application/json", }; const response = new NextResponse( JSON.stringify({ tokens, }), { status: 200, headers, } ); return response; } ``` ================================================ FILE: docs/pages/docs/usage/client-side-apis.mdx ================================================ # Using Client-Side APIs The starter example uses the [inMemoryPersistence](https://github.com/awinogrodzki/next-firebase-auth-edge/blob/main/examples/next-typescript-starter/app/auth/firebase.ts#L28-L30) strategy, relying entirely on server-side tokens. This avoids consistency issues on the client side. While this approach is recommended, it can lead to a few challenges: 1. **Stale tokens:** In long-running client sessions, server-side tokens may expire, requiring the user to refresh the page to get a valid token. This typically happens if the user reopens a tab after about an hour. 2. **Unauthenticated Firebase Client SDK environment:** With `inMemoryPersistence`, `currentUser` will often be `null` when using [client-side APIs](https://firebase.google.com/docs/auth/web/manage-users), preventing the use of Firebase’s client-side SDKs. However, `next-firebase-auth-edge` includes several features that address these issues: ### Enable Refresh Token API Endpoint in Auth Middleware In long-running client sessions (e.g., when a user reopens a tab after an hour), the server-side token may expire. This can cause problems when validating external API calls or when using the `customToken` with Firebase's `signInWithCustomToken`. To resolve this, you can expose an endpoint via `authMiddleware` to refresh client-side tokens when the server-side token has expired. To enable this endpoint, define the `refreshTokenPath` option in proxy/middleware. > **Next.js 14-15:** Use `middleware.ts` with `export async function middleware(...)` instead of `proxy.ts`. See the [compatibility note](/docs/usage/middleware#nextjs-14-15-compatibility). ```tsx filename="proxy.ts" export async function proxy(request: NextRequest) { return authMiddleware(request, { loginPath: '/api/login', logoutPath: '/api/logout', refreshTokenPath: '/api/refresh-token' // other options... }); } export const config = { // Make sure to include the path in `matcher` matcher: [ '/api/login', '/api/logout', '/api/refresh-token', '/', '/((?!_next|favicon.ico|api|.*\\.).*)' ] }; ``` Calling `/api/refresh-token` will: 1. Check if the current token has expired. If it has, it regenerates the token and updates the cookies with a `Set-Cookie` header containing the fresh token. 2. Return JSON with a valid `idToken`. It can also return `customToken`, if `enableCustomToken` is set to `true` in `authMiddleware`. ### getValidIdToken The `getValidIdToken` function works in conjunction with the [refresh token endpoint](/docs/usage/client-side-apis#enable-refresh-token-api-endpoint-in-auth-middleware) to ensure you have the latest valid ID token. This is helpful if you use tokens to authorize external API calls. It requires `serverIdToken`, which is the `token` returned by the [getTokens](/docs/usage/server-components#gettokens) function in server components. The function is optimized to be fast and safe for repeated calls. The `/api/refresh-token` endpoint will only be called if the token has expired. Example usage: ```ts import {getValidIdToken} from 'next-firebase-auth-edge/next/client'; export async function fetchSomethingFromExternalApi(serverIdToken: string) { const idToken = await getValidIdToken({ serverIdToken, refreshTokenUrl: '/api/refresh-token' }); return fetch('https://some-external-api.com/api/example', { method: 'GET', headers: { Authorization: `Bearer ${idToken}` } }); } ``` ### getValidCustomToken Please note that since v1.8 custom token is disabled by default. In order to enable custom cookies, pass `enableCustomToken: true` option to `authMiddleware`. Custom token introduces significant footprint on authentication cookie and is not required for most use-cases. If you want to avoid cookie size issues, learn how to [split session into multiple cookies](/docs/usage/middleware#multiple-cookies) Similar to `getValidIdToken`, the `getValidCustomToken` function works with the [refresh token endpoint](/docs/usage/client-side-apis#enable-refresh-token-api-endpoint-in-auth-middleware) to provide a valid custom token. This is useful when using the custom token with Firebase’s [signInWithCustomToken](https://firebase.google.com/docs/auth/web/custom-auth#authenticate-with-firebase) method. It requires `serverCustomToken`, which is the `customToken` returned by the [getTokens](/docs/usage/server-components#gettokens) function in server components. Like `getValidIdToken`, this function is designed to be efficient and only calls the `/api/refresh-token` endpoint if necessary. Example usage: ```ts export async function signInWithServerCustomToken(serverCustomToken: string) { const auth = getAuth(getFirebaseApp()); const customToken = await getValidCustomToken({ serverCustomToken, refreshTokenUrl: '/api/refresh-token' }); if (!customToken) { throw new Error('Invalid custom token'); } return signInWithCustomToken(auth, customToken); } ``` ## Handling errors `getValidIdToken` and `getValidCustomToken` can fail with [AuthError](/docs/errors#autherror). Whenever the error happens, it usually means that the credentials have expired due to various reasons, and **user should be redirected to the sign in page**. ## Using Firebase Client SDKs The Firebase Client SDK exposes the [signInWithCustomToken](https://firebase.google.com/docs/auth/web/custom-auth#authenticate-with-firebase) method, which allows you to access the current user using a custom token. You can obtain a custom token by calling the [getTokens](/docs/usage/server-components#gettokens) function in server components. ```tsx import {signInWithCustomToken} from 'firebase/auth'; import {getValidCustomToken} from 'next-firebase-auth-edge/next/client'; import {doc, getDoc, getFirestore, updateDoc, setDoc} from 'firebase/firestore'; export async function doSomethingWithFirestoreClient( serverCustomToken: string ) { const auth = getAuth(getFirebaseApp()); // See https://next-firebase-auth-edge-docs.vercel.app/docs/usage/client-side-apis#getvalidcustomtoken const customToken = await getValidCustomToken({ serverCustomToken, refreshTokenUrl: '/api/refresh-token' }); if (!customToken) { throw new Error('Invalid custom token'); } const {user: firebaseUser} = await signInWithCustomToken(auth, customToken); // Use client-side firestore instance const db = getFirestore(getApp()); } ``` ================================================ FILE: docs/pages/docs/usage/cloud-run.mdx ================================================ # Usage in Google Cloud Run Environment Before running `next-firebase-auth-edge` in a Google Cloud Run environment, make sure to: 1. [Enable the IAM Service Account Credentials API](https://console.cloud.google.com/apis/api/iamcredentials.googleapis.com). 2. Assign the `iam.serviceAccounts.signBlob` permission to the IAM role attached to the [default compute service account](https://console.cloud.google.com/iam-admin/iam). Once this is done, you can omit the `serviceAccount` option in `authMiddleware`, `getTokens`, and other functions. If `serviceAccount` is `undefined`, `next-firebase-auth-edge` will automatically extract credentials from the authenticated [Google Cloud Run](https://cloud.google.com/run) environment. Keep in mind that you still need to provide the Firebase `apiKey`. Example [authMiddleware](/docs/usage/middleware) usage: > **Next.js 14-15:** Use `middleware.ts` with `export async function middleware(...)` instead of `proxy.ts`. See the [compatibility note](/docs/usage/middleware#nextjs-14-15-compatibility). ```tsx filename="proxy.ts" import { NextRequest, NextResponse } from "next/server"; import { authMiddleware } from "next-firebase-auth-edge"; export async function proxy(request: NextRequest) { return authMiddleware(request, { loginPath: "/api/login", logoutPath: "/api/logout", apiKey: "XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX", cookieName: "AuthToken", cookieSignatureKeys: ["Key-Should-Be-at-least-32-bytes-in-length"], cookieSerializeOptions: { path: "/", httpOnly: true, secure: false, sameSite: "lax" as const, maxAge: 12 * 60 * 60 * 24, }, }); } ``` Example [getTokens](/docs/usage/server-components) usage: ```tsx import { getTokens } from "next-firebase-auth-edge"; const tokens = await getTokens(context.req.cookies, { apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], }); ``` ================================================ FILE: docs/pages/docs/usage/debug-mode.mdx ================================================ # Debug Mode You can enable `debug mode` by setting `debug: true` in the options for `authMiddleware` and `getTokens`. When debug mode is active, the middleware will log additional details about the authentication process to the console. > **Next.js 14-15:** Use `middleware.ts` with `export async function middleware(...)` instead of `proxy.ts`. See the [compatibility note](/docs/usage/middleware#nextjs-14-15-compatibility). ```tsx filename="proxy.ts" import { NextRequest, NextResponse } from "next/server"; import { authMiddleware } from "next-firebase-auth-edge"; export async function proxy(request: NextRequest) { return authMiddleware(request, { debug: true, // Enable debug mode loginPath: "/api/login", logoutPath: "/api/logout", apiKey: "XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX", cookieName: "AuthToken", cookieSignatureKeys: ["Key-Should-Be-at-least-32-bytes-in-length"], cookieSerializeOptions: { path: "/", httpOnly: true, secure: false, sameSite: "lax" as const, maxAge: 12 * 60 * 60 * 24, }, serviceAccount: { projectId: "your-firebase-project-id", clientEmail: "firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com", privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", }, }); } export const config = { matcher: ["/api/login", "/api/logout", "/", "/((?!_next|favicon.ico|api|.*\\.).*)"], }; ``` ```tsx import { getTokens } from "next-firebase-auth-edge"; import { cookies } from "next/headers"; ``` ```tsx // Since Next.js 15, `cookies()` returns a Promise and must be preceded with `await`. // In Next.js 14, `cookies()` is synchronous — use `getTokens(cookies(), ...)` without `await` on `cookies()`. const tokens = await getTokens(await cookies(), { debug: true, apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' } }); ``` ================================================ FILE: docs/pages/docs/usage/domain-restriction.mdx ================================================ # Firebase API Key Domain Restriction In a production-ready application, it's important to restrict your Firebase API key by domain for security purposes. You can update your API key restrictions in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials). ## Enable Referer Validation To support API key domain restrictions, you need to inform Google APIs about the referer of your requests. If you are using any of the advanced methods like `getCustomIdAndRefreshTokens`, `verifyIdToken`, `handleTokenRefresh`, or `verifyAndRefreshExpiredIdToken` from the [advanced usage](/docs/usage/advanced-usage) section, make sure to pass the `referer` option. The `referer` should be the authorized domain, derived from the request headers. You can use the `getReferer` function (imported from `next-firebase-auth-edge/next/utils`) to extract the referer from the headers of `NextRequest`. ```ts import {getFirebaseAuth} from 'next-firebase-auth-edge/auth'; import {getReferer} from 'next-firebase-auth-edge/next/utils'; import type {NextRequest} from 'next/server'; const {verifyIdToken} = getFirebaseAuth(/*{...}*/); export async function POST(request: NextRequest) { const token = request.headers.get('Authorization')?.split(' ')[1] ?? ''; if (!token) { throw new Error('Unauthenticated'); } await verifyIdToken(token, { referer: getReferer(request.headers) }); //... } ``` ================================================ FILE: docs/pages/docs/usage/firebase-hosting.mdx ================================================ # Usage in Firebase Hosting Environment By default, the Firebase Hosting environment strips all cookies except for `__session`. (See [this StackOverflow thread](https://stackoverflow.com/questions/44929653/firebase-cloud-function-wont-store-cookie-named-other-than-session) for more details.) To use `next-firebase-auth-edge` in Firebase Hosting, you need to set a custom `cookieName` with the value `__session`, as shown in the examples below: > **Next.js 14-15:** Use `middleware.ts` with `export async function middleware(...)` instead of `proxy.ts`. See the [compatibility note](/docs/usage/middleware#nextjs-14-15-compatibility). ```tsx filename="proxy.ts" import { NextRequest, NextResponse } from "next/server"; import { authMiddleware } from "next-firebase-auth-edge"; export async function proxy(request: NextRequest) { return authMiddleware(request, { cookieName: "__session", // This needs to be "__session" to work inside Firebase Hosting loginPath: "/api/login", logoutPath: "/api/logout", apiKey: "XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX", cookieSignatureKeys: ["Key-Should-Be-at-least-32-bytes-in-length"], cookieSerializeOptions: { path: "/", httpOnly: true, secure: false, sameSite: "lax" as const, maxAge: 12 * 60 * 60 * 24, }, serviceAccount: { projectId: "your-firebase-project-id", clientEmail: "firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com", privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", }, }); } export const config = { matcher: ["/api/login", "/api/logout", "/", "/((?!_next|favicon.ico|api|.*\\.).*)"], }; ``` Example [getTokens](/docs/usage/server-components) usage: ```tsx import { getTokens } from "next-firebase-auth-edge"; const tokens = await getTokens(context.req.cookies, { apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: '__session', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], serviceAccount: { projectId: "your-firebase-project-id", clientEmail: "firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com", privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", }, }); ``` ================================================ FILE: docs/pages/docs/usage/get-server-side-props.mdx ================================================ # Usage in getServerSideProps Example usage of [getApiRequestTokens](/docs/usage/pages-router-api-routes) function in [getServerSideProps](https://nextjs.org/docs/pages/building-your-application/data-fetching/get-server-side-props) ```tsx import { GetServerSidePropsContext } from "next"; import { getApiRequestTokens } from "next-firebase-auth-edge"; export async function getServerSideProps(context: GetServerSidePropsContext) { const tokens = await getApiRequestTokens(context.req, { apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' }, // Optional tenantId: 'your-tenant-id', }); return { props: { tokens } }; } ``` ================================================ FILE: docs/pages/docs/usage/index.mdx ================================================ import {Card, Cards} from 'nextra-theme-docs'; import {ChevronRightIcon} from '@heroicons/react/24/outline'; # Usage Guide This page provides comprehensive documentation for the `next-firebase-auth-edge` functions and their use cases. If you prefer a more hands-on learning experience, you can explore these example applications instead: - [Starter Example](/examples#starter) - [Minimal Example](/examples#minimal) ## Sections } title="Authentication Middleware" href="/docs/usage/middleware" /> } title="Server Components" href="/docs/usage/server-components" /> } title="Redirect Helper Functions" href="/docs/usage/redirect-functions" /> } title="App Router API Route Handlers" href="/docs/usage/app-router-api-routes" /> } title="Pages Router API Routes" href="/docs/usage/pages-router-api-routes" /> } title="Usage in getServerSideProps" href="/docs/usage/get-server-side-props" /> } title="Refreshing credentials" href="/docs/usage/refresh-credentials" /> } title="Removing credentials" href="/docs/usage/remove-credentials" /> } title="Using Client-Side APIs" href="/docs/usage/client-side-apis" /> } title="Advanced usage" href="/docs/usage/advanced-usage" /> } title="Usage in Google Cloud Run" href="/docs/usage/cloud-run" /> } title="Usage in Firebase Hosting" href="/docs/usage/firebase-hosting" /> } title="Debug mode" href="/docs/usage/debug-mode" /> } title="Firebase API Key domain restriction" href="/docs/usage/domain-restriction" /> ================================================ FILE: docs/pages/docs/usage/middleware.mdx ================================================ # Authentication Middleware The `authMiddleware` works with the [getTokens](/docs/usage/server-components) function to share valid user credentials between [Next.js Proxy](https://nextjs.org/docs/app/getting-started/proxy) (or [Middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware) in Next.js 14-15) and [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components). ## Key Features 1. Sets up `/api/login` and `/api/logout` endpoints for managing browser authentication cookies. You don't need to create these API routes yourself—the middleware handles it for you. You can rename these endpoints by adjusting the `loginPath` and `logoutPath` options in the middleware settings. 2. Automatically refreshes browser authentication cookies when the token expires, allowing developers to handle it accordingly. 3. Signs user cookies with rotating keys to reduce the risk of cryptanalysis attacks. 4. Validates user cookies on every request. 5. Provides flexibility by allowing you to define custom behavior using the `handleValidToken`, `handleInvalidToken`, and `handleError` callbacks. ## Next.js 14-15 Compatibility Starting from Next.js 16, there are two key API changes: 1. **`middleware.ts` has been renamed to `proxy.ts`** and the exported function should be named `proxy` instead of `middleware`. The proxy runs on the Node.js runtime instead of the Edge runtime. 2. **Async `cookies()` and `headers()` are now mandatory.** Next.js 15 introduced async versions of `cookies()` and `headers()` with temporary synchronous backward compatibility. In Next.js 16, the synchronous fallback is removed — you must use `await cookies()` and `await headers()`. All code examples in the documentation use the **Next.js 15-16** convention with `await cookies()` and `await headers()`. If you are using **Next.js 14 or 15**, replace `proxy.ts` with `middleware.ts` and `export async function proxy(...)` with `export async function middleware(...)`. If you are using **Next.js 14**, you should also remove the `await` before `cookies()` and `headers()` calls, as these functions are synchronous in Next.js 14. The `authMiddleware` function and its options remain the same across all versions. ## Advanced usage Advanced usage of `authMiddleware` in `proxy.ts`, based on [starter example](/examples#starter): ```tsx filename="proxy.ts" import {NextResponse} from 'next/server'; import type {NextRequest} from 'next/server'; import { authMiddleware, redirectToHome, redirectToLogin } from 'next-firebase-auth-edge'; const PUBLIC_PATHS = ['/register', '/login', '/reset-password']; export async function proxy(request: NextRequest) { return authMiddleware(request, { loginPath: '/api/login', logoutPath: '/api/logout', apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], cookieSerializeOptions: { path: '/', httpOnly: true, secure: false, // Set this to true on HTTPS environments sameSite: 'lax' as const, maxAge: 12 * 60 * 60 * 24 // twelve days }, serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' }, enableMultipleCookies: true, enableCustomToken: false, debug: true, tenantId: 'your-tenant-id', checkRevoked: true, authorizationHeaderName: 'Authorization', dynamicCustomClaimsKeys: ['someCustomClaim'], handleValidToken: async ({token, decodedToken}, headers) => { // Authenticated user should not be able to access /login, /register and /reset-password routes if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) { return redirectToHome(request); } return NextResponse.next({ request: { headers } }); }, handleInvalidToken: async (reason) => { console.info('Missing or malformed credentials', {reason}); return redirectToLogin(request, { path: '/login', publicPaths: PUBLIC_PATHS }); }, handleError: async (error) => { console.error('Unhandled authentication error', {error}); return redirectToLogin(request, { path: '/login', publicPaths: PUBLIC_PATHS }); }, getMetadata: async (tokens: TokenSet) => { // Here you can load any data related to the user // The data will be saved in cookies and can be accessed using `getTokens` function. // Note: The cookie size is limited, so keep the data compact return {uid: tokens.decodedIdToken.uid, timestamp: new Date().getTime()}; }, enableTokenRefreshOnExpiredKidHeader: true }); } export const config = { matcher: [ '/api/login', '/api/logout', '/', '/((?!_next|favicon.ico|api|.*\\.)*)' ] }; ``` ## Metadata Starting from v1.10.0, Authentication Middleware allows **you** to store custom data within cookies. The data is signed and verified together with the token, so it's safe to use it to, for example, load user permissions from the database. Example usage: ```tsx filename="proxy.ts" export async function proxy(request: NextRequest) { return authMiddleware(request, { // Store additional data in the cookies getMetadata: async (tokens: TokenSet) => { const roles = await loadRolesFromDb(); return {roles}; } // ...other options }); } ``` Access the data using [getTokens](/docs/usage/server-components): ```tsx const { metadata: {roles} } = await getTokens(await cookies(), authConfig); ``` ## Custom Token Starting from v1.8.0, **custom token is no longer enabled by default**. If you wish to enable custom token, set `enableCustomToken` option to `true` in `authMiddleware`. Custom token introduces a significant footprint on the size of authentication cookie and is not required for most use-cases. It's recommended to use `enableCustomToken` together with `enableMultipleCookies`. `enableMultipleCookies` would split session into multiple cookies, eliminating issues that can come from cookie size, as explained below. ## Multiple Cookies Starting from v1.6.0, the `authMiddleware` supports the `enableMultipleCookies` option. By default, the session data is stored in a single cookie. This works for most cases, but it limits the size of custom claims you can add to a token. Most browsers won't store a cookie larger than [4096 bytes](https://support.convert.com/hc/en-us/articles/4511582623117-Cookie-size-limits-and-the-impact-on-the-use-of-Convert-goals). To prevent cookie size issues, it's recommended to set `enableMultipleCookies` to `true`. When enabled, the session will be split into four cookies: - `${cookieName}.id` – stores the `idToken` - `${cookieName}.refresh` – stores the `refreshToken` - `${cookieName}.custom` – stores the `customToken` - `${cookieName}.sig` – stores the `signature` used to validate the tokens ### Firebase Hosting and Multiple Cookies If you're using [Firebase Hosting](/docs/usage/firebase-hosting), set `enableMultipleCookies` to `false`. Due to an issue with Firebase Hosting ([details here](https://stackoverflow.com/questions/44929653/firebase-cloud-function-wont-store-cookie-named-other-than-session)), multiple cookies are not supported. If you run into cookie size problems on Firebase Hosting, you may want to either reduce the size of custom claims in your tokens or consider switching to a different hosting provider. ## Middleware Token Verification Caching Since v0.9.0, the `handleValidToken` function is called with modified request `headers` as a second parameter. You can pass this headers object to `NextResponse.next({ request: { headers } })` to enable token verification caching. For more details on modifying request headers in middleware, check out [Modifying Request Headers in Middleware](https://vercel.com/templates/next.js/edge-functions-modify-request-header). The example below shows a simplified version of how you can combine other middleware with `next-firebase-auth-edge`. ```tsx handleValidToken: async ({token, decodedToken, customToken}, headers) => { return NextResponse.next({ request: { headers // Pass modified request headers to skip token verification in subsequent getTokens and getApiRequestTokens calls } }); }; ``` ## Usage with `next-intl` and other middlewares ```tsx filename="proxy.ts" import type {NextRequest} from 'next/server'; import createIntlMiddleware from 'next-intl/middleware'; import {authMiddleware} from 'next-firebase-auth-edge'; const intlMiddleware = createIntlMiddleware({ locales: ['en', 'pl'], defaultLocale: 'en' }); export async function proxy(request: NextRequest) { return authMiddleware(request, { // ... handleValidToken: async (tokens) => { return intlMiddleware(request); }, handleInvalidToken: async (reason) => { return intlMiddleware(request); }, handleError: async (error) => { return intlMiddleware(request); } }); } ``` **Note:** When using `next-intl` middleware, you don't need to pass `headers` like you would in [middleware token verification caching](https://next-firebase-auth-edge-docs.vercel.app/docs/usage/middleware#middleware-token-verification-caching). By the time we call `intlMiddleware`, the `request` already has the updated headers. `next-intl` will handle passing the modified headers for you. If you're experiencing issues with redirects while using `next-intl` middleware, check out [this comment on GitHub](https://github.com/awinogrodzki/next-firebase-auth-edge/issues/169#issuecomment-2076683598) for code examples. ## Options | Name | Required? | Type/Default | Description | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | loginPath | **Required** | | Defines the API login endpoint. When called with a Firebase auth token from the client, it responds with `Set-Cookie` headers containing signed ID and refresh tokens. | | logoutPath | **Required** | | Defines the API logout endpoint. When called from the client, it returns empty `Set-Cookie` headers that clear any previously set credentials. | | apiKey | **Required** | | The Firebase project API key, used to fetch Firebase ID and refresh tokens. | | cookieName | **Required** | | The name of the cookie set by the `loginPath` API route. | | cookieSignatureKeys | **Required** | | [Rotating keys](https://developer.okta.com/docs/concepts/key-rotation/#:~:text=Key%20rotation%20is%20when%20a,and%20follows%20cryptographic%20best%20practices.) used to validate the cookie. | | cookieSerializeOptions | **Required** | | Defines additional options for the `Set-Cookie` headers. | | serviceAccount | Optional in authenticated [Google Cloud Run](https://cloud.google.com/run) environments. Otherwise **required** | | The Firebase project service account. | | enableMultipleCookies | Optional | `boolean`, defaults to `false` | Splits session tokens into multiple cookies to increase token claims capacity. Recommended, but defaults to `false` for backwards compatibility. Set to `false` on Firebase Hosting due to limitations like [this](https://stackoverflow.com/questions/44929653/firebase-cloud-function-wont-store-cookie-named-other-than-session). | | enableCustomToken | Optional | `boolean`, defaults to `false` | If enabled, authentication cookie would contain custom token. This is helpful if you want to use [signInWithCustomToken](https://firebase.google.com/docs/auth/web/custom-auth) Firebase Client SDK method | | tenantId | Optional | `string`, defaults to `undefined` | The Google Cloud Platform tenant identifier. Specify if your project supports [multi-tenancy](https://cloud.google.com/identity-platform/docs/multi-tenancy-authentication). | | authorizationHeaderName | Optional | `string`, defaults to `Authorization` | The name of the authorization header expected by the login endpoint. | | checkRevoked | Optional | `boolean`, defaults to `false` | If `true`, validates the token against the Firebase server on every request. Unless there's a specific need, it's usually better not to use this. | | handleValidToken | Optional | `(tokens: { token: string, decodedToken: DecodedIdToken, customToken?: string }, headers: Headers) => Promise`, defaults to `NextResponse.next()` | Called when a valid token is received. Should return a promise resolving with `NextResponse`. It's passed modified request `headers` as a second parameter, which, if forwarded with `NextResponse.next({ request: { headers } })`, prevents re-verification of the token in subsequent calls, improving response times. | | handleInvalidToken | Optional | `(reason: InvalidTokenReason) => Promise`, defaults to `NextResponse.next()` | Called when a request is unauthenticated (either missing credentials or has invalid credentials). Can be used to redirect users to a specific page. The `reason` argument can be one of `MISSING_CREDENTIALS`, `MISSING_REFRESH_TOKEN`, `MALFORMED_CREDENTIALS`, `INVALID_SIGNATURE`, `INVALID_KID` or `INVALID_CREDENTIALS`. See the [handleInvalidToken section](/docs/errors#handleinvalidtoken) for details on each reason. | | handleError | Optional | `(error: AuthError) => Promise`, defaults to `NextResponse.next()` | Called when an unhandled error occurs during authentication. By default, the app will render, but you can customize error handling here. See the [handleError section](/docs/errors#handleerror) for more information on possible errors. | | getMetadata | Optional | `(tokens: TokenSet) => Promise` | Called when user signs in or the credentials are refreshed. Can be used to load user data from external sources and save it inside the cookies. Metadata can be accessed using `metadata` property returned by [getTokens](/docs/usage/server-components) | | debug | Optional | `boolean`, defaults to `false` | Enables helpful logs for better understanding and debugging of the authentication flow. | | dynamicCustomClaimsKeys | Optional | `string[]`, defaults to `undefined` | By default, when you update custom user claims using `setCustomUserClaims`, the changes will only take effect after the user logs in again. If you want the updated claim to be available immediately after refreshing the token (e.g., after handling `refreshNextResponseCookies`), you need to include the claim key in `dynamicCustomClaimsKeys`. | | enableTokenRefreshOnExpiredKidHeader | Optional | `boolean`, defaults to `true` | By default, [when Google public keys expire](https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_a_third-party_jwt_library) the token is refreshed automatically. Set `false` to opt-out from automatic refresh. When opted-out, `authMiddleware` will call [handleInvalidToken](/docs/errors#handleinvalidtoken) with `INVALID_KID` reason, allowing user to be safely redirected to sign in page. | ================================================ FILE: docs/pages/docs/usage/pages-router-api-routes.mdx ================================================ # Pages Router API Routes To support gradual adoption of the latest Next.js features, `next-firebase-auth-edge` offers the `getApiRequestTokens` function. This function is designed to work with [getServerSideProps](/docs/usage/get-server-side-props) and [API Routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes). The `getApiRequestTokens` function works similarly to `getTokens`, but it's specifically for extracting cookie information from the `req` object. ```tsx import { NextApiRequest, NextApiResponse } from "next"; import { getApiRequestTokens } from "next-firebase-auth-edge"; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { const tokens = await getApiRequestTokens(req, { apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' } }); if (!tokens) { throw new Error('Unauthenticated'); } return res.status(200).json({ tokens }); } ``` ================================================ FILE: docs/pages/docs/usage/redirect-functions.mdx ================================================ # Redirect Helper Functions This library provides a set of helper functions to simplify handling common redirect scenarios. These redirect functions make it easier to create a [redirect response in Next.js Middleware](https://nextjs.org/docs/app/building-your-application/routing/redirecting#nextresponseredirect-in-middleware). For example, here’s how the `redirectToPath` helper function works: ```ts export function redirectToPath( request: NextRequest, path: string, options: RedirectToPathOptions = {shouldClearSearchParams: false} ) { const url = request.nextUrl.clone(); url.pathname = path; if (options.shouldClearSearchParams) { url.search = ''; } return NextResponse.redirect(url); } ``` It’s a straightforward function that builds a `NextResponse` object for handling redirects. **Note:** You don’t have to use the library's redirect functions—you can always implement your own redirect logic if it better suits your needs. ## redirectToPath Example usage ```ts import {redirectToPath} from 'next-firebase-auth-edge'; ``` ```ts redirectToPath(request, '/dashboard', {shouldClearSearchParams: true}); ``` ## redirectToHome `redirectToHome` is a simplified version of `redirectToPath` that redirects the user to the home page (`/`). ```ts import {redirectToHome} from 'next-firebase-auth-edge'; ``` ```ts redirectToHome(request); ``` ## redirectToLogin `redirectToLogin` redirects unauthenticated users to a public login page. ### Public paths `redirectToLogin` will skip the redirect if the request matches one of the paths listed in `publicPaths`. ```ts import {redirectToLogin} from 'next-firebase-auth-edge'; ``` ```ts redirectToLogin(request, { path: '/sign-in', publicPaths: ['/sign-in', '/register', /^\/post\/(\w+)/] }); ``` ### Private paths `redirectToLogin` will skip the redirect if the request does not match one of the paths listed in `privatePaths`. ```ts import {redirectToLogin} from 'next-firebase-auth-edge'; ``` ```ts redirectToLogin(request, { path: '/sign-in', privatePaths: ['/dashboard', /^\/settings\/(\w+)/] }); ``` ================================================ FILE: docs/pages/docs/usage/refresh-credentials.mdx ================================================ # Refreshing Credentials `next-firebase-auth-edge` provides three different functions for updating user credentials after changes to the user token structure (e.g., adding new user claims). ## Refresh Credentials in Proxy/Middleware To refresh credentials in [Next.js Proxy](https://nextjs.org/docs/app/getting-started/proxy) (or [Middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware) in Next.js 14-15) after updating the user token, use the `refreshCredentials` function from `next-firebase-auth-edge/next/cookies`. > **Next.js 14-15:** Use `middleware.ts` with `export async function middleware(...)` instead of `proxy.ts`. See the [compatibility note](/docs/usage/middleware#nextjs-14-15-compatibility). ```tsx filename="proxy.ts" import type {NextRequest} from 'next/server'; import {authMiddleware} from 'next-firebase-auth-edge'; import {refreshCredentials} from 'next-firebase-auth-edge/next/cookies'; const commonOptions = { apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], cookieSerializeOptions: { path: '/', httpOnly: true, secure: false, // Set this to true on HTTPS environments sameSite: 'strict' as const, maxAge: 12 * 60 * 60 * 24 // twelve days }, serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' } }; export async function proxy(request: NextRequest) { return authMiddleware(request, { handleValidToken: async ({decodedToken}, headers) => { const shouldRefreshCredentials = await makeSomeComputationsToDeduceIfUserCredentialsShouldBeUpdated( decodedToken.uid ); if (shouldRefreshCredentials) { return refreshCredentials( request, commonOptions, ({headers, tokens}) => { // Optionally perform additional verification on refreshed `tokens`... return NextResponse.next({ request: { headers } }); } ); } return NextResponse.next({ request: { headers } }); }, ...commonOptions }); } export const config = { matcher: [ '/', '/((?!_next|favicon.ico|api|.*\\.).*)', '/api/login', '/api/logout' ] }; ``` ### refreshCredentials The `refreshCredentials` function is useful when you need to update user credentials after some asynchronous action that affects the user token structure (e.g., a cron job or event queue that updates custom claims). In this example, `makeSomeComputationsToDeduceIfUserCredentialsShouldBeUpdated` is a fictional function used to quickly check if user credentials need updating. For example, it might check a distributed cache. When you call `refreshCredentials`, it performs three actions: 1. It generates a new token based on the existing credentials, including any updated claims. 2. It allows the developer to create a new `NextResponse` with [Modified Request Headers](https://vercel.com/templates/next.js/edge-functions-modify-request-header). Passing the modified request headers ensures that `getTokens` will return the fresh token within **a single request**. 3. It updates the `NextResponse` with `Set-Cookie` headers that contain the latest credentials. Here’s the function signature: ```tsx async function refreshCredentials( request: NextRequest, options: SetAuthCookiesOptions, responseFactory: (options: { headers: Headers; tokens: VerifiedCookies; metadata: Metadata; }) => NextResponse | Promise ): Promise; ``` #### Response Factory Options | Name | Description | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | headers | [Modified Request Headers](https://vercel.com/templates/next.js/edge-functions-modify-request-header). Passing these modified headers ensures that `getTokens` will return the updated token within **a single request**. | | tokens | A `VerifiedCookies` object, containing the values for `idToken`, `refreshToken`, and `decodedIdToken`. It also returns `customToken` if you passed `enableCustomToken` flag to `authMiddleware`. | | metadata | A `Metadata` object. `Metadata` is whatever `getMetadata` callback provided to `authMiddleware` resolves with. | ## Refresh Auth Cookies in API Route Handlers To refresh authentication cookies after updating a user token in [API Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), use `refreshNextResponseCookies` from `next-firebase-auth-edge/next/cookies`. ```tsx import {NextResponse} from 'next/server'; import type {NextRequest} from 'next/server'; import {refreshNextResponseCookies} from 'next-firebase-auth-edge/next/cookies'; import {getFirebaseAuth, getTokens} from 'next-firebase-auth-edge'; const commonOptions = { apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], cookieSerializeOptions: { path: '/', httpOnly: true, secure: false, // Set this to true on HTTPS environments sameSite: 'strict' as const, maxAge: 12 * 60 * 60 * 24 // twelve days }, serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' } }; const {setCustomUserClaims, getUser} = getFirebaseAuth({ serviceAccount: commonOptions.serviceAccount, apiKey: commonOptions.apiKey }); export async function POST(request: NextRequest) { const tokens = await getTokens(request.cookies, commonOptions); if (!tokens) { throw new Error('Cannot update custom claims of unauthenticated user'); } await setCustomUserClaims(tokens.decodedToken.uid, { someCustomClaim: { updatedAt: Date.now() } }); const user = await getUser(tokens.decodedToken.uid); const response = new NextResponse( JSON.stringify({ customClaims: user.customClaims }), { status: 200, headers: {'content-type': 'application/json'} } ); return refreshNextResponseCookies(request, response, commonOptions); } ``` ## Refresh Auth Cookies in API Route Handlers with an ID Token Extracted from the Authorization Header To refresh authentication cookies using the token string extracted from the Authorization header, use `refreshNextResponseCookiesWithToken` from `next-firebase-auth-edge/next/cookies`. ```tsx import type {NextRequest} from 'next/server'; import {refreshNextResponseCookiesWithToken} from 'next-firebase-auth-edge/next/cookies'; const commonOptions = { apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], cookieSerializeOptions: { path: '/', httpOnly: true, secure: false, // Set this to true on HTTPS environments sameSite: 'strict' as const, maxAge: 12 * 60 * 60 * 24 // twelve days }, serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' } }; export async function POST(request: NextRequest) { const token = request.headers.get('Authorization')?.split(' ')[1] ?? ''; if (!token) { throw new Error('Unauthenticated'); } return refreshNextResponseCookiesWithToken( token, request, response, commonOptions ); } ``` ## Refresh Auth Cookies in Pages Router API Routes To refresh authentication cookies after updating a user token in [API Routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes), use `refreshApiResponseCookies` from `next-firebase-auth-edge/next/api`. ```tsx import {NextApiRequest, NextApiResponse} from 'next'; import {refreshApiResponseCookies} from 'next-firebase-auth-edge/next/api'; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { await refreshApiResponseCookies(req, res, { serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' }, apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], cookieSerializeOptions: { path: '/', httpOnly: true, secure: false, // Set this to true on HTTPS environments sameSite: 'strict' as const, maxAge: 12 * 60 * 60 * 24 // twelve days } }); res.status(200).json({example: true}); } ``` ## Refresh Auth Cookies in Server Actions To refresh authentication cookies after updating user credentials in [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations), use `refreshServerCookies` from `next-firebase-auth-edge/next/cookies`. ```tsx 'use server'; import {cookies, headers} from 'next/headers'; import {getTokens} from 'next-firebase-auth-edge'; import {refreshServerCookies} from 'next-firebase-auth-edge/next/cookies'; const commonOptions = { apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], cookieSerializeOptions: { path: '/', httpOnly: true, secure: false, // Set this to true on HTTPS environments sameSite: 'strict' as const, maxAge: 12 * 60 * 60 * 24 // twelve days }, serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' } }; export async function performServerAction() { const tokens = await getTokens(await cookies(), commonOptions); if (!tokens) { throw new Error('Unauthenticated'); } // headers() needs to be wrapped with new Headers() to work in Server Actions await refreshServerCookies( await cookies(), new Headers(await headers()), commonOptions ); } ``` ================================================ FILE: docs/pages/docs/usage/remove-credentials.mdx ================================================ # Removing Credentials The `next-firebase-auth-edge` library provides a `removeCookies` method to remove authenticated cookies within Middleware. This is useful for situations where you want to explicitly log a user out of the app. ## Removing Credentials in Middleware or API routes To remove authenticated cookies in Middleware or API routes, use the `removeCookies` function from `next-firebase-auth-edge/next/cookies`. This will attach expired `Set-Cookie` headers to the response, prompting the browser to delete the authenticated cookies. ```tsx import {NextRequest, NextResponse} from 'next/server'; import {removeCookies} from 'next-firebase-auth-edge/next/cookies'; //... function forceLogout(request: NextRequest) { const response = NextResponse.redirect(new URL('/login', request.url)); removeCookies(request.headers, response, { cookieName: 'AuthToken', cookieSerializeOptions: { path: '/', httpOnly: true, secure: false, sameSite: 'lax' as const, maxAge: 12 * 60 * 60 * 24 } }); return response; } ``` ## Removing Credentials in Server Actions To remove authenticated cookies in Server Actions, use the `removeServerCookies` function from `next-firebase-auth-edge/next/cookies`. This will remove authentication cookies using `cookies.delete` method on Next.js cookies object ```tsx import {NextRequest, NextResponse} from 'next/server'; import {cookies} from 'next/headers'; import {removeServerCookies} from 'next-firebase-auth-edge/next/cookies'; // Since Next.js 15, `cookies()` returns a Promise and must be preceded with `await`. // In Next.js 14, `cookies()` is synchronous — call it without `await`. removeServerCookies(await cookies(), { cookieName: 'AuthToken', }); ``` ================================================ FILE: docs/pages/docs/usage/server-components.mdx ================================================ # Usage in Server Components The library provides a `getTokens` function to extract and validate user credentials. This function can only be used in `Server Components` or [API Route Handlers](/docs/usage/app-router-api-routes). It returns `null` if there are no authentication cookies or if the credentials have expired. If the request contains valid credentials, the function returns an object with `token`, `decodedToken`. The object can contain `customToken`, if you passed `enableCustomToken` flag to `authMiddleware`. The `token` is a JWT-encoded string, while `decodedToken` is the decoded object representation of that token. ## getTokens Here’s an example of how to use the `getTokens` function from `next-firebase-auth-edge`: ```tsx import {getTokens} from 'next-firebase-auth-edge'; import {cookies, headers} from 'next/headers'; import {notFound} from 'next/navigation'; export default async function ServerComponentExample() { // Since Next.js 15, `cookies()` returns a Promise and must be preceded with `await`. // In Next.js 14, `cookies()` is synchronous — use `getTokens(cookies(), ...)` without `await` on `cookies()`. const tokens = await getTokens(await cookies(), { apiKey: 'XXxxXxXXXxXxxxxx_XxxxXxxxxxXxxxXXXxxXxX', cookieName: 'AuthToken', cookieSignatureKeys: ['Key-Should-Be-at-least-32-bytes-in-length'], serviceAccount: { projectId: 'your-firebase-project-id', clientEmail: 'firebase-adminsdk-nnw48@your-firebase-project-id.iam.gserviceaccount.com', privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' }, tenantId: 'your-tenant-id' }); if (!tokens) { return notFound(); } const {token, decodedToken, customToken, metadata} = tokens; return (

Valid token: {token}
User email: {decodedToken.email}
Custom token, if you enabled custom token support by passing `enableCustomToken` flag to `authMiddleware`: {customToken}
Metadata:

          {JSON.stringify(metadata, undefined, 2)}
        

); } ``` ### Required Options | Name | Description | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | apiKey | **Required**. The Firebase Web API Key, which you can find on the Firebase Project settings overview page. Keep in mind, this API key will only be visible once you enable Firebase Authentication. | | serviceAccount | Optional in authenticated [Google Cloud Run](https://cloud.google.com/run) environments. Otherwise, **required**. This refers to the Firebase Service Account credentials. | | cookieName | **Required**. The name of the cookie set by the `loginPath` API route. | | cookieSignatureKeys | **Required**. These are [rotating keys](https://developer.okta.com/docs/concepts/key-rotation/#:~:text=Key%20rotation%20is%20when%20a,and%20follows%20cryptographic%20best%20practices) used to validate the cookie. | ### Optional Options | Name | Description | | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | tenantId | **Optional** `string`. Specify this if your project supports [multi-tenancy](https://cloud.google.com/identity-platform/docs/multi-tenancy-authentication). | ### Metadata `getTokens` can return `metadata` property, which is a custom data that can be saved within the cookies using `getMetadata` property passed to [Authentication Middleware](/docs/usage/middleware#metadata). `getMetadata` is called when user logs in or the credential are refreshed. The resulting object is then saved within user cookies. ================================================ FILE: docs/pages/examples/index.mdx ================================================ import Example from 'components/Example'; # Examples
================================================ FILE: docs/pages/index.mdx ================================================ --- title: next-firebase-auth-edge --- import Hero from 'components/Hero';
================================================ FILE: docs/postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } }; ================================================ FILE: docs/prettier.config.js ================================================ module.exports = { singleQuote: true, trailingComma: 'none', bracketSpacing: false }; ================================================ FILE: docs/public/favicon/site.webmanifest ================================================ { "name": "", "short_name": "", "icons": [ { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } ], "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" } ================================================ FILE: docs/services/BrowserTracker.tsx ================================================ import * as vercel from '@vercel/analytics/react'; type Event = { name: 'partner-referral'; data: {href: string; name: string}; }; export default class BrowserTracker { public static trackEvent({data, name}: Event) { const promises = []; if (typeof window !== 'undefined') { const umami = (window as any).umami; if (umami) { promises.push(umami.track(name, data)); } else { console.warn('umami not loaded'); } } promises.push(vercel.track(name, data)); return Promise.all(promises); } } ================================================ FILE: docs/services/ServerTracker.tsx ================================================ import * as vercel from '@vercel/analytics/server'; export default class ServerTracker { private static postToCollect({ auth, body, request }: { body: { type: 'pageview' | 'event'; payload?: any; }; auth?: string; request: Request; }) { const referer = request.headers.get('referer'); const requestUrl = new URL(request.url); const language = request.headers .get('accept-language') ?.split(',') .at(0) ?.replace(/;.*/, ''); const headers = new Headers(); headers.set('content-type', 'application/json'); if (referer) { headers.set('referer', referer); } if (auth) { headers.set('x-umami-auth', auth); } return fetch(process.env.UMAMI_URL + '/api/collect', { method: 'POST', headers, body: JSON.stringify({ ...body, payload: { language, website: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, hostname: requestUrl.hostname, url: requestUrl.pathname, ...body.payload } }) }).then((res) => res.text().then((nextAuth) => { if (!nextAuth || !res.ok) { throw new Error( 'Failed to track umami event: ' + res.status + nextAuth + ' ' ); } return nextAuth; }) ); } private static createAuth(request: Request) { return ServerTracker.postToCollect({ request, body: { type: 'pageview', payload: {url: request.url} } }); } public static async trackEvent({ data, name, request }: { data?: Record; name: string; request: Request; }) { const promises = []; promises.push( vercel.track(name, data).catch((error) => { throw new Error('Vercel tracking failed', {cause: error}); }) ); promises.push( ServerTracker.postToCollect({ request, auth: await ServerTracker.createAuth(request), body: { type: 'event', payload: { event_name: name, event_data: data } } }).catch((error) => { throw new Error('Umami tracking failed', {cause: error}); }) ); return Promise.all(promises); } } ================================================ FILE: docs/styles.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; :root { --shiki-token-punctuation: rgba(0, 0, 0, 0.5); --shiki-token-comment: rgba(100, 116, 139, 0.8); } .dark { --shiki-token-string-expression: hsl(160, 75%, 45%); --shiki-token-punctuation: rgba(255, 255, 255, 0.5); } /** * Navbar on home */ .navbar-home { @apply bg-slate-800 text-white dark:bg-slate-800; } /* Hamburger menu */ .navbar-home svg.open { @apply !text-slate-900 dark:!text-white; } .navbar-home nav > a { @apply !text-white/80 transition-opacity hover:opacity-70; } .navbar-home *:not(.nextra-scrollbar) a, .navbar-home *:not(.nextra-scrollbar) button { @apply !text-inherit; } html:not(.dark) .navbar-home .nextra-scrollbar { @apply !bg-white; } .navbar-home input::placeholder { @apply !text-white/50; } .navbar-home input { @apply !bg-slate-900 !text-white; } .navbar-home .nextra-nav-container-blur { display: none; } /** * Typography */ figure { @apply my-8 flex flex-col items-center; } figure .nextra-code-block { @apply w-full; } figure .nextra-code-block > pre { @apply mb-0; } figcaption { @apply mt-4 text-center text-sm text-slate-500; } summary { @apply font-bold; } details > div > div { @apply pt-4 pl-7 pr-3; } details > div > div:last-child { @apply mb-4; } ================================================ FILE: docs/tailwind.config.js ================================================ /** @type {import('tailwindcss').Config} */ module.exports = { darkMode: 'class', content: [ './components/**/*.{js,tsx}', './pages/**/*.{md,mdx}', './theme.config.{js,tsx}' ], theme: { extend: { fontSize: { '2xs': ['0.69rem', { lineHeight: '1' }], '5xl': ['3rem', { lineHeight: '1.2' }] }, colors: { slate: { 50: "#E1F8F9", 100: "#C3F2F4", 200: "#87E5E8", 300: "#50D9DD", 400: "#25BBC1", 500: "#198185", 600: "#0E4749", 700: "#0B3638", 800: "#072527", 900: "#031111", 950: "#020809" }, gray: { 50: "#E1F8F9", 100: "#C3F2F4", 200: "#87E5E8", 300: "#50D9DD", 400: "#25BBC1", 500: "#198185", 600: "#0E4749", 700: "#0B3638", 800: "#072527", 900: "#031111", 950: "#020809" }, primary: '#B3F2DD' } }, fontFamily: { sans: ['Inter', 'sans-serif'], mono: [ 'Monaco', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace' ] } } }; ================================================ FILE: docs/theme.config.tsx ================================================ import Footer from 'components/Footer'; import { useRouter } from 'next/router'; import { ThemeConfig } from 'nextra'; import { Navbar, ThemeSwitch, useConfig } from 'nextra-theme-docs'; import { ComponentProps } from 'react'; import config from './config'; const logo = ( ); export default { project: { link: config.githubUrl }, docsRepositoryBase: config.githubUrl + '/blob/main/docs', useNextSeoProps() { return { titleTemplate: '%s – Firebase Authentication for Next.js 16' }; }, // banner: { // text: ( // <> // // ) // }, primaryHue: { light: 210, dark: 195 }, footer: { component: Footer }, navigation: true, darkMode: true, logo, sidebar: { autoCollapse: true, defaultMenuCollapseLevel: 1 }, themeSwitch: { component(props: ComponentProps) { return (
); } }, navbar: { component: function CustomNavbar(props: ComponentProps) { const router = useRouter(); const isRoot = router.pathname === '/'; if (!isRoot) return ; return (
); } }, feedback: { content: 'Provide feedback on this page', useLink: () => { const router = useRouter(); const pageConfig = useConfig(); const url = new URL(config.githubUrl); url.pathname += '/issues/new'; url.searchParams.set('title', `[Docs]: ${pageConfig.title}`); url.searchParams.set('template', 'update_docs.yml'); url.searchParams.set('pageLink', config.baseUrl + router.pathname); return url.href; } }, head: () => ( <> ) } satisfies ThemeConfig; ================================================ FILE: docs/tsconfig.json ================================================ { "extends": "eslint-config-molindo/tsconfig.json", "compilerOptions": { "baseUrl": ".", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "incremental": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "plugins": [ { "name": "next" } ], "strict": true }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts" ], "exclude": [ "node_modules" ] } ================================================ FILE: eslint.config.mjs ================================================ import eslint from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import tseslint from 'typescript-eslint'; export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, eslintPluginPrettierRecommended, { files: [ "**/*.ts", "**/*.tsx" ], ignores: [ "node_modules", "lib" ], rules: { 'prettier/prettier': 'error', }, } ); ================================================ FILE: examples/next-typescript-minimal/.eslintrc.json ================================================ { "extends": "next/core-web-vitals" } ================================================ FILE: examples/next-typescript-minimal/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js .yarn/install-state.gz # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: examples/next-typescript-minimal/README.md ================================================ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). ## Getting Started First, run the development server: ```bash npm run dev # or yarn dev # or pnpm dev # or bun dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. ================================================ FILE: examples/next-typescript-minimal/app/HomePage.tsx ================================================ "use client"; import { useRouter } from "next/navigation"; import { getAuth, signOut } from "firebase/auth"; import { app } from "../firebase"; interface HomePageProps { email?: string; } export default function HomePage({ email }: HomePageProps) { const router = useRouter(); async function handleLogout() { await signOut(getAuth(app)); await fetch("/api/logout"); router.push("/login"); } return (

Super secure home page

Only {email} holds the magic key to this kingdom!

); } ================================================ FILE: examples/next-typescript-minimal/app/globals.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; :root { --foreground-rgb: 0, 0, 0; --background-start-rgb: 214, 219, 220; --background-end-rgb: 255, 255, 255; } @media (prefers-color-scheme: dark) { :root { --foreground-rgb: 255, 255, 255; --background-start-rgb: 0, 0, 0; --background-end-rgb: 0, 0, 0; } } body { color: rgb(var(--foreground-rgb)); background: linear-gradient( to bottom, transparent, rgb(var(--background-end-rgb)) ) rgb(var(--background-start-rgb)); } @layer utilities { .text-balance { text-wrap: balance; } } ================================================ FILE: examples/next-typescript-minimal/app/layout.tsx ================================================ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( {children} ); } ================================================ FILE: examples/next-typescript-minimal/app/login/page.tsx ================================================ "use client"; import { FormEvent, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { getAuth, signInWithEmailAndPassword } from "firebase/auth"; import { app } from "../../firebase"; export default function Login() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const router = useRouter(); async function handleSubmit(event: FormEvent) { event.preventDefault(); setError(""); try { const credential = await signInWithEmailAndPassword( getAuth(app), email, password ); const idToken = await credential.user.getIdToken(); await fetch("/api/login", { headers: { Authorization: `Bearer ${idToken}`, }, }); router.push("/"); } catch (e) { setError((e as Error).message); } } return (

Speak thy secret word!

setEmail(e.target.value)} id="email" className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="name@company.com" required />
setPassword(e.target.value)} id="password" placeholder="••••••••" className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required />
{error && (
{error}
)}

Don't have an account?{" "} Register here

); } ================================================ FILE: examples/next-typescript-minimal/app/page.tsx ================================================ import { getTokens } from "next-firebase-auth-edge"; import { cookies } from "next/headers"; import { notFound } from "next/navigation"; import { clientConfig, serverConfig } from "../config"; import HomePage from "./HomePage"; export default async function Home() { const tokens = await getTokens(cookies(), { apiKey: clientConfig.apiKey, cookieName: serverConfig.cookieName, cookieSignatureKeys: serverConfig.cookieSignatureKeys, serviceAccount: serverConfig.serviceAccount, }); if (!tokens) { notFound(); } return ; } ================================================ FILE: examples/next-typescript-minimal/app/register/page.tsx ================================================ "use client"; import { FormEvent, useState } from "react"; import Link from "next/link"; import { getAuth, createUserWithEmailAndPassword } from "firebase/auth"; import { app } from "../../firebase"; import { useRouter } from "next/navigation"; export default function Register() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmation, setConfirmation] = useState(""); const [error, setError] = useState(""); const router = useRouter(); async function handleSubmit(event: FormEvent) { event.preventDefault(); setError(""); if (password !== confirmation) { setError("Passwords don't match"); return; } try { await createUserWithEmailAndPassword(getAuth(app), email, password); router.push("/login"); } catch (e) { setError((e as Error).message); } } return (

Pray tell, who be this gallant soul seeking entry to mine humble abode?

setEmail(e.target.value)} id="email" className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="name@company.com" required />
setPassword(e.target.value)} id="password" placeholder="••••••••" className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required />
setConfirmation(e.target.value)} id="confirm-password" placeholder="••••••••" className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required />
{error && (
{error}
)}

Already have an account?{" "} Login here

); } ================================================ FILE: examples/next-typescript-minimal/config.ts ================================================ export const serverConfig = { cookieName: process.env.AUTH_COOKIE_NAME!, cookieSignatureKeys: [process.env.AUTH_COOKIE_SIGNATURE_KEY_CURRENT!, process.env.AUTH_COOKIE_SIGNATURE_KEY_PREVIOUS!], cookieSerializeOptions: { path: "/", httpOnly: true, secure: process.env.USE_SECURE_COOKIES === "true", sameSite: "lax" as const, maxAge: 12 * 60 * 60 * 24, }, serviceAccount: { projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!, clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL!, privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY?.replace(/\\n/g, "\n")!, } }; export const clientConfig = { projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!, authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL, messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }; ================================================ FILE: examples/next-typescript-minimal/firebase.ts ================================================ import { initializeApp } from 'firebase/app'; import { clientConfig } from './config'; export const app = initializeApp(clientConfig); ================================================ FILE: examples/next-typescript-minimal/middleware.ts ================================================ import { NextRequest, NextResponse } from "next/server"; import { authMiddleware, redirectToHome, redirectToLogin } from "next-firebase-auth-edge"; import { clientConfig, serverConfig } from "./config"; const PUBLIC_PATHS = ['/register', '/login']; export async function middleware(request: NextRequest) { return authMiddleware(request, { loginPath: "/api/login", logoutPath: "/api/logout", apiKey: clientConfig.apiKey, cookieName: serverConfig.cookieName, cookieSignatureKeys: serverConfig.cookieSignatureKeys, cookieSerializeOptions: serverConfig.cookieSerializeOptions, serviceAccount: serverConfig.serviceAccount, handleValidToken: async ({token, decodedToken, customToken}, headers) => { // Authenticated user should not be able to access /login, /register and /reset-password routes if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) { return redirectToHome(request); } return NextResponse.next({ request: { headers } }); }, handleInvalidToken: async (reason) => { console.info('Missing or malformed credentials', {reason}); return redirectToLogin(request, { path: '/login', publicPaths: PUBLIC_PATHS }); }, handleError: async (error) => { console.error('Unhandled authentication error', {error}); return redirectToLogin(request, { path: '/login', publicPaths: PUBLIC_PATHS }); } }); } export const config = { matcher: [ "/", "/((?!_next|api|.*\\.).*)", "/api/login", "/api/logout", ], }; ================================================ FILE: examples/next-typescript-minimal/next.config.mjs ================================================ /** @type {import('next').NextConfig} */ const nextConfig = {}; export default nextConfig; ================================================ FILE: examples/next-typescript-minimal/package.json ================================================ { "name": "my-app", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" }, "dependencies": { "firebase": "^10.9.0", "next": "14.1.4", "next-firebase-auth-edge": "^1.4.1", "react": "^18", "react-dom": "^18" }, "devDependencies": { "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", "eslint": "^8", "eslint-config-next": "14.1.4", "postcss": "^8", "tailwindcss": "^3.3.0", "typescript": "^5" } } ================================================ FILE: examples/next-typescript-minimal/postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ================================================ FILE: examples/next-typescript-minimal/tailwind.config.ts ================================================ import type { Config } from "tailwindcss"; const config: Config = { content: [ "./pages/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: { backgroundImage: { "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", "gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", }, }, }, plugins: [], }; export default config; ================================================ FILE: examples/next-typescript-minimal/tsconfig.json ================================================ { "compilerOptions": { "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } ================================================ FILE: examples/next-typescript-starter/.dockerignore ================================================ Dockerfile .dockerignore node_modules npm-debug.log ================================================ FILE: examples/next-typescript-starter/.firebaserc ================================================ { "projects": { "default": "fake-project" } } ================================================ FILE: examples/next-typescript-starter/.gitignore ================================================ node_modules .vscode yarn-error.log .yarn .next .env .env.local .env.temp lib cjs tmp package-lock.json coverage webpack-stats.json .turbo .DS_Store .idea *.log dist .firebase ================================================ FILE: examples/next-typescript-starter/Dockerfile ================================================ FROM node:18.19.0-alpine WORKDIR /usr/app COPY . . RUN yarn install --production RUN printenv RUN yarn build CMD [ "yarn", "start" ] ================================================ FILE: examples/next-typescript-starter/README.md ================================================ # next-firebase-auth-edge starter This is a [Next.js](https://nextjs.org/) project showcasing `next-firebase-auth-edge` library features. Demo of this project can be previewed at [next-firebase-auth-edge-starter.vercel.app](https://next-firebase-auth-edge-starter.vercel.app) ## Before Getting Started To properly run this example, you will need to setup a new Firebase Project. You will also need to: - Create a new web app in your new Firebase Project. - Add Firebase Auth to your project and enable Google, Email/Password and Email Link sign-in methods - Add `localhost` and any other authorized domains in `Authentication > Settings > Authorized domains` - Add a Firestore database - Get your private keys from "Project settings > Service Accounts > Generate new private keys" - Make a copy of `.env.dist` and rename it to `.env.local` - Fill in the variables inside the `.env.local` - Make sure to format private variable in `.env.local` as follows to avoid parsing errors `FIREBASE_ADMIN_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR KEY\n-----END PRIVATE KEY-----\n"` First, run the development server: ```bash npm run dev # or yarn dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ## Configuring Firestore rules The demo shows example usage of Firestore Client SDK Make sure to update Firestore Database Rules of `user-counters` collection in [Firebase Console](https://console.firebase.google.com/). The following Firestore Database Rules validates if user has access to update specific `user-counters` database entry ``` rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /user-counters/{document} { allow read, write: if request.auth.uid == resource.data.id; } } } ``` ## Emulator support Library provides Firebase Authentication Emulator support Use [the official guide](https://firebase.google.com/docs/emulator-suite/connect_auth) to run the emulator locally. In order to use emulator, copy the contents of `.env.dist` to `.env.local` and uncomment following variables: ```shell NEXT_PUBLIC_AUTH_EMULATOR_HOST=localhost:9099 NEXT_PUBLIC_FIRESTORE_EMULATOR_HOST=localhost:8080 FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099 ``` `FIREBASE_AUTH_EMULATOR_HOST` is used internally by the library. It's required. `NEXT_PUBLIC_AUTH_EMULATOR_HOST` is used only by the example app. It can be redefined. `NEXT_PUBLIC_FIRESTORE_EMULATOR_HOST` is used only by the example app. It can be redefined. Also, don't forget to put correct Firebase Project ID in `.firebaserc` file. ## App Check support Library provides [Firebase App Check](https://firebase.google.com/docs/app-check) support Use [the official guide](https://firebase.google.com/docs/app-check/web/recaptcha-enterprise-provider) to integrate your app with App Check. In order to integrate the example with App Check, you need to add two env variables to your `.env.local` file (you can copy them from `.env.dist`). ```shell NEXT_PUBLIC_FIREBASE_APP_CHECK_KEY=XXxxxxXxXXXXXXXxxxXxxxXXXxxXXXXxxxxxXX_X NEXT_PUBLIC_FIREBASE_APP_ID=x:xxxxxxxxxxxx:web:xxxxxxxxxxxxxxxxxxxxxx ``` `NEXT_PUBLIC_FIREBASE_APP_CHECK_KEY` should be an app check key obtained by following [Set up your Firebase project](https://firebase.google.com/docs/app-check/web/recaptcha-enterprise-provider#project-setup) step `NEXT_PUBLIC_FIREBASE_APP_ID` can be obtained by following to `Project overview` > `Project settings`, under `Your apps` section in your Firebase Console. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. ================================================ FILE: examples/next-typescript-starter/api/index.ts ================================================ import {getToken} from '@firebase/app-check'; import {getAppCheck} from '../app-check'; import {UserCredential} from 'firebase/auth'; export async function login(token: string) { const headers: Record = { Authorization: `Bearer ${token}` }; // This is optional. Use it if your app supports App Check – https://firebase.google.com/docs/app-check if (process.env.NEXT_PUBLIC_FIREBASE_APP_CHECK_KEY) { const appCheckTokenResponse = await getToken(getAppCheck(), false); headers['X-Firebase-AppCheck'] = appCheckTokenResponse.token; } await fetch('/api/login', { method: 'GET', headers }); } export async function loginWithCredential(credential: UserCredential) { const idToken = await credential.user.getIdToken(); await login(idToken); } export async function logout() { const headers: Record = {}; // This is optional. Use it if your app supports App Check – https://firebase.google.com/docs/app-check if (process.env.NEXT_PUBLIC_FIREBASE_APP_CHECK_KEY) { const appCheckTokenResponse = await getToken(getAppCheck(), false); headers['X-Firebase-AppCheck'] = appCheckTokenResponse.token; } await fetch('/api/logout', { method: 'GET', headers }); } export async function checkEmailVerification() { const headers: Record = {}; // This is optional. Use it if your app supports App Check – https://firebase.google.com/docs/app-check if (process.env.NEXT_PUBLIC_FIREBASE_APP_CHECK_KEY) { const appCheckTokenResponse = await getToken(getAppCheck(), false); headers['X-Firebase-AppCheck'] = appCheckTokenResponse.token; } await fetch('/api/check-email-verification', { method: 'GET', headers }); } ================================================ FILE: examples/next-typescript-starter/app/actions/login.ts ================================================ 'use server'; import {signInWithEmailAndPassword} from 'firebase/auth'; import {refreshCookiesWithIdToken} from 'next-firebase-auth-edge/next/cookies'; import {getFirebaseAuth} from '../auth/firebase'; import {cookies, headers} from 'next/headers'; import {authConfig} from '../../config/server-config'; import {redirect} from 'next/navigation'; export async function loginAction(username: string, password: string) { const credential = await signInWithEmailAndPassword( getFirebaseAuth(), username, password ); const idToken = await credential.user.getIdToken(); await refreshCookiesWithIdToken( idToken, await headers(), await cookies(), authConfig ); redirect('/'); } ================================================ FILE: examples/next-typescript-starter/app/actions/refresh-cookies.ts ================================================ 'use server'; import {cookies, headers} from 'next/headers'; import {getTokens} from 'next-firebase-auth-edge'; import {refreshServerCookies} from 'next-firebase-auth-edge/next/cookies'; import {authConfig} from '../../config/server-config'; export async function refreshCookies() { const tokens = await getTokens(await cookies(), authConfig); if (!tokens) { throw new Error('Unauthenticated'); } await refreshServerCookies( await cookies(), new Headers(await headers()), authConfig ); } ================================================ FILE: examples/next-typescript-starter/app/actions/user-counters.ts ================================================ 'use server'; import {getFirestore} from 'firebase-admin/firestore'; import {getTokens} from 'next-firebase-auth-edge'; import {revalidatePath} from 'next/cache'; import {cookies} from 'next/headers'; import {getFirebaseAdminApp} from '../firebase'; import {authConfig} from '../../config/server-config'; export async function incrementCounter() { const tokens = await getTokens(await cookies(), authConfig); if (!tokens) { throw new Error('Cannot update counter of unauthenticated user'); } const db = getFirestore(getFirebaseAdminApp()); const snapshot = await db .collection('user-counters') .doc(tokens.decodedToken.uid) .get(); const currentUserCounter = snapshot.data(); if (!snapshot.exists || !currentUserCounter) { const userCounter = { id: tokens.decodedToken.uid, count: 1 }; await snapshot.ref.create(userCounter); } const newUserCounter = { ...currentUserCounter, count: currentUserCounter?.count + 1 }; await snapshot.ref.update(newUserCounter); revalidatePath('/'); } ================================================ FILE: examples/next-typescript-starter/app/api/check-email-verification/route.ts ================================================ import {refreshNextResponseCookies} from 'next-firebase-auth-edge/next/cookies'; import {getTokens} from 'next-firebase-auth-edge/next/tokens'; import {NextResponse} from 'next/server'; import type { NextRequest } from 'next/server'; import {authConfig} from '../../../config/server-config'; export async function GET(request: NextRequest) { const tokens = await getTokens(request.cookies, authConfig); if (!tokens) { throw new Error('Cannot refresh tokens of unauthenticated user'); } const headers: Record = { 'Content-Type': 'application/json' }; const response = new NextResponse( JSON.stringify({ success: true }), { status: 200, headers } ); return refreshNextResponseCookies(request, response, authConfig); } ================================================ FILE: examples/next-typescript-starter/app/api/custom-claims/route.ts ================================================ import {getFirebaseAuth} from 'next-firebase-auth-edge/auth'; import {refreshNextResponseCookies} from 'next-firebase-auth-edge/next/cookies'; import {getTokens} from 'next-firebase-auth-edge/next/tokens'; import {NextResponse} from 'next/server'; import type {NextRequest} from 'next/server'; import {authConfig} from '../../../config/server-config'; const {setCustomUserClaims, getUser} = getFirebaseAuth({ serviceAccount: authConfig.serviceAccount, apiKey: authConfig.apiKey, tenantId: authConfig.tenantId, enableCustomToken: authConfig.enableCustomToken }); export async function POST(request: NextRequest) { const tokens = await getTokens(request.cookies, authConfig); if (!tokens) { throw new Error('Cannot update custom claims of unauthenticated user'); } await setCustomUserClaims(tokens.decodedToken.uid, { someCustomClaim: { updatedAt: Date.now() } }); const user = await getUser(tokens.decodedToken.uid); const headers: Record = { 'Content-Type': 'application/json' }; const response = new NextResponse( JSON.stringify({ customClaims: user?.customClaims }), { status: 200, headers } ); return refreshNextResponseCookies(request, response, authConfig); } ================================================ FILE: examples/next-typescript-starter/app/api/test-app-check/route.ts ================================================ import {NextResponse} from 'next/server'; import type { NextRequest } from 'next/server'; import {serverConfig} from '../../../config/server-config'; import {getAppCheck} from 'next-firebase-auth-edge/app-check'; import {getReferer} from 'next-firebase-auth-edge/next/utils'; export async function POST(request: NextRequest) { const appCheckToken = request.headers.get('X-Firebase-AppCheck'); const {verifyToken} = getAppCheck({ serviceAccount: serverConfig.serviceAccount }); if (!appCheckToken) { return new NextResponse( JSON.stringify({ message: 'X-Firebase-AppCheck header is missing' }), { status: 400, headers: {'content-type': 'application/json'} } ); } try { const response = await verifyToken(appCheckToken, { referer: getReferer(request.headers) ?? '' }); return new NextResponse(JSON.stringify(response.token), { status: 200, headers: {'content-type': 'application/json'} }); } catch (e) { return new NextResponse( JSON.stringify({ message: (e as Error)?.message }), { status: 500, headers: {'content-type': 'application/json'} } ); } } ================================================ FILE: examples/next-typescript-starter/app/api/token-test/route.ts ================================================ import {NextResponse} from 'next/server'; import type { NextRequest } from 'next/server'; import {getTokens} from 'next-firebase-auth-edge'; import {cookies} from 'next/headers'; import {authConfig} from '../../../config/server-config'; export async function GET(_request: NextRequest) { const tokens = await getTokens(await cookies(), authConfig); if (!tokens) { throw new Error('Unauthenticated'); } const headers: Record = { 'Content-Type': 'application/json' }; const response = new NextResponse( JSON.stringify({ tokens }), { status: 200, headers } ); return response; } ================================================ FILE: examples/next-typescript-starter/app/api/user-counters/route.ts ================================================ import {NextResponse} from 'next/server'; import type { NextRequest } from 'next/server'; import {authConfig} from '../../../config/server-config'; import {getFirestore} from 'firebase-admin/firestore'; import {getTokens} from 'next-firebase-auth-edge/next/tokens'; import {getFirebaseAdminApp} from '../../firebase'; export async function POST(request: NextRequest) { const tokens = await getTokens(request.cookies, authConfig); if (!tokens) { throw new Error('Cannot update counter of unauthenticated user'); } const db = getFirestore(getFirebaseAdminApp()); const snapshot = await db .collection('user-counters') .doc(tokens.decodedToken.uid) .get(); const currentUserCounter = await snapshot.data(); if (!snapshot.exists || !currentUserCounter) { const userCounter = { id: tokens.decodedToken.uid, count: 1 }; await snapshot.ref.create(userCounter); return NextResponse.json(userCounter); } const newUserCounter = { ...currentUserCounter, count: currentUserCounter.count + 1 }; await snapshot.ref.update(newUserCounter); return NextResponse.json(newUserCounter); } ================================================ FILE: examples/next-typescript-starter/app/auth/AuthContext.ts ================================================ import {createContext, useContext} from 'react'; import {UserInfo} from 'firebase/auth'; import {Claims} from 'next-firebase-auth-edge/auth/claims'; export interface Metadata { uid: string; timestamp: number; } export interface User extends UserInfo { idToken: string; customToken?: string; emailVerified: boolean; customClaims: Claims; metadata: Metadata; } export interface AuthContextValue { user: User | null; } export const AuthContext = createContext({ user: null }); export const useAuth = () => useContext(AuthContext); ================================================ FILE: examples/next-typescript-starter/app/auth/AuthProvider.tsx ================================================ 'use client'; import * as React from 'react'; import {AuthContext, User} from './AuthContext'; export interface AuthProviderProps { user: User | null; children: React.ReactNode; } export const AuthProvider: React.FunctionComponent = ({ user, children }) => { return ( {children} ); }; ================================================ FILE: examples/next-typescript-starter/app/auth/firebase.ts ================================================ import { getApp, getApps, initializeApp } from 'firebase/app'; import { connectAuthEmulator, getAuth, inMemoryPersistence, setPersistence } from 'firebase/auth'; import { clientConfig } from '../../config/client-config'; export const getFirebaseApp = () => { if (getApps().length) { return getApp(); } return initializeApp(clientConfig); }; export function getFirebaseAuth() { const auth = getAuth(getFirebaseApp()); // App relies only on server token. We make sure Firebase does not store credentials in the browser. // See: https://github.com/awinogrodzki/next-firebase-auth-edge/issues/143 setPersistence(auth, inMemoryPersistence); if (process.env.NEXT_PUBLIC_AUTH_EMULATOR_HOST) { // https://stackoverflow.com/questions/73605307/firebase-auth-emulator-fails-intermittently-with-auth-emulator-config-failed (auth as unknown as any)._canInitEmulator = true; connectAuthEmulator(auth, `http://${process.env.NEXT_PUBLIC_AUTH_EMULATOR_HOST}`, { disableWarnings: true }); } if (clientConfig.tenantId) { auth.tenantId = clientConfig.tenantId; } return auth; } ================================================ FILE: examples/next-typescript-starter/app/firebase.ts ================================================ import admin from 'firebase-admin'; import {authConfig} from '../config/server-config'; const initializeApp = () => { if (!authConfig.serviceAccount) { return admin.initializeApp(); } // Don't use real credentials with Firebase Emulator https://firebase.google.com/docs/emulator-suite/connect_auth#admin_sdks if (process.env.FIREBASE_AUTH_EMULATOR_HOST) { return admin.initializeApp({ projectId: authConfig.serviceAccount.projectId }); } return admin.initializeApp({ credential: admin.credential.cert(authConfig.serviceAccount) }); }; export const getFirebaseAdminApp = () => { if (admin.apps.length > 0) { return admin.apps[0] as admin.app.App; } // admin.firestore.setLogFunction(console.log); return initializeApp(); }; ================================================ FILE: examples/next-typescript-starter/app/globals.css ================================================ html, body { height: 100%; padding: 0; margin: 0; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; } a { color: inherit; text-decoration: none; } * { box-sizing: border-box; } @media (prefers-color-scheme: dark) { html { color-scheme: dark; } body { color: white; background: black; } } :root { --toggle-color: rgb(204, 204, 204); --background-color: #0070f3; --slider-color: rgb(255, 255, 255); } @media (prefers-color-scheme: dark) { :root { --toggle-color: rgb(255, 255, 255); --background-color: #0070f3; --slider-color: rgb(82, 79, 79); } } ================================================ FILE: examples/next-typescript-starter/app/layout.module.css ================================================ .container { height: 100%; display: flex; flex-direction: column; } .main { padding: 1.5rem 0; flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; } .footer { display: flex; flex: 0; padding: 2rem 0; border-top: 1px solid #eaeaea; justify-content: center; align-items: center; } .footer a { display: flex; justify-content: center; align-items: center; flex-grow: 1; } @media (prefers-color-scheme: dark) { .footer { border-color: #222; } } ================================================ FILE: examples/next-typescript-starter/app/layout.tsx ================================================ import { getTokens } from 'next-firebase-auth-edge'; import { cookies } from 'next/headers'; import { authConfig } from '../config/server-config'; import { AuthProvider } from './auth/AuthProvider'; import './globals.css'; import styles from './layout.module.css'; import { toUser } from './shared/user'; import { Metadata } from './auth/AuthContext'; export default async function RootLayout({ children }: { children: React.ReactNode; }) { const tokens = await getTokens(await cookies(), authConfig); const user = tokens ? toUser(tokens) : null; return ( ); } export const metadata = { title: 'next-firebase-auth-edge example page', description: 'Next.js page showcasing next-firebase-auth-edge features', icons: '/favicon.ico' }; ================================================ FILE: examples/next-typescript-starter/app/login/LoginPage.tsx ================================================ 'use client'; import { UserCredential, getRedirectResult, isSignInWithEmailLink, sendSignInLinkToEmail, signInWithEmailAndPassword, signInWithEmailLink } from 'firebase/auth'; import Link from 'next/link'; import * as React from 'react'; import {useLoadingCallback} from 'react-loading-hook'; import {loginWithCredential} from '../../api'; import {Button} from '../../ui/Button'; import {ButtonGroup} from '../../ui/ButtonGroup'; import {MainTitle} from '../../ui/MainTitle'; import {PasswordForm} from '../../ui/PasswordForm'; import {PasswordFormValue} from '../../ui/PasswordForm/PasswordForm'; import {Switch} from '../../ui/Switch/Switch'; import {LoadingIcon} from '../../ui/icons'; import {getFirebaseAuth} from '../auth/firebase'; import {appendRedirectParam} from '../shared/redirect'; import {useRedirectAfterLogin} from '../shared/useRedirectAfterLogin'; import {useRedirectParam} from '../shared/useRedirectParam'; import { getGoogleProvider, loginWithProvider, loginWithProviderUsingRedirect } from './firebase'; import styles from './login.module.css'; const auth = getFirebaseAuth(); export function LoginPage({ loginAction }: { loginAction: (email: string, password: string) => void; }) { const [hasLogged, setHasLogged] = React.useState(false); const [shouldLoginWithAction, setShouldLoginWithAction] = React.useState(false); let [isLoginActionPending, startTransition] = React.useTransition(); const redirect = useRedirectParam(); const redirectAfterLogin = useRedirectAfterLogin(); async function handleLogin(credential: UserCredential) { await loginWithCredential(credential); redirectAfterLogin(); } const [handleLoginWithEmailAndPassword, isEmailLoading, emailPasswordError] = useLoadingCallback(async ({email, password}: PasswordFormValue) => { setHasLogged(false); const auth = getFirebaseAuth(); if (shouldLoginWithAction) { startTransition(() => loginAction(email, password)); } else { await handleLogin( await signInWithEmailAndPassword(auth, email, password) ); setHasLogged(true); } }); const [handleLoginWithGoogle, isGoogleLoading, googleError] = useLoadingCallback(async () => { setHasLogged(false); const auth = getFirebaseAuth(); await handleLogin(await loginWithProvider(auth, getGoogleProvider(auth))); setHasLogged(true); }); const [ handleLoginWithGoogleUsingRedirect, isGoogleUsingRedirectLoading, googleUsingRedirectError ] = useLoadingCallback(async () => { setHasLogged(false); const auth = getFirebaseAuth(); await loginWithProviderUsingRedirect(auth, getGoogleProvider(auth)); setHasLogged(true); }); async function handleLoginWithRedirect() { const credential = await getRedirectResult(auth); if (credential?.user) { await handleLogin(credential); setHasLogged(true); } } React.useEffect(() => { handleLoginWithRedirect(); }, []); const [handleLoginWithEmailLink, isEmailLinkLoading, emailLinkError] = useLoadingCallback(async () => { const auth = getFirebaseAuth(); const email = window.prompt('Please provide your email'); if (!email) { return; } window.localStorage.setItem('emailForSignIn', email); await sendSignInLinkToEmail(auth, email, { url: process.env.NEXT_PUBLIC_ORIGIN + '/login', handleCodeInApp: true }); }); async function handleLoginWithEmailLinkCallback() { const auth = getFirebaseAuth(); if (!isSignInWithEmailLink(auth, window.location.href)) { return; } let email = window.localStorage.getItem('emailForSignIn'); if (!email) { email = window.prompt('Please provide your email for confirmation'); } if (!email) { return; } setHasLogged(false); await handleLogin( await signInWithEmailLink(auth, email, window.location.href) ); window.localStorage.removeItem('emailForSignIn'); setHasLogged(true); } React.useEffect(() => { handleLoginWithEmailLinkCallback(); }, []); return (
Login {hasLogged && (
Redirecting to {redirect || '/'}
)} {!hasLogged && ( Login with Server Action
) : undefined } error={ emailPasswordError || googleError || emailLinkError || googleUsingRedirectError } > Reset password )} ); } ================================================ FILE: examples/next-typescript-starter/app/login/firebase.ts ================================================ import type { Auth, AuthError, AuthProvider, User, UserCredential } from 'firebase/auth'; import { browserPopupRedirectResolver, GoogleAuthProvider, signInWithPopup, signInWithRedirect, signOut, useDeviceLanguage as setDeviceLanguage } from 'firebase/auth'; const CREDENTIAL_ALREADY_IN_USE_ERROR = 'auth/credential-already-in-use'; export const isCredentialAlreadyInUseError = (e: AuthError) => e?.code === CREDENTIAL_ALREADY_IN_USE_ERROR; export const logout = async (auth: Auth): Promise => { return signOut(auth); }; export const getGoogleProvider = (auth: Auth) => { const provider = new GoogleAuthProvider(); provider.addScope('profile'); provider.addScope('email'); setDeviceLanguage(auth); provider.setCustomParameters({ display: 'popup' }); return provider; }; export const loginWithProvider = async ( auth: Auth, provider: AuthProvider ): Promise => { const result = await signInWithPopup( auth, provider, browserPopupRedirectResolver ); return result; }; export const loginWithProviderUsingRedirect = async ( auth: Auth, provider: AuthProvider ): Promise => { await signInWithRedirect(auth, provider); }; ================================================ FILE: examples/next-typescript-starter/app/login/login.module.css ================================================ .page { width: 640px; max-width: 100%; height: 100%; flex-direction: column; display: flex; align-items: center; justify-content: center; padding: 1.5rem; gap: 1rem; } .info { display: flex; align-items: center; margin-bottom: 1.5rem; gap: 1rem; } .link { display: flex; justify-content: flex-end; font-size: 0.675rem; text-decoration: underline; } .titleIcon { position: absolute; top: 50%; right: -32px; transform: translateY(-50%); } .loginWithAction { display: flex; gap: 0.675rem; align-items: center; } ================================================ FILE: examples/next-typescript-starter/app/login/page.tsx ================================================ import {loginAction} from '../actions/login'; import {LoginPage as ClientLoginPage} from './LoginPage'; export default function Login() { return ; } ================================================ FILE: examples/next-typescript-starter/app/page.module.css ================================================ .container { padding: 0 2rem; } ================================================ FILE: examples/next-typescript-starter/app/page.tsx ================================================ import styles from './page.module.css'; import Link from 'next/link'; import {Button} from '../ui/Button'; import {MainTitle} from '../ui/MainTitle'; import {Badge} from '../ui/Badge'; import {Card} from '../ui/Card'; export async function generateStaticParams() { return [{}]; } export default function Home() { return (
Home Static

You are logged in

); } ================================================ FILE: examples/next-typescript-starter/app/profile/UserProfile/UserProfile.module.css ================================================ .container { display: flex; gap: 1rem; width: 100%; } .title { display: flex; align-items: center; justify-content: center; gap: 1rem; } .claims { text-align: left; } .claims h5 { margin: 0 0 0.5rem; } .claims pre { margin: 0; padding: 0.5rem; font-size: 0.675rem; } .content { display: flex; align-items: center; justify-content: center; gap: 1rem; margin-bottom: 1.5rem; } .contentButton { max-width: 160px; } .avatar { width: 32px; height: 32px; border-radius: 16px; background: #7f7f7f; overflow: hidden; } .avatar img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 50%; } .section { flex: 1; text-align: center; } @media only screen and (max-width: 640px) { .container { flex-direction: column; } } ================================================ FILE: examples/next-typescript-starter/app/profile/UserProfile/UserProfile.tsx ================================================ 'use client'; import {getToken} from '@firebase/app-check'; import * as React from 'react'; import {useLoadingCallback} from 'react-loading-hook'; import {signOut} from 'firebase/auth'; import {useRouter} from 'next/navigation'; import {checkEmailVerification, logout} from '../../../api'; import {getAppCheck} from '../../../app-check'; import {Badge} from '../../../ui/Badge'; import {Button} from '../../../ui/Button'; import {ButtonGroup} from '../../../ui/ButtonGroup'; import {Card} from '../../../ui/Card'; import {useAuth} from '../../auth/AuthContext'; import {getFirebaseAuth} from '../../auth/firebase'; import styles from './UserProfile.module.css'; import {incrementCounterUsingClientFirestore} from './user-counters'; import {refreshCookies} from '../../actions/refresh-cookies'; interface UserProfileProps { count: number; incrementCounter: () => void; } export function UserProfile({count, incrementCounter}: UserProfileProps) { const router = useRouter(); const {user} = useAuth(); const [hasLoggedOut, setHasLoggedOut] = React.useState(false); const [handleLogout, isLogoutLoading] = useLoadingCallback(async () => { const auth = getFirebaseAuth(); await signOut(auth); await logout(); router.refresh(); setHasLoggedOut(true); }); const [handleClaims, isClaimsLoading] = useLoadingCallback(async () => { const headers: Record = {}; // This is optional. Use it if your app supports App Check – https://firebase.google.com/docs/app-check if (process.env.NEXT_PUBLIC_FIREBASE_APP_CHECK_KEY) { const appCheckTokenResponse = await getToken(getAppCheck(), false); headers['X-Firebase-AppCheck'] = appCheckTokenResponse.token; } await fetch('/api/custom-claims', { method: 'POST', headers }); router.refresh(); }); const [handleAppCheck, isAppCheckLoading] = useLoadingCallback(async () => { const appCheckTokenResponse = await getToken(getAppCheck(), false); const response = await fetch('/api/test-app-check', { method: 'POST', headers: { 'X-Firebase-AppCheck': appCheckTokenResponse.token } }); if (response.ok) { console.info( 'Successfully verified App Check token', await response.json() ); } else { console.error('Could not verify App Check token', await response.json()); } }); const [handleIncrementCounterApi, isIncrementCounterApiLoading] = useLoadingCallback(async () => { const response = await fetch('/api/user-counters', { method: 'POST' }); await response.json(); router.refresh(); }); const [handleIncrementCounterClient, isIncrementCounterClientLoading] = useLoadingCallback(async () => { if (!user) { return; } if (user.customToken) { await incrementCounterUsingClientFirestore(user.customToken); } else { console.warn( 'Custom token is not present. Have you set `enableCustomToken` option to `true` in `authMiddleware`?' ); } router.refresh(); }); const [handleReCheck, isReCheckLoading] = useLoadingCallback(async () => { await checkEmailVerification(); router.refresh(); }); let [isIncrementCounterActionPending, startTransition] = React.useTransition(); let [isRefreshCookiesActionPending, startRefreshCookiesTransition] = React.useTransition(); if (!user) { return null; } const isIncrementLoading = isIncrementCounterApiLoading || isIncrementCounterActionPending || isIncrementCounterClientLoading; return (

You are logged in as

{user.photoURL && }
{user.email}
{!user.emailVerified && (
Email not verified.
)}
Custom claims
{JSON.stringify(user.customClaims, undefined, 2)}
Metadata
{JSON.stringify(user.metadata, undefined, 2)}
{process.env.NEXT_PUBLIC_FIREBASE_APP_CHECK_KEY && ( )}

{/* defaultCount is updated by server */} Counter: {count}

); } ================================================ FILE: examples/next-typescript-starter/app/profile/UserProfile/index.ts ================================================ export {UserProfile} from './UserProfile'; ================================================ FILE: examples/next-typescript-starter/app/profile/UserProfile/user-counters-server.ts ================================================ import {initializeServerApp} from 'firebase/app'; import {doc, getDoc, getFirestore} from 'firebase/firestore'; import {clientConfig} from '../../../config/client-config'; import {getAuth} from 'firebase/auth'; export async function getServerFirebase(authIdToken?: string) { const app = initializeServerApp(clientConfig, {authIdToken}); const auth = getAuth(app); await auth.authStateReady(); const db = getFirestore(app); return {app, auth, db}; } export interface UserCounter { id: string; count: number; } export async function getUserCounter( userId: string, authToken?: string ): Promise { try { const {db} = await getServerFirebase(authToken); const counterRef = doc(db, 'user-counters', userId); const counterSnap = await getDoc(counterRef); if (counterSnap.exists()) { return { id: counterSnap.id, ...counterSnap.data() } as UserCounter; } return null; } catch (error) { console.error('Error fetching user counter:', error); throw error; } } ================================================ FILE: examples/next-typescript-starter/app/profile/UserProfile/user-counters.ts ================================================ import {signInWithCustomToken} from 'firebase/auth'; import {getValidCustomToken} from 'next-firebase-auth-edge/next/client'; import {getFirebaseApp, getFirebaseAuth} from '../../auth/firebase'; import { doc, getDoc, getFirestore, updateDoc, setDoc, connectFirestoreEmulator } from 'firebase/firestore'; const db = getFirestore(getFirebaseApp()); // Use together with Firestore Emulator https://cloud.google.com/firestore/docs/emulator#android_apple_platforms_and_web_sdks if (process.env.NEXT_PUBLIC_FIRESTORE_EMULATOR_HOST) { const [host, port] = process.env.NEXT_PUBLIC_FIRESTORE_EMULATOR_HOST.split(':'); connectFirestoreEmulator(db, host, Number(port)); } const auth = getFirebaseAuth(); export async function incrementCounterUsingClientFirestore( serverCustomToken: string ) { // We use `getValidCustomToken` to fetch fresh `customToken` using /api/refresh-token endpoint if original custom token has expired. // This ensures custom token is valid, even in long-running client sessions const customToken = await getValidCustomToken({ serverCustomToken, refreshTokenUrl: '/api/refresh-token' }); if (!customToken) { throw new Error('Invalid custom token'); } const {user: firebaseUser} = await signInWithCustomToken(auth, customToken); const docRef = doc(db, 'user-counters', firebaseUser.uid); const docSnap = await getDoc(docRef); if (docSnap.exists()) { const data = docSnap.data(); await updateDoc(docRef, {count: data.count + 1}); } else { await setDoc(docRef, {count: 1}); } } ================================================ FILE: examples/next-typescript-starter/app/profile/page.module.css ================================================ .container { padding: 0 1.5rem; width: 800px; max-width: 100%; height: 100%; flex-direction: column; display: flex; align-items: center; justify-content: center; } ================================================ FILE: examples/next-typescript-starter/app/profile/page.tsx ================================================ import styles from './page.module.css'; import {UserProfile} from './UserProfile'; import {Metadata} from 'next'; import {getTokens} from 'next-firebase-auth-edge/next/tokens'; import {cookies} from 'next/headers'; import {authConfig} from '../../config/server-config'; import {Badge} from '../../ui/Badge'; import {HomeLink} from '../../ui/HomeLink'; import {MainTitle} from '../../ui/MainTitle'; import {incrementCounter} from '../actions/user-counters'; import {getUserCounter} from './UserProfile/user-counters-server'; export default async function Profile() { const tokens = await getTokens(await cookies(), authConfig); if (!tokens) { throw new Error('Cannot get counter of unauthenticated user'); } const counter = await getUserCounter(tokens.decodedToken.uid, tokens.token); return (
Profile Rendered on server
); } // Generate customized metadata based on user cookies // https://nextjs.org/docs/app/building-your-application/optimizing/metadata export async function generateMetadata(): Promise { const tokens = await getTokens(await cookies(), authConfig); if (!tokens) { return {}; } return { title: `${tokens.decodedToken.email} profile page | next-firebase-auth-edge example` }; } ================================================ FILE: examples/next-typescript-starter/app/register/RegisterPage.tsx ================================================ 'use client'; import * as React from 'react'; import {useLoadingCallback} from 'react-loading-hook'; import { createUserWithEmailAndPassword, sendEmailVerification } from 'firebase/auth'; import Link from 'next/link'; import {getFirebaseAuth} from '../auth/firebase'; import {Button} from '../../ui/Button'; import {MainTitle} from '../../ui/MainTitle'; import {PasswordForm} from '../../ui/PasswordForm'; import {PasswordFormValue} from '../../ui/PasswordForm/PasswordForm'; import {LoadingIcon} from '../../ui/icons'; import {appendRedirectParam} from '../shared/redirect'; import {useRedirectParam} from '../shared/useRedirectParam'; import styles from './register.module.css'; import {useRedirectAfterLogin} from '../shared/useRedirectAfterLogin'; import {loginWithCredential} from '../../api'; export function RegisterPage() { const [hasLogged, setHasLogged] = React.useState(false); const redirect = useRedirectParam(); const redirectAfterLogin = useRedirectAfterLogin(); const [registerWithEmailAndPassword, isRegisterLoading, error] = useLoadingCallback(async ({email, password}: PasswordFormValue) => { setHasLogged(false); const auth = getFirebaseAuth(); const credential = await createUserWithEmailAndPassword( auth, email, password ); await loginWithCredential(credential); await sendEmailVerification(credential.user); redirectAfterLogin(); setHasLogged(true); }); return (
Register {hasLogged && (
Redirecting to {redirect || '/'}
)} {!hasLogged && ( )}
); } ================================================ FILE: examples/next-typescript-starter/app/register/firebase.ts ================================================ import type {Auth, AuthError, AuthProvider, User} from 'firebase/auth'; import { browserPopupRedirectResolver, GoogleAuthProvider, signInWithPopup, signOut, useDeviceLanguage as setDeviceLanguage } from 'firebase/auth'; const CREDENTIAL_ALREADY_IN_USE_ERROR = 'auth/credential-already-in-use'; export const isCredentialAlreadyInUseError = (e: AuthError) => e?.code === CREDENTIAL_ALREADY_IN_USE_ERROR; export const logout = async (auth: Auth): Promise => { return signOut(auth); }; export const getGoogleProvider = (auth: Auth) => { const provider = new GoogleAuthProvider(); provider.addScope('profile'); provider.addScope('email'); setDeviceLanguage(auth); provider.setCustomParameters({ display: 'popup' }); return provider; }; export const loginWithProvider = async ( auth: Auth, provider: AuthProvider ): Promise => { const result = await signInWithPopup( auth, provider, browserPopupRedirectResolver ); return result.user; }; ================================================ FILE: examples/next-typescript-starter/app/register/page.tsx ================================================ import {RegisterPage} from './RegisterPage'; export default function Register() { return ; } ================================================ FILE: examples/next-typescript-starter/app/register/register.module.css ================================================ .page { width: 640px; max-width: 100%; height: 100%; flex-direction: column; display: flex; align-items: center; justify-content: center; padding: 1.5rem; } .info { display: flex; align-items: center; margin-bottom: 1.5rem; gap: 1rem; } ================================================ FILE: examples/next-typescript-starter/app/reset-password/ResetPasswordPage.module.css ================================================ .page { width: 640px; max-width: 100%; height: 100%; flex-direction: column; display: flex; align-items: center; justify-content: center; padding: 1.5rem; } .info { display: flex; align-items: center; margin: 0; } .form { display: flex; flex-direction: column; gap: 1rem; } ================================================ FILE: examples/next-typescript-starter/app/reset-password/ResetPasswordPage.tsx ================================================ 'use client'; import * as React from 'react'; import {sendPasswordResetEmail} from 'firebase/auth'; import Link from 'next/link'; import {useLoadingCallback} from 'react-loading-hook'; import {getFirebaseAuth} from '../auth/firebase'; import {Button} from '../../ui/Button'; import {FormError} from '../../ui/FormError'; import {Input} from '../../ui/Input'; import {MainTitle} from '../../ui/MainTitle'; import {appendRedirectParam} from '../shared/redirect'; import {useRedirectParam} from '../shared/useRedirectParam'; import styles from './ResetPasswordPage.module.css'; import {ChangeEvent} from 'react'; export function ResetPasswordPage() { const [email, setEmail] = React.useState(''); const [isSent, setIsSent] = React.useState(false); const redirect = useRedirectParam(); const [sendResetInstructions, loading, error] = useLoadingCallback( async (event: React.FormEvent) => { event.preventDefault(); event.stopPropagation(); const auth = getFirebaseAuth(); setIsSent(false); await sendPasswordResetEmail(auth, email); setEmail(''); setIsSent(true); } ); return (
Reset password
) => setEmail(e.target.value) } name="email" type="email" placeholder="Email address" /> {isSent && (

Instructions sent. Check your email.

)} {error && {error?.message}}
); } ================================================ FILE: examples/next-typescript-starter/app/reset-password/firebase.ts ================================================ import type {Auth, AuthError, AuthProvider, User} from 'firebase/auth'; import { browserPopupRedirectResolver, GoogleAuthProvider, signInWithPopup, signOut, useDeviceLanguage as setDefaultLanguage } from 'firebase/auth'; const CREDENTIAL_ALREADY_IN_USE_ERROR = 'auth/credential-already-in-use'; export const isCredentialAlreadyInUseError = (e: AuthError) => e?.code === CREDENTIAL_ALREADY_IN_USE_ERROR; export const logout = async (auth: Auth): Promise => { return signOut(auth); }; export const getGoogleProvider = (auth: Auth) => { const provider = new GoogleAuthProvider(); provider.addScope('profile'); provider.addScope('email'); setDefaultLanguage(auth); provider.setCustomParameters({ display: 'popup' }); return provider; }; export const loginWithProvider = async ( auth: Auth, provider: AuthProvider ): Promise => { const result = await signInWithPopup( auth, provider, browserPopupRedirectResolver ); return result.user; }; ================================================ FILE: examples/next-typescript-starter/app/reset-password/page.tsx ================================================ import {ResetPasswordPage} from './ResetPasswordPage'; export default function ResetPassword() { return ; } ================================================ FILE: examples/next-typescript-starter/app/shared/redirect.ts ================================================ export function appendRedirectParam(url: string, redirectUrl: string | null) { if (redirectUrl) { return `${url}?redirect=${redirectUrl}`; } return url; } ================================================ FILE: examples/next-typescript-starter/app/shared/useRedirectAfterLogin.ts ================================================ import {useRouter} from 'next/navigation'; import {useRedirectParam} from './useRedirectParam'; export function useRedirectAfterLogin() { const router = useRouter(); const redirect = useRedirectParam(); return function () { router.push(redirect ?? '/'); router.refresh(); }; } ================================================ FILE: examples/next-typescript-starter/app/shared/useRedirectParam.ts ================================================ import {useSearchParams} from 'next/navigation'; export function useRedirectParam(): string | null { const params = useSearchParams(); return params?.get('redirect') ?? null; } ================================================ FILE: examples/next-typescript-starter/app/shared/user.ts ================================================ import {Tokens} from 'next-firebase-auth-edge'; import {Metadata, User} from '../auth/AuthContext'; import {filterStandardClaims} from 'next-firebase-auth-edge/auth/claims'; export const toUser = ({token, customToken, decodedToken, metadata}: Tokens): User => { const { uid, email, picture: photoURL, email_verified: emailVerified, phone_number: phoneNumber, name: displayName, source_sign_in_provider: signInProvider } = decodedToken; const customClaims = filterStandardClaims(decodedToken); return { uid, email: email ?? null, displayName: displayName ?? null, photoURL: photoURL ?? null, phoneNumber: phoneNumber ?? null, emailVerified: emailVerified ?? false, providerId: signInProvider, customClaims, idToken: token, customToken, metadata }; }; ================================================ FILE: examples/next-typescript-starter/app-check/index.ts ================================================ import { AppCheck, initializeAppCheck, ReCaptchaEnterpriseProvider, } from "@firebase/app-check"; import { getFirebaseApp } from "../app/auth/firebase"; import { FirebaseApp } from "@firebase/app"; let appCheck: AppCheck | null = null; export function getOrInitializeAppCheck(app: FirebaseApp): AppCheck { if (appCheck) { return appCheck; } // Firebase uses a global variable to check if app check is enabled in a dev environment if (process.env.NODE_ENV !== "production") { Object.assign(window, { FIREBASE_APPCHECK_DEBUG_TOKEN: process.env.NEXT_PUBLIC_APP_CHECK_DEBUG_TOKEN, }); } return (appCheck = initializeAppCheck(app, { provider: new ReCaptchaEnterpriseProvider( process.env.NEXT_PUBLIC_FIREBASE_APP_CHECK_KEY! ), isTokenAutoRefreshEnabled: true, // Set to true to allow auto-refresh. })); } export function getAppCheck() { const app = getFirebaseApp(); return getOrInitializeAppCheck(app); } ================================================ FILE: examples/next-typescript-starter/config/client-config.ts ================================================ export const clientConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!, authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!, databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL!, projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!, messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID!, // Optional – required if your app uses AppCheck – https://firebase.google.com/docs/app-check appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!, // Optional – required if your app uses Multi-Tenancy – https://cloud.google.com/identity-platform/docs/multi-tenancy-authentication tenantId: process.env.NEXT_PUBLIC_FIREBASE_AUTH_TENANT_ID }; ================================================ FILE: examples/next-typescript-starter/config/server-config.ts ================================================ import {TokenSet} from 'next-firebase-auth-edge/auth'; import {clientConfig} from './client-config'; export const serverConfig = { useSecureCookies: process.env.USE_SECURE_COOKIES === 'true', firebaseApiKey: process.env.FIREBASE_API_KEY!, serviceAccount: process.env.FIREBASE_ADMIN_PRIVATE_KEY ? { projectId: process.env.FIREBASE_PROJECT_ID!, clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL!, privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY.replace( /\\n/g, '\n' )! } : undefined }; export const authConfig = { apiKey: serverConfig.firebaseApiKey, cookieName: 'AuthToken', cookieSignatureKeys: [ process.env.COOKIE_SECRET_CURRENT!, process.env.COOKIE_SECRET_PREVIOUS! ], cookieSerializeOptions: { path: '/', httpOnly: true, secure: serverConfig.useSecureCookies, // Set this to true on HTTPS environments sameSite: 'lax' as const, maxAge: 12 * 60 * 60 * 24 // twelve days }, serviceAccount: serverConfig.serviceAccount, // Set to false in Firebase Hosting environment due to https://stackoverflow.com/questions/44929653/firebase-cloud-function-wont-store-cookie-named-other-than-session enableMultipleCookies: true, // Set to false if you're not planning to use `signInWithCustomToken` Firebase Client SDK method enableCustomToken: true, enableTokenRefreshOnExpiredKidHeader: true, debug: false, tenantId: clientConfig.tenantId, getMetadata: async (tokens: TokenSet) => { return {uid: tokens.decodedIdToken.uid, timestamp: new Date().getTime()}; }, dynamicCustomClaimsKeys: ['someCustomClaim'] }; ================================================ FILE: examples/next-typescript-starter/eslint.config.mjs ================================================ import { dirname } from 'path'; import { fileURLToPath } from 'url'; import { FlatCompat } from '@eslint/eslintrc'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const compat = new FlatCompat({ baseDirectory: __dirname, }); const eslintConfig = [...compat.extends('next/core-web-vitals')]; export default eslintConfig; ================================================ FILE: examples/next-typescript-starter/firebase.json ================================================ { "emulators": { "singleProjectMode": true, "auth": { "port": 9099 }, "firestore": { "port": 8080 }, "ui": { "enabled": true } } } ================================================ FILE: examples/next-typescript-starter/next-env.d.ts ================================================ /// /// /// import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. ================================================ FILE: examples/next-typescript-starter/next.config.js ================================================ const path = require('path'); /** @type {import('next').NextConfig} */ const nextConfig = { turbopack: { root: path.resolve(__dirname), }, // Needed for `signInWithRedirect` and custom `authDomain` configuration. See https://firebase.google.com/docs/auth/web/redirect-best-practices#proxy-requests // If you don't plan to use `signInWithRedirect` or custom `authDomain`, you can safely remove `rewrites` config. async rewrites() { return [ { source: '/__/auth', destination: `https://${process.env.FIREBASE_PROJECT_ID}.firebaseapp.com/__/auth` }, { source: '/__/auth/:path*', destination: `https://${process.env.FIREBASE_PROJECT_ID}.firebaseapp.com/__/auth/:path*` }, { source: '/__/firebase/init.json', destination: `https://${process.env.FIREBASE_PROJECT_ID}.firebaseapp.com/__/firebase/init.json` } ]; }, typescript: { // Type conflicts are expected with `link:` dependencies due to duplicate // `next` installations using `unique symbol` in type declarations. // This does not affect real consumers who install the package from npm. ignoreBuildErrors: true, }, env: { VERCEL: process.env.VERCEL } }; module.exports = nextConfig; ================================================ FILE: examples/next-typescript-starter/package.json ================================================ { "name": "next-typescript-starter", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start -p $PORT", "lint": "eslint ." }, "dependencies": { "@types/node": "^22.2.0", "firebase": "^12.1.0", "firebase-admin": "^13.4.0", "next": "^16.1.6", "next-firebase-auth-edge": "1.12.0-canary.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-loading-hook": "1.1.2", "typescript": "^5.5.4" }, "devDependencies": { "@eslint/eslintrc": "^3.1.0", "@types/react": "^19.1.9", "eslint": "^9.9.0", "eslint-config-next": "^16.1.6" } } ================================================ FILE: examples/next-typescript-starter/pages/api/tokens.ts ================================================ import {NextApiRequest, NextApiResponse} from 'next'; import {getApiRequestTokens} from 'next-firebase-auth-edge/next/tokens'; import {authConfig} from '../../config/server-config'; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { const tokens = await getApiRequestTokens(req, { apiKey: authConfig.apiKey, cookieName: authConfig.cookieName, cookieSignatureKeys: authConfig.cookieSignatureKeys, serviceAccount: authConfig.serviceAccount }); return res.status(200).json({tokens}); } ================================================ FILE: examples/next-typescript-starter/proxy.ts ================================================ import {NextResponse} from 'next/server'; import type {NextRequest} from 'next/server'; import { authMiddleware, redirectToHome, redirectToLogin } from 'next-firebase-auth-edge'; import {authConfig} from './config/server-config'; const PRIVATE_PATHS = ['/', '/profile']; const PUBLIC_PATHS = ['/register', '/login', '/reset-password']; export async function proxy(request: NextRequest) { return authMiddleware(request, { loginPath: '/api/login', logoutPath: '/api/logout', refreshTokenPath: '/api/refresh-token', debug: authConfig.debug, enableMultipleCookies: authConfig.enableMultipleCookies, enableCustomToken: authConfig.enableCustomToken, apiKey: authConfig.apiKey, cookieName: authConfig.cookieName, cookieSerializeOptions: authConfig.cookieSerializeOptions, cookieSignatureKeys: authConfig.cookieSignatureKeys, serviceAccount: authConfig.serviceAccount, enableTokenRefreshOnExpiredKidHeader: authConfig.enableTokenRefreshOnExpiredKidHeader, tenantId: authConfig.tenantId, dynamicCustomClaimsKeys: authConfig.dynamicCustomClaimsKeys, handleValidToken: async ({token, decodedToken, customToken}, headers) => { // Authenticated user should not be able to access /login, /register and /reset-password routes if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) { return redirectToHome(request); } return NextResponse.next({ request: { headers } }); }, handleInvalidToken: async (_reason) => { return redirectToLogin(request, { path: '/login', privatePaths: PRIVATE_PATHS }); }, handleError: async (error) => { console.error('Unhandled authentication error', {error}); return redirectToLogin(request, { path: '/login', privatePaths: PRIVATE_PATHS }); }, getMetadata: authConfig.getMetadata }); } export const config = { matcher: [ '/', '/((?!_next|favicon.ico|__/auth|__/firebase|api|.*\\.).*)', // Middleware api routes '/api/login', '/api/logout', '/api/refresh-token', // App api routes '/api/custom-claims', '/api/user-counters' ] }; ================================================ FILE: examples/next-typescript-starter/tsconfig.json ================================================ { "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "incremental": true, "plugins": [ { "name": "next" } ] }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], "exclude": [ "node_modules" ] } ================================================ FILE: examples/next-typescript-starter/ui/Badge/Badge.module.css ================================================ .badge { font-size: 0.675rem; line-height: 1rem; color: #0070f3; border: 1px solid #0070f3; padding: 0.5rem; letter-spacing: 0; border-radius: 1rem; } ================================================ FILE: examples/next-typescript-starter/ui/Badge/Badge.tsx ================================================ import styles from './Badge.module.css'; import {cx} from '../classNames'; import {JSX} from 'react'; export function Badge(props: JSX.IntrinsicElements['span']) { return ; } ================================================ FILE: examples/next-typescript-starter/ui/Badge/index.ts ================================================ export { Badge } from "./Badge"; ================================================ FILE: examples/next-typescript-starter/ui/Button/Button.module.css ================================================ .button { font-size: 1rem; padding: 0.75rem 1rem; color: inherit; text-decoration: none; background: transparent; border: 1px solid #0070f3; border-radius: 10px; transition: color 0.15s ease, border-color 0.15s ease; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; width: 280px; min-width: 0; } .button:last-of-type { margin-bottom: 0; } .button[disabled] { cursor: not-allowed; opacity: 0.5; } .button:not([disabled]):hover, .button:not([disabled]):focus, .button:not([disabled]):active { color: #fff; background-color: #0070f3; } .icon { margin-right: 0.5rem; } .outlined { } .contained { color: #fff; border-color: #0070f3; background-color: #0070f3; } .contained:not([disabled]):hover, .contained:not([disabled]):focus, .contained:not([disabled]):active { color: #fff; border-color: #015ac0; background-color: #015ac0; } ================================================ FILE: examples/next-typescript-starter/ui/Button/Button.tsx ================================================ import * as React from 'react'; import styles from './Button.module.css'; import {LoadingIcon} from '../icons'; import {cx} from '../classNames'; import {JSX} from 'react'; const variantClassNames = { contained: styles.contained, outlined: styles.outlined }; export function Button({ loading, children, variant = 'outlined', ...props }: JSX.IntrinsicElements['button'] & { loading?: boolean; variant?: 'contained' | 'outlined'; }) { return ( ); } ================================================ FILE: examples/next-typescript-starter/ui/Button/index.ts ================================================ export { Button } from "./Button"; ================================================ FILE: examples/next-typescript-starter/ui/ButtonGroup/ButtonGroup.module.css ================================================ .group { display: flex; flex-direction: column; padding: 0; gap: 1rem; max-width: 100%; } .group a { width: 100%; display: flex; } .group button { width: 100%; } ================================================ FILE: examples/next-typescript-starter/ui/ButtonGroup/ButtonGroup.tsx ================================================ import styles from './ButtonGroup.module.css'; import {cx} from '../classNames'; import {JSX} from 'react'; export function ButtonGroup(props: JSX.IntrinsicElements['div']) { return
; } ================================================ FILE: examples/next-typescript-starter/ui/ButtonGroup/index.ts ================================================ export { ButtonGroup } from "./ButtonGroup"; ================================================ FILE: examples/next-typescript-starter/ui/Card/Card.module.css ================================================ .card { display: block; margin: 1rem auto; padding: 1.5rem; color: inherit; text-decoration: none; border: 1px solid #eaeaea; border-radius: 10px; transition: color 0.15s ease, border-color 0.15s ease; max-width: 100%; text-align: center; min-width: 0px; } .card h2 { margin: 0 0 1.5rem 0; font-size: 1.5rem; } .card p { margin: 0; font-size: 1.25rem; line-height: 1.5; } @media (prefers-color-scheme: dark) { .card { border-color: #222; } } ================================================ FILE: examples/next-typescript-starter/ui/Card/Card.tsx ================================================ import styles from './Card.module.css'; import {cx} from '../classNames'; import {JSX} from 'react'; export function Card(props: JSX.IntrinsicElements['div']) { return
; } ================================================ FILE: examples/next-typescript-starter/ui/Card/index.ts ================================================ export { Card } from "./Card"; ================================================ FILE: examples/next-typescript-starter/ui/FormError/FormError.module.css ================================================ .error { font-size: 0.675rem; line-height: 1rem; color: #da4e42; border: 1px solid #da4e42; padding: 0.5rem; letter-spacing: 0; border-radius: 0.5rem; } ================================================ FILE: examples/next-typescript-starter/ui/FormError/FormError.tsx ================================================ import styles from './FormError.module.css'; import {cx} from '../classNames'; import {JSX} from 'react'; export function FormError(props: JSX.IntrinsicElements['span']) { return ; } ================================================ FILE: examples/next-typescript-starter/ui/FormError/index.ts ================================================ export { FormError } from "./FormError"; ================================================ FILE: examples/next-typescript-starter/ui/HomeLink/HomeLink.module.css ================================================ .home { display: inline-flex; width: 3rem; height: 3rem; align-items: center; justify-content: center; border-radius: 1.5rem; transition: all 0.175s ease-in-out; padding: 0.5rem; cursor: pointer; } .home:hover { background-color: rgba(0, 0, 0, 0.1); } @media (prefers-color-scheme: dark) { .home:hover { background: rgba(255, 255, 255, 0.1); } } ================================================ FILE: examples/next-typescript-starter/ui/HomeLink/HomeLink.tsx ================================================ import styles from "./HomeLink.module.css"; import { HomeIcon } from "../icons/HomeIcon"; import Link from "next/link"; export function HomeLink() { return ( ); } ================================================ FILE: examples/next-typescript-starter/ui/HomeLink/index.ts ================================================ export { HomeLink } from "./HomeLink"; ================================================ FILE: examples/next-typescript-starter/ui/IconButton/IconButton.module.css ================================================ .button { display: inline-flex; width: 2rem; height: 2rem; align-items: center; justify-content: center; border-radius: 1rem; transition: all 0.175s ease-in-out; background-color: transparent; border: none; padding: 0.5rem; cursor: pointer; } .button:hover { background-color: rgba(0, 0, 0, 0.1); } @media (prefers-color-scheme: dark) { .button:hover { background: rgba(255, 255, 255, 0.1); } } ================================================ FILE: examples/next-typescript-starter/ui/IconButton/IconButton.tsx ================================================ import styles from './IconButton.module.css'; import {cx} from '../classNames'; import {JSX} from 'react'; export function IconButton(props: JSX.IntrinsicElements['button']) { return ( {children}
); } ================================================ FILE: examples/next-typescript-starter/ui/PasswordForm/index.ts ================================================ export { PasswordForm } from "./PasswordForm"; ================================================ FILE: examples/next-typescript-starter/ui/Switch/Switch.module.css ================================================ .switch { position: relative; display: inline-block; width: 40px; height: 22px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--slider-color); -webkit-transition: .4s; transition: .4s; border-radius: 22px; box-shadow: 0 0 1px var(--background-color); } .slider:before { position: absolute; content: ""; height: 14px; width: 14px; left: 4px; bottom: 4px; background-color: var(--toggle-color); -webkit-transition: .4s; transition: .4s; border-radius: 50%; } input:checked + .slider { background-color: var(--background-color) } input:checked + .slider:before { transform: translateX(18px); } ================================================ FILE: examples/next-typescript-starter/ui/Switch/Switch.tsx ================================================ import styles from './Switch.module.css'; interface SwitchProps { value: boolean; onChange: (value: boolean) => void; } export function Switch({value, onChange}: SwitchProps) { return ( ); } ================================================ FILE: examples/next-typescript-starter/ui/Switch/index.ts ================================================ ================================================ FILE: examples/next-typescript-starter/ui/Switch/vars.css ================================================ :root { --toggle-color: 204, 204, 204; --background-color: 214, 219, 220; --slider-color: 255, 255, 255; } @media (prefers-color-scheme: dark) { :root { --toggle-color: 51, 51, 51; --background-color: 0, 0, 0; --slider-color: 0, 0, 0; } } ================================================ FILE: examples/next-typescript-starter/ui/classNames.ts ================================================ export function cx(...className: (string | undefined)[]): string { return className.filter(Boolean).join(" "); } ================================================ FILE: examples/next-typescript-starter/ui/icons/HiddenIcon.tsx ================================================ import * as React from 'react'; import styles from './icons.module.css'; import {JSX} from 'react'; export function HiddenIcon(props: JSX.IntrinsicElements['span']) { return ( ); } ================================================ FILE: examples/next-typescript-starter/ui/icons/HomeIcon.tsx ================================================ import * as React from 'react'; import styles from './icons.module.css'; import {JSX} from 'react'; export function HomeIcon(props: JSX.IntrinsicElements['span']) { return ( ); } ================================================ FILE: examples/next-typescript-starter/ui/icons/LoadingIcon.tsx ================================================ import * as React from 'react'; import styles from './icons.module.css'; import {JSX} from 'react'; export function LoadingIcon(props: JSX.IntrinsicElements['span']) { return ( ); } ================================================ FILE: examples/next-typescript-starter/ui/icons/VisibleIcon.tsx ================================================ import * as React from 'react'; import styles from './icons.module.css'; import {JSX} from 'react'; export function VisibleIcon(props: JSX.IntrinsicElements['span']) { return ( ); } ================================================ FILE: examples/next-typescript-starter/ui/icons/icons.module.css ================================================ .icon { width: 24px; height: 24px; display: inline-flex; color: inherit; } ================================================ FILE: examples/next-typescript-starter/ui/icons/index.ts ================================================ export { LoadingIcon } from './LoadingIcon'; ================================================ FILE: jest.config.js ================================================ module.exports = { setupFiles: ['dotenv/config'], testEnvironment: 'node', setupFilesAfterEnv: ['/jest.setup.ts'], testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], extensionsToTreatAsEsm: ['.ts', '.tsx'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', }, transform: { '^.+\\.(t|j)sx?$': '@swc/jest', }, }; ================================================ FILE: jest.setup.ts ================================================ import "isomorphic-fetch"; import "dotenv/config"; ================================================ FILE: package.json ================================================ { "name": "next-firebase-auth-edge", "version": "1.12.0", "description": "Next.js Firebase Authentication for Edge and server runtimes. Compatible with latest Next.js features.", "files": [ "lib/**/*.js", "lib/**/*.d.ts", "browser/**/*.js", "browser/**/*.d.ts", "esm/**/*.js", "esm/**/*.d.ts" ], "sideEffects": false, "main": "./lib/index.js", "browser": "./browser/index.js", "types": "./lib/index.d.ts", "exports": { ".": { "types": "./lib/index.d.ts", "bun": "./browser/index.js", "deno": "./browser/index.js", "browser": "./browser/index.js", "worker": "./browser/index.js", "workerd": "./browser/index.js", "import": "./esm/index.js", "require": "./lib/index.js" }, "./app-check": { "types": "./lib/app-check/index.d.ts", "bun": "./browser/app-check/index.js", "deno": "./browser/app-check/index.js", "browser": "./browser/app-check/index.js", "worker": "./browser/app-check/index.js", "workerd": "./browser/app-check/index.js", "import": "./esm/app-check/index.js", "require": "./lib/app-check/index.js" }, "./auth": { "types": "./lib/auth/index.d.ts", "bun": "./browser/auth/index.js", "deno": "./browser/auth/index.js", "browser": "./browser/auth/index.js", "worker": "./browser/auth/index.js", "workerd": "./browser/auth/index.js", "import": "./esm/auth/index.js", "require": "./lib/auth/index.js" }, "./auth/error": { "types": "./lib/auth/error.d.ts", "bun": "./browser/auth/error.js", "deno": "./browser/auth/error.js", "browser": "./browser/auth/error.js", "worker": "./browser/auth/error.js", "workerd": "./browser/auth/error.js", "import": "./esm/auth/error.js", "require": "./lib/auth/error.js" }, "./auth/claims": { "types": "./lib/auth/claims.d.ts", "bun": "./browser/auth/claims.js", "deno": "./browser/auth/claims.js", "browser": "./browser/auth/claims.js", "worker": "./browser/auth/claims.js", "workerd": "./browser/auth/claims.js", "import": "./esm/auth/claims.js", "require": "./lib/auth/claims.js" }, "./next/utils": { "types": "./lib/next/utils.d.ts", "bun": "./browser/next/utils.js", "deno": "./browser/next/utils.js", "browser": "./browser/next/utils.js", "worker": "./browser/next/utils.js", "workerd": "./browser/next/utils.js", "import": "./esm/next/utils.js", "require": "./lib/next/utils.js" }, "./next/cookies": { "types": "./lib/next/cookies/index.d.ts", "bun": "./browser/next/cookies/index.js", "deno": "./browser/next/cookies/index.js", "browser": "./browser/next/cookies/index.js", "worker": "./browser/next/cookies/index.js", "workerd": "./browser/next/cookies/index.js", "import": "./esm/next/cookies/index.js", "require": "./lib/next/cookies/index.js" }, "./next/tokens": { "types": "./lib/next/tokens.d.ts", "bun": "./browser/next/tokens.js", "deno": "./browser/next/tokens.js", "browser": "./browser/next/tokens.js", "worker": "./browser/next/tokens.js", "workerd": "./browser/next/tokens.js", "import": "./esm/next/tokens.js", "require": "./lib/next/tokens.js" }, "./next/client": { "types": "./lib/next/client.d.ts", "bun": "./browser/next/client.js", "deno": "./browser/next/client.js", "browser": "./browser/next/client.js", "worker": "./browser/next/client.js", "workerd": "./browser/next/client.js", "import": "./esm/next/client.js", "require": "./lib/next/client.js" }, "./next/api": { "types": "./lib/next/api.d.ts", "bun": "./browser/next/api.js", "deno": "./browser/next/api.js", "browser": "./browser/next/api.js", "worker": "./browser/next/api.js", "workerd": "./browser/next/api.js", "import": "./esm/next/api.js", "require": "./lib/next/api.js" }, "./next/middleware": { "types": "./lib/next/middleware.d.ts", "bun": "./browser/next/middleware.js", "deno": "./browser/next/middleware.js", "browser": "./browser/next/middleware.js", "worker": "./browser/next/middleware.js", "workerd": "./browser/next/middleware.js", "import": "./esm/next/middleware.js", "require": "./lib/next/middleware.js" }, "./next/refresh-token": { "types": "./lib/next/refresh-token.d.ts", "bun": "./browser/next/refresh-token.js", "deno": "./browser/next/refresh-token.js", "browser": "./browser/next/refresh-token.js", "worker": "./browser/next/refresh-token.js", "workerd": "./browser/next/refresh-token.js", "import": "./esm/next/refresh-token.js", "require": "./lib/next/refresh-token.js" }, "./lib": { "types": "./lib/index.d.ts", "bun": "./browser/index.js", "deno": "./browser/index.js", "browser": "./browser/index.js", "worker": "./browser/index.js", "workerd": "./browser/index.js", "import": "./esm/index.js", "require": "./lib/index.js" }, "./lib/app-check": { "types": "./lib/app-check/index.d.ts", "bun": "./browser/app-check/index.js", "deno": "./browser/app-check/index.js", "browser": "./browser/app-check/index.js", "worker": "./browser/app-check/index.js", "workerd": "./browser/app-check/index.js", "import": "./esm/app-check/index.js", "require": "./lib/app-check/index.js" }, "./lib/auth": { "types": "./lib/auth/index.d.ts", "bun": "./browser/auth/index.js", "deno": "./browser/auth/index.js", "browser": "./browser/auth/index.js", "worker": "./browser/auth/index.js", "workerd": "./browser/auth/index.js", "import": "./esm/auth/index.js", "require": "./lib/auth/index.js" }, "./lib/auth/error": { "types": "./lib/auth/error.d.ts", "bun": "./browser/auth/error.js", "deno": "./browser/auth/error.js", "browser": "./browser/auth/error.js", "worker": "./browser/auth/error.js", "workerd": "./browser/auth/error.js", "import": "./esm/auth/error.js", "require": "./lib/auth/error.js" }, "./lib/auth/claims": { "types": "./lib/auth/claims.d.ts", "bun": "./browser/auth/claims.js", "deno": "./browser/auth/claims.js", "browser": "./browser/auth/claims.js", "worker": "./browser/auth/claims.js", "workerd": "./browser/auth/claims.js", "import": "./esm/auth/claims.js", "require": "./lib/auth/claims.js" }, "./lib/auth/token-verifier": { "types": "./lib/auth/token-verifier.d.ts", "bun": "./browser/auth/token-verifier.js", "deno": "./browser/auth/token-verifier.js", "browser": "./browser/auth/token-verifier.js", "worker": "./browser/auth/token-verifier.js", "workerd": "./browser/auth/token-verifier.js", "import": "./esm/auth/token-verifier.js", "require": "./lib/auth/token-verifier.js" }, "./lib/next/utils": { "types": "./lib/next/utils.d.ts", "bun": "./browser/next/utils.js", "deno": "./browser/next/utils.js", "browser": "./browser/next/utils.js", "worker": "./browser/next/utils.js", "workerd": "./browser/next/utils.js", "import": "./esm/next/utils.js", "require": "./lib/next/utils.js" }, "./lib/next/cookies": { "types": "./lib/next/cookies/index.d.ts", "bun": "./browser/next/cookies/index.js", "deno": "./browser/next/cookies/index.js", "browser": "./browser/next/cookies/index.js", "worker": "./browser/next/cookies/index.js", "workerd": "./browser/next/cookies/index.js", "import": "./esm/next/cookies/index.js", "require": "./lib/next/cookies/index.js" }, "./lib/next/tokens": { "types": "./lib/next/tokens.d.ts", "bun": "./browser/next/tokens.js", "deno": "./browser/next/tokens.js", "browser": "./browser/next/tokens.js", "worker": "./browser/next/tokens.js", "workerd": "./browser/next/tokens.js", "import": "./esm/next/tokens.js", "require": "./lib/next/tokens.js" }, "./lib/next/client": { "types": "./lib/next/client.d.ts", "bun": "./browser/next/client.js", "deno": "./browser/next/client.js", "browser": "./browser/next/client.js", "worker": "./browser/next/client.js", "workerd": "./browser/next/client.js", "import": "./esm/next/client.js", "require": "./lib/next/client.js" }, "./lib/next/api": { "types": "./lib/next/api.d.ts", "bun": "./browser/next/api.js", "deno": "./browser/next/api.js", "browser": "./browser/next/api.js", "worker": "./browser/next/api.js", "workerd": "./browser/next/api.js", "import": "./esm/next/api.js", "require": "./lib/next/api.js" }, "./lib/next/middleware": { "types": "./lib/next/middleware.d.ts", "bun": "./browser/next/middleware.js", "deno": "./browser/next/middleware.js", "browser": "./browser/next/middleware.js", "worker": "./browser/next/middleware.js", "workerd": "./browser/next/middleware.js", "import": "./esm/next/middleware.js", "require": "./lib/next/middleware.js" }, "./lib/next/refresh-token": { "types": "./lib/next/refresh-token.d.ts", "bun": "./browser/next/refresh-token.js", "deno": "./browser/next/refresh-token.js", "browser": "./browser/next/refresh-token.js", "worker": "./browser/next/refresh-token.js", "workerd": "./browser/next/refresh-token.js", "import": "./esm/next/refresh-token.js", "require": "./lib/next/refresh-token.js" } }, "scripts": { "build": "run-s clear build:*", "build:cjs": "tsc", "build:esm": "tsc -p tsconfig.esm.json", "build:browser": "tsc -p tsconfig.browser.json", "build:browser-bundle": "esbuild --bundle browser/index.js --format=esm --target=es2020 --outfile=browser/index.bundle.js", "build:browser-bundle-min": "esbuild --minify --bundle browser/index.js --format=esm --target=es2020 --outfile=browser/index.bundle.min.js", "build:browser-umd": "rollup browser/index.bundle.js --format umd --name next-firebase-auth-edge -o browser/index.umd.js && rollup browser/index.bundle.min.js --compact --format umd --name next-firebase-auth-edge -o browser/index.umd.min.js", "clear": "rm -Rf lib esm browser", "test": "jest src --coverage", "lint": "eslint src/", "check-circular-imports": "madge --extensions js,jsx,ts,tsx --ts-config tsconfig.json --circular src" }, "peerDependencies": { "next": "^14.0.0 || 15.0.0-rc.0 || ^15.0.0 || ^16.0.0" }, "dependencies": { "cookie": "^0.7.0", "encoding": "^0.1.13", "jose": "^5.6.3" }, "devDependencies": { "@commitlint/cli": "19.4.0", "@commitlint/config-conventional": "19.2.2", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@semantic-release/npm": "^13.1.4", "semantic-release": "^25.0.3", "@eslint/js": "^9.9.0", "@swc/core": "^1.7.26", "@swc/jest": "^0.2.36", "@types/cookie": "^0.6.0", "@types/eslint__js": "^8.42.3", "@types/jest": "^29.5.13", "@types/node": "^22.2.0", "@types/uuid": "^10.0.0", "dotenv": "^16.4.5", "esbuild": "^0.24.0", "eslint": "^9.9.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "husky": "9.1.4", "isomorphic-fetch": "^3.0.0", "jest": "^29.2.0", "jest-fetch-mock": "^3.0.3", "madge": "^8.0.0", "next": "^16.1.6", "npm-run-all2": "^6.2.3", "prettier": "^3.3.3", "react": "^19.1.1", "react-dom": "^19.1.1", "rollup": "^4.22.4", "typescript": "^5.5.4", "typescript-eslint": "^8.0.1", "uuid": "^10.0.0" }, "keywords": [ "firebase", "authentication", "firebase auth", "next", "next.js", "edge runtime", "edge", "middleware" ], "author": "Amadeusz Winogrodzki", "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/awinogrodzki/next-firebase-auth-edge.git" }, "engines": { "node": ">=16.0.0 <26.0.0", "npm": ">=8.0.0 <12.0.0", "yarn": ">=1.22.0 <2.0.0" }, "packageManager": "yarn@1.22.0" } ================================================ FILE: prettier.config.js ================================================ module.exports = { bracketSpacing: false, singleQuote: true, trailingComma: 'none', }; ================================================ FILE: src/app-check/api-client.ts ================================================ import {getSdkVersion} from '../auth/auth-request-handler.js'; import {Credential} from '../auth/credential.js'; import {formatString} from '../auth/utils.js'; import {AppCheckToken} from './types.js'; const FIREBASE_APP_CHECK_V1_API_URL_FORMAT = 'https://firebaseappcheck.googleapis.com/v1/projects/{projectId}/apps/{appId}:exchangeCustomToken'; const FIREBASE_APP_CHECK_CONFIG_HEADERS = { 'X-Firebase-Client': `fire-admin-node/${getSdkVersion()}` }; export class AppCheckApiClient { constructor(private credential: Credential) {} public async exchangeToken( customToken: string, appId: string ): Promise { const url = await this.getUrl(appId); const token = await this.credential.getAccessToken(false); const response = await fetch(url, { method: 'POST', headers: { ...FIREBASE_APP_CHECK_CONFIG_HEADERS, Authorization: `Bearer ${token.accessToken}` }, body: JSON.stringify({customToken}) }); if (response.ok) { return this.toAppCheckToken(response); } throw await this.toFirebaseError(response); } private async getUrl(appId: string): Promise { const projectId = await this.credential.getProjectId(); const urlParams = { projectId, appId }; const baseUrl = formatString( FIREBASE_APP_CHECK_V1_API_URL_FORMAT, urlParams ); return formatString(baseUrl); } private async toFirebaseError( response: Response ): Promise { const data = (await response.json()) as ErrorResponse; const error: Error = data.error || {}; let code: AppCheckErrorCode = 'unknown-error'; if (error.status && error.status in APP_CHECK_ERROR_CODE_MAPPING) { code = APP_CHECK_ERROR_CODE_MAPPING[error.status]; } const message = error.message || `Unknown server error: ${response.text}`; return new FirebaseAppCheckError(code, message); } private async toAppCheckToken(response: Response): Promise { const data = await response.json(); const token = data.token; const ttlMillis = this.stringToMilliseconds(data.ttl); return { token, ttlMillis }; } private stringToMilliseconds(duration: string): number { if (!duration.endsWith('s')) { throw new FirebaseAppCheckError( 'invalid-argument', '`ttl` must be a valid duration string with the suffix `s`.' ); } const seconds = duration.slice(0, -1); return Math.floor(Number(seconds) * 1000); } } export interface ErrorResponse { error?: Error; } export interface Error { code?: number; message?: string; status?: string; } export const APP_CHECK_ERROR_CODE_MAPPING: { [key: string]: AppCheckErrorCode; } = { ABORTED: 'aborted', INVALID_ARGUMENT: 'invalid-argument', INVALID_CREDENTIAL: 'invalid-credential', INTERNAL: 'internal-error', PERMISSION_DENIED: 'permission-denied', UNAUTHENTICATED: 'unauthenticated', NOT_FOUND: 'not-found', UNKNOWN: 'unknown-error' }; export type AppCheckErrorCode = | 'aborted' | 'invalid-argument' | 'invalid-credential' | 'internal-error' | 'permission-denied' | 'unauthenticated' | 'not-found' | 'app-check-token-expired' | 'unknown-error'; export class FirebaseAppCheckError extends Error { constructor( public readonly code: AppCheckErrorCode, message: string ) { super(`(${code}): ${message}`); Object.setPrototypeOf(this, FirebaseAppCheckError.prototype); } } ================================================ FILE: src/app-check/index.ts ================================================ import { Credential, ServiceAccount, ServiceAccountCredential } from '../auth/credential'; import {getApplicationDefault} from '../auth/default-credential.js'; import {cryptoSignerFromCredential} from '../auth/token-generator.js'; import {VerifyOptions} from '../auth/types.js'; import {AppCheckApiClient} from './api-client.js'; import {AppCheckTokenGenerator} from './token-generator.js'; import {AppCheckTokenVerifier} from './token-verifier.js'; import { AppCheckToken, AppCheckTokenOptions, VerifyAppCheckTokenResponse } from './types'; class AppCheck { private readonly client: AppCheckApiClient; private readonly tokenGenerator: AppCheckTokenGenerator; private readonly appCheckTokenVerifier: AppCheckTokenVerifier; constructor(credential: Credential, tenantId?: string) { this.client = new AppCheckApiClient(credential); this.tokenGenerator = new AppCheckTokenGenerator( cryptoSignerFromCredential(credential, tenantId) ); this.appCheckTokenVerifier = new AppCheckTokenVerifier(credential); } public createToken = ( appId: string, options?: AppCheckTokenOptions ): Promise => { return this.tokenGenerator .createCustomToken(appId, options) .then((customToken) => { return this.client.exchangeToken(customToken, appId); }); }; public verifyToken = ( appCheckToken: string, options: VerifyOptions ): Promise => { return this.appCheckTokenVerifier .verifyToken(appCheckToken, options) .then((decodedToken) => { return { appId: decodedToken.app_id, token: decodedToken }; }); }; } export interface AppCheckOptions { serviceAccount?: ServiceAccount; tenantId?: string; } function isAppCheckOptions( options: ServiceAccount | AppCheckOptions ): options is AppCheckOptions { const serviceAccount = options as ServiceAccount; return ( !serviceAccount.privateKey || !serviceAccount.projectId || !serviceAccount.clientEmail ); } export function getAppCheck(options: AppCheckOptions): AppCheck; /** @deprecated Use `AppCheckOptions` configuration object instead */ export function getAppCheck( serviceAccount: ServiceAccount, tenantId?: string ): AppCheck; export function getAppCheck( serviceAccount: ServiceAccount | AppCheckOptions, tenantId?: string ) { if (!isAppCheckOptions(serviceAccount)) { return new AppCheck(new ServiceAccountCredential(serviceAccount), tenantId); } const options = serviceAccount; const credential = options.serviceAccount ? new ServiceAccountCredential(options.serviceAccount) : getApplicationDefault(); return new AppCheck(credential, tenantId); } ================================================ FILE: src/app-check/test/app-check.integration.test.ts ================================================ import {getAppCheck} from '../index.js'; import {FirebaseAppCheckError} from '../api-client.js'; const { FIREBASE_PROJECT_ID, FIREBASE_ADMIN_CLIENT_EMAIL, FIREBASE_ADMIN_PRIVATE_KEY, FIREBASE_AUTH_TENANT_ID, FIREBASE_APP_ID } = process.env; const TEST_SERVICE_ACCOUNT = { clientEmail: FIREBASE_ADMIN_CLIENT_EMAIL!, privateKey: FIREBASE_ADMIN_PRIVATE_KEY!.replace(/\\n/g, '\n'), projectId: FIREBASE_PROJECT_ID! }; describe('app check integration test', () => { const scenarios = [ { desc: 'single-tenant', tenantID: undefined }, { desc: 'multi-tenant', tenantId: FIREBASE_AUTH_TENANT_ID } ]; for (const {desc, tenantId} of scenarios) { describe(desc, () => { const {createToken, verifyToken} = getAppCheck( TEST_SERVICE_ACCOUNT, tenantId ); it('should create and verify app check token', async () => { const {token} = await createToken(FIREBASE_APP_ID!); await verifyToken(token, {referer: 'http://localhost:3000'}); }); it('should throw app check expired error if token is expired', async () => { const {token} = await createToken(FIREBASE_APP_ID!); return expect(() => verifyToken(token, { currentDate: new Date(Date.now() + 7200 * 1000), referer: 'http://localhost:3000' }) ).rejects.toEqual( new FirebaseAppCheckError( 'app-check-token-expired', 'The provided App Check token has expired. Get a fresh App Check token from your client app and try again.' ) ); }); }); } }); ================================================ FILE: src/app-check/token-generator.ts ================================================ import {CryptoSigner} from '../auth/jwt/crypto-signer.js'; import {AppCheckTokenOptions} from './types.js'; import {FirebaseAppCheckError} from './api-client.js'; const ONE_MINUTE_IN_SECONDS = 60; const ONE_MINUTE_IN_MILLIS = ONE_MINUTE_IN_SECONDS * 1000; const ONE_DAY_IN_MILLIS = 24 * 60 * 60 * 1000; const FIREBASE_APP_CHECK_AUDIENCE = 'https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1.TokenExchangeService'; function transformMillisecondsToSecondsString(milliseconds: number): string { let duration: string; const seconds = Math.floor(milliseconds / 1000); const nanos = Math.floor((milliseconds - seconds * 1000) * 1000000); if (nanos > 0) { let nanoString = nanos.toString(); while (nanoString.length < 9) { nanoString = '0' + nanoString; } duration = `${seconds}.${nanoString}s`; } else { duration = `${seconds}s`; } return duration; } export class AppCheckTokenGenerator { private readonly signer: CryptoSigner; constructor(signer: CryptoSigner) { this.signer = signer; } public async createCustomToken( appId: string, options?: AppCheckTokenOptions ): Promise { if (!appId) { throw new FirebaseAppCheckError( 'invalid-argument', '`appId` must be a non-empty string.' ); } let customOptions = {}; if (typeof options !== 'undefined') { customOptions = this.validateTokenOptions(options); } const account = await this.signer.getAccountId(); const iat = Math.floor(Date.now() / 1000); const body = { iss: account, sub: account, app_id: appId, aud: FIREBASE_APP_CHECK_AUDIENCE, exp: iat + ONE_MINUTE_IN_SECONDS * 5, iat, ...customOptions }; return this.signer.sign(body); } private validateTokenOptions(options: AppCheckTokenOptions): { [key: string]: unknown; } { if (typeof options.ttlMillis !== 'undefined') { if ( options.ttlMillis < ONE_MINUTE_IN_MILLIS * 30 || options.ttlMillis > ONE_DAY_IN_MILLIS * 7 ) { throw new FirebaseAppCheckError( 'invalid-argument', 'ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive).' ); } return {ttl: transformMillisecondsToSecondsString(options.ttlMillis)}; } return {}; } } ================================================ FILE: src/app-check/token-verifier.ts ================================================ import {decodeJwt, decodeProtectedHeader, errors} from 'jose'; import {JOSEError} from 'jose/dist/types/util/errors'; import {Credential} from '../auth/credential.js'; import {ALGORITHM_RS256} from '../auth/jwt/verify.js'; import { DecodedToken, JWKSSignatureVerifier, SignatureVerifier } from '../auth/signature-verifier'; import {VerifyOptions} from '../auth/types.js'; import {FirebaseAppCheckError} from './api-client.js'; import {DecodedAppCheckToken} from './types.js'; const APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/'; const JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks'; export class AppCheckTokenVerifier { private readonly signatureVerifier: SignatureVerifier; constructor(private readonly credential: Credential) { this.signatureVerifier = new JWKSSignatureVerifier(JWKS_URL); } public async verifyToken( token: string, options: VerifyOptions ): Promise { const projectId = await this.credential.getProjectId(); const decoded = await this.decodeAndVerify(token, projectId, options); const decodedAppCheckToken = decoded.payload as DecodedAppCheckToken; decodedAppCheckToken.app_id = decodedAppCheckToken.sub; return decodedAppCheckToken; } private async decodeAndVerify( token: string, projectId: string, options: VerifyOptions ): Promise { const header = decodeProtectedHeader(token); const payload = decodeJwt(token); this.verifyContent({header, payload}, projectId); await this.verifySignature(token, options); return {header, payload}; } private verifyContent( fullDecodedToken: DecodedToken, projectId: string | null ): void { const header = fullDecodedToken.header; const payload = fullDecodedToken.payload; const projectIdMatchMessage = ' Make sure the App Check token comes from the same ' + 'Firebase project as the service account used to authenticate this SDK.'; const scopedProjectId = `projects/${projectId}`; let errorMessage: string | undefined; if (header.alg !== ALGORITHM_RS256) { errorMessage = 'The provided App Check token has incorrect algorithm. Expected "' + ALGORITHM_RS256 + '" but got ' + '"' + header.alg + '".'; } else if (!payload.aud?.includes(scopedProjectId)) { errorMessage = 'The provided App Check token has incorrect "aud" (audience) claim. Expected "' + scopedProjectId + '" but got "' + payload.aud + '".' + projectIdMatchMessage; } else if ( typeof payload.iss !== 'string' || !payload.iss.startsWith(APP_CHECK_ISSUER) ) { errorMessage = 'The provided App Check token has incorrect "iss" (issuer) claim.'; } else if (typeof payload.sub !== 'string') { errorMessage = 'The provided App Check token has no "sub" (subject) claim.'; } else if (payload.sub === '') { errorMessage = 'The provided App Check token has an empty string "sub" (subject) claim.'; } if (errorMessage) { throw new FirebaseAppCheckError('invalid-argument', errorMessage); } } private verifySignature( jwtToken: string, options: VerifyOptions ): Promise { return this.signatureVerifier .verify(jwtToken, options) .catch((error: JOSEError) => { throw this.mapJwtErrorToAppCheckError(error); }); } private mapJwtErrorToAppCheckError(error: JOSEError): FirebaseAppCheckError { if (error instanceof errors.JWTExpired) { const errorMessage = 'The provided App Check token has expired. Get a fresh App Check token' + ' from your client app and try again.'; return new FirebaseAppCheckError('app-check-token-expired', errorMessage); } else if (error instanceof errors.JWSSignatureVerificationFailed) { const errorMessage = 'The provided App Check token has invalid signature.'; return new FirebaseAppCheckError('invalid-argument', errorMessage); } else if (error instanceof errors.JWKSNoMatchingKey) { const errorMessage = 'The provided App Check token has "kid" claim which does not ' + 'correspond to a known public key. Most likely the provided App Check token ' + 'is expired, so get a fresh token from your client app and try again.'; return new FirebaseAppCheckError('invalid-argument', errorMessage); } return new FirebaseAppCheckError('invalid-argument', error.message); } } ================================================ FILE: src/app-check/types.ts ================================================ export interface AppCheckToken { token: string; ttlMillis: number; } export interface AppCheckTokenOptions { ttlMillis?: number; } export interface DecodedAppCheckToken { iss: string; sub: string; aud: string[]; exp: number; iat: number; app_id: string; [key: string]: unknown; } export interface VerifyAppCheckTokenResponse { appId: string; token: DecodedAppCheckToken; } ================================================ FILE: src/auth/auth-request-handler.ts ================================================ import { Credential, FirebaseAccessToken, getFirebaseAdminTokenProvider } from './credential'; import {AuthError, AuthErrorCode} from './error.js'; import {emulatorHost, useEmulator} from './firebase.js'; import {GetAccountInfoUserResponse} from './user-record.js'; import {formatString} from './utils.js'; import {isEmail, isNonNullObject} from './validator.js'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'; export class ApiSettings { constructor( private endpoint: string, private httpMethod: HttpMethod = 'POST' ) {} public getEndpoint(): string { return this.endpoint; } public getHttpMethod(): HttpMethod { return this.httpMethod; } } export function getSdkVersion(): string { return '11.2.0'; } const FIREBASE_AUTH_HEADER = { 'X-Client-Version': `Node/Admin/${getSdkVersion()}`, Accept: 'application/json', 'Content-Type': 'application/json' }; const FIREBASE_AUTH_BASE_URL_FORMAT = 'https://identitytoolkit.googleapis.com/{version}/projects/{projectId}{api}'; const FIREBASE_AUTH_EMULATOR_BASE_URL_FORMAT = 'http://{host}/identitytoolkit.googleapis.com/{version}/projects/{projectId}{api}'; class AuthResourceUrlBuilder { protected urlFormat: string; constructor( protected version: string = 'v1', private credential: Credential ) { if (useEmulator()) { this.urlFormat = formatString(FIREBASE_AUTH_EMULATOR_BASE_URL_FORMAT, { host: emulatorHost() }); } else { this.urlFormat = FIREBASE_AUTH_BASE_URL_FORMAT; } } public async getUrl(api?: string, params?: object): Promise { const baseParams = { version: this.version, projectId: await this.credential.getProjectId(), api: api || '' }; const baseUrl = formatString(this.urlFormat, baseParams); return formatString(baseUrl, params || {}); } } export const FIREBASE_AUTH_CREATE_SESSION_COOKIE = new ApiSettings( ':createSessionCookie', 'POST' ); export const FIREBASE_AUTH_GET_ACCOUNT_INFO = new ApiSettings( '/accounts:lookup', 'POST' ); export const FIREBASE_AUTH_DELETE_ACCOUNT = new ApiSettings( '/accounts:delete', 'POST' ); export const FIREBASE_AUTH_SET_ACCOUNT_INFO = new ApiSettings( '/accounts:update', 'POST' ); export const FIREBASE_AUTH_SIGN_UP_NEW_USER = new ApiSettings( '/accounts', 'POST' ); export const FIREBASE_AUTH_LIST_USERS_INFO = new ApiSettings( '/accounts:batchGet', 'GET' ); export type ListUsersResponse = { kind: string; users: GetAccountInfoUserResponse[]; nextPageToken: string; }; export type GetAccountInfoByEmailResponse = { users: GetAccountInfoUserResponse[]; }; type ResponseObject = { localId: string; }; export interface AuthRequestHandlerOptions { tenantId?: string; } export interface ErrorResponse { error: Error; } export abstract class AbstractAuthRequestHandler { private authUrlBuilder: AuthResourceUrlBuilder | undefined; private getToken: (forceRefresh?: boolean) => Promise; private static getErrorCode(response: unknown): string | null { return ( (isNonNullObject(response) && (response as ErrorResponse).error && (response as ErrorResponse).error.message) || null ); } constructor( credential: Credential, protected options: AuthRequestHandlerOptions = {} ) { this.getToken = useEmulator() ? () => Promise.resolve({ accessToken: 'owner', expirationTime: Infinity }) : getFirebaseAdminTokenProvider(credential).getToken; } private prepareRequest(request: object) { if (!this.options.tenantId) { return request; } return { ...request, tenantId: this.options.tenantId }; } public getAccountInfoByUid( uid: string ): Promise<{users?: GetAccountInfoUserResponse[]}> { const request = { localId: [uid] }; return this.invokeRequestHandler( this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request ); } public deleteAccount(uid: string): Promise { return this.invokeRequestHandler( this.getAuthUrlBuilder(), FIREBASE_AUTH_DELETE_ACCOUNT, { localId: uid } ); } public getAccountInfoByEmail( email: string ): Promise { if (!isEmail(email)) { return Promise.reject( new AuthError(AuthErrorCode.INVALID_ARGUMENT, 'Invalid e-mail address') ); } const request = { email: [email] }; return this.invokeRequestHandler( this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request ); } public createNewAccount(properties: CreateRequest): Promise { type SignUpNewUserRequest = CreateRequest & { photoUrl?: string | null; localId?: string; mfaInfo?: AuthFactorInfo[]; }; const request: SignUpNewUserRequest = { ...properties }; if (typeof request.photoURL !== 'undefined') { request.photoUrl = request.photoURL; delete request.photoURL; } if (typeof request.uid !== 'undefined') { request.localId = request.uid; delete request.uid; } if (request.multiFactor) { if ( Array.isArray(request.multiFactor.enrolledFactors) && request.multiFactor.enrolledFactors.length > 0 ) { const mfaInfo: AuthFactorInfo[] = []; try { request.multiFactor.enrolledFactors.forEach((multiFactorInfo) => { if ('enrollmentTime' in multiFactorInfo) { throw new AuthError( AuthErrorCode.INVALID_ARGUMENT, '"enrollmentTime" is not supported when adding second factors via "createUser()"' ); } else if ('uid' in multiFactorInfo) { throw new AuthError( AuthErrorCode.INVALID_ARGUMENT, '"uid" is not supported when adding second factors via "createUser()"' ); } mfaInfo.push(convertMultiFactorInfoToServerFormat(multiFactorInfo)); }); } catch (e) { return Promise.reject(e); } request.mfaInfo = mfaInfo; } delete request.multiFactor; } return this.invokeRequestHandler( this.getAuthUrlBuilder(), FIREBASE_AUTH_SIGN_UP_NEW_USER, request ).then((response) => { return response.localId; }); } public createSessionCookie( idToken: string, expiresInMs: number ): Promise { const request = { idToken, // To seconds validDuration: expiresInMs / 1000 }; return this.invokeRequestHandler<{sessionCookie: string}>( this.getAuthUrlBuilder(), FIREBASE_AUTH_CREATE_SESSION_COOKIE, request ).then((response) => response.sessionCookie); } public updateExistingAccount( uid: string, properties: UpdateRequest ): Promise { const request: UpdateRequest & { deleteAttribute?: string[]; deleteProvider?: string[]; linkProviderUserInfo?: UserProvider & {rawId?: string}; photoUrl?: string | null; disableUser?: boolean; mfa?: { enrollments?: AuthFactorInfo[]; }; localId: string; } = { ...properties, deleteAttribute: [], localId: uid }; const deletableParams: {[key: string]: string} = { displayName: 'DISPLAY_NAME', photoURL: 'PHOTO_URL' }; request.deleteAttribute = []; for (const key in deletableParams) { if (request[key as keyof UpdateRequest] === null) { request.deleteAttribute.push(deletableParams[key]); delete request[key as keyof UpdateRequest]; } } if (request.deleteAttribute.length === 0) { delete request.deleteAttribute; } if (request.phoneNumber === null) { if (request.deleteProvider) { request.deleteProvider.push('phone'); } else { request.deleteProvider = ['phone']; } delete request.phoneNumber; } if (typeof request.providerToLink !== 'undefined') { request.linkProviderUserInfo = {...request.providerToLink}; delete request.providerToLink; request.linkProviderUserInfo.rawId = request.linkProviderUserInfo.uid; delete request.linkProviderUserInfo.uid; } if (typeof request.providersToUnlink !== 'undefined') { if (!Array.isArray(request.deleteProvider)) { request.deleteProvider = []; } request.deleteProvider = request.deleteProvider.concat( request.providersToUnlink ); delete request.providersToUnlink; } if (typeof request.photoURL !== 'undefined') { request.photoUrl = request.photoURL; delete request.photoURL; } if (typeof request.disabled !== 'undefined') { request.disableUser = request.disabled; delete request.disabled; } if (request.multiFactor) { if (request.multiFactor.enrolledFactors === null) { request.mfa = {}; } else if (Array.isArray(request.multiFactor.enrolledFactors)) { request.mfa = { enrollments: [] }; try { request.multiFactor.enrolledFactors.forEach( (multiFactorInfo: UpdateMultiFactorInfoRequest) => { request.mfa!.enrollments!.push( convertMultiFactorInfoToServerFormat(multiFactorInfo) ); } ); } catch (e) { return Promise.reject(e); } if (request.mfa!.enrollments!.length === 0) { delete request.mfa.enrollments; } } delete request.multiFactor; } return this.invokeRequestHandler( this.getAuthUrlBuilder(), FIREBASE_AUTH_SET_ACCOUNT_INFO, request ).then((response) => { return response.localId; }); } public setCustomUserClaims( uid: string, customUserClaims: object | null ): Promise { if (customUserClaims === null) { customUserClaims = {}; } const request = { localId: uid, customAttributes: JSON.stringify(customUserClaims) }; return this.invokeRequestHandler( this.getAuthUrlBuilder(), FIREBASE_AUTH_SET_ACCOUNT_INFO, request ).then((response) => { return response.localId; }); } public listUsers(nextPageToken?: string, maxResults?: number) { const request = { nextPageToken, maxResults }; return this.invokeRequestHandler( this.getAuthUrlBuilder(), FIREBASE_AUTH_LIST_USERS_INFO, request ); } private getSearchParams(requestData: object) { const searchParams = new URLSearchParams(); for (const key in requestData) { if (!requestData[key as keyof object]) { continue; } searchParams.append(key, requestData[key as keyof object]); } return searchParams; } private decorateUrlWithParams(url: string, requestData: object) { const params = this.getSearchParams(requestData); const paramsString = params.toString(); if (!paramsString) { return url; } return `${url}?${paramsString}`; } protected async invokeRequestHandler( urlBuilder: AuthResourceUrlBuilder, apiSettings: ApiSettings, requestData: object | undefined, additionalResourceParams?: object ): Promise { let url = await urlBuilder.getUrl( apiSettings.getEndpoint(), additionalResourceParams ); const token = await this.getToken(); const init: RequestInit = { method: apiSettings.getHttpMethod(), headers: { ...FIREBASE_AUTH_HEADER, Authorization: `Bearer ${token.accessToken}` } }; if (requestData && !['GET', 'HEAD'].includes(apiSettings.getHttpMethod())) { init.body = JSON.stringify(this.prepareRequest(requestData)); } if (requestData && ['GET', 'HEAD'].includes(apiSettings.getHttpMethod())) { url = this.decorateUrlWithParams(url, this.prepareRequest(requestData)); } const res = await fetch(url, init); if (!res.ok) { const error = await res.json(); const errorCode = AbstractAuthRequestHandler.getErrorCode(error); if (!errorCode) { throw new AuthError( AuthErrorCode.INTERNAL_ERROR, `Error returned from server: ${JSON.stringify(error)}.` ); } throw new AuthError( AuthErrorCode.INTERNAL_ERROR, `Error returned from server: ${JSON.stringify( error )}. Code: ${errorCode}` ); } return await res.json(); } protected abstract newAuthUrlBuilder(): AuthResourceUrlBuilder; private getAuthUrlBuilder(): AuthResourceUrlBuilder { if (!this.authUrlBuilder) { this.authUrlBuilder = this.newAuthUrlBuilder(); } return this.authUrlBuilder; } } export class AuthRequestHandler extends AbstractAuthRequestHandler { protected readonly authResourceUrlBuilder: AuthResourceUrlBuilder; constructor( private credential: Credential, options?: AuthRequestHandlerOptions ) { super(credential, options); this.authResourceUrlBuilder = new AuthResourceUrlBuilder('v2', credential); } protected newAuthUrlBuilder(): AuthResourceUrlBuilder { return new AuthResourceUrlBuilder('v1', this.credential); } } function isPhoneFactor( multiFactorInfo: UpdateMultiFactorInfoRequest ): multiFactorInfo is UpdatePhoneMultiFactorInfoRequest { return multiFactorInfo.factorId === 'phone'; } function isUTCDateString(dateString: string): boolean { try { return ( Boolean(dateString) && new Date(dateString).toUTCString() === dateString ); } catch { return false; } } export function convertMultiFactorInfoToServerFormat( multiFactorInfo: UpdateMultiFactorInfoRequest ): AuthFactorInfo { let enrolledAt; if (typeof multiFactorInfo.enrollmentTime !== 'undefined') { if (isUTCDateString(multiFactorInfo.enrollmentTime)) { enrolledAt = new Date(multiFactorInfo.enrollmentTime).toISOString(); } else { throw new AuthError( AuthErrorCode.INVALID_ARGUMENT, `The second factor "enrollmentTime" for "${multiFactorInfo.uid}" must be a valid ` + 'UTC date string.' ); } } if (isPhoneFactor(multiFactorInfo)) { const authFactorInfo: AuthFactorInfo = { mfaEnrollmentId: multiFactorInfo.uid, displayName: multiFactorInfo.displayName, phoneInfo: multiFactorInfo.phoneNumber, enrolledAt }; for (const objKey in authFactorInfo) { if (typeof authFactorInfo[objKey] === 'undefined') { delete authFactorInfo[objKey]; } } return authFactorInfo; } else { throw new AuthError( AuthErrorCode.INVALID_ARGUMENT, `Unsupported second factor "${JSON.stringify(multiFactorInfo)}" provided.` ); } } export interface AuthFactorInfo { mfaEnrollmentId?: string; displayName?: string; phoneInfo?: string; enrolledAt?: string; [key: string]: unknown; } export interface BaseUpdateMultiFactorInfoRequest { uid?: string; displayName?: string; enrollmentTime?: string; factorId: string; } export interface UpdatePhoneMultiFactorInfoRequest extends BaseUpdateMultiFactorInfoRequest { phoneNumber: string; } export type UpdateMultiFactorInfoRequest = UpdatePhoneMultiFactorInfoRequest; export interface MultiFactorUpdateSettings { enrolledFactors: UpdateMultiFactorInfoRequest[] | null; } export interface UpdateRequest { disabled?: boolean; displayName?: string | null; email?: string; emailVerified?: boolean; password?: string; phoneNumber?: string | null; photoURL?: string | null; multiFactor?: MultiFactorUpdateSettings; providerToLink?: UserProvider; providersToUnlink?: string[]; tenantId?: string; } export interface UserProvider { uid?: string; displayName?: string; email?: string; phoneNumber?: string; photoURL?: string; providerId?: string; } export interface BaseCreateMultiFactorInfoRequest { displayName?: string; factorId: string; } export interface CreatePhoneMultiFactorInfoRequest extends BaseCreateMultiFactorInfoRequest { phoneNumber: string; } export type CreateMultiFactorInfoRequest = CreatePhoneMultiFactorInfoRequest; export interface MultiFactorCreateSettings { enrolledFactors: CreateMultiFactorInfoRequest[]; } export interface CreateRequest extends UpdateRequest { uid?: string; multiFactor?: MultiFactorCreateSettings; } ================================================ FILE: src/auth/claims.ts ================================================ export type Claims = {[key: string]: unknown}; export const STANDARD_CLAIMS = [ 'aud', 'auth_time', 'email', 'email_verified', 'exp', 'firebase', 'source_sign_in_provider', 'iat', 'iss', 'name', 'phone_number', 'picture', 'sub', 'uid', 'user_id' ]; export const filterStandardClaims = (obj: Claims = {}) => { const claims: Claims = {}; Object.keys(obj).forEach((key) => { if (!STANDARD_CLAIMS.includes(key)) { claims[key] = obj[key]; } }); return claims; }; ================================================ FILE: src/auth/credential.ts ================================================ import {JWTPayload} from 'jose'; import {sign} from './jwt/sign.js'; import {fetchJson, fetchText} from './utils.js'; export interface GoogleOAuthAccessToken { access_token: string; expires_in: number; } export interface Credential { getProjectId(): Promise; getServiceAccountEmail(): Promise; getAccessToken(forceRefresh?: boolean): Promise; } const TOKEN_EXPIRY_THRESHOLD_MILLIS = 5 * 60 * 1000; const GOOGLE_TOKEN_AUDIENCE = 'https://accounts.google.com/o/oauth2/token'; const GOOGLE_AUTH_TOKEN_HOST = 'accounts.google.com'; const GOOGLE_AUTH_TOKEN_PATH = '/o/oauth2/token'; const ONE_HOUR_IN_SECONDS = 60 * 60; export interface ServiceAccount { projectId: string; privateKey: string; clientEmail: string; } const accessTokenCache: Map = new Map(); export class ServiceAccountCredential implements Credential { public readonly projectId: string; public readonly privateKey: string; public readonly clientEmail: string; constructor(serviceAccount: ServiceAccount) { this.projectId = serviceAccount.projectId; this.privateKey = serviceAccount.privateKey; this.clientEmail = serviceAccount.clientEmail; } private async fetchAccessToken(url: string): Promise { const token = await this.createJwt(); const postData = 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3A' + 'grant-type%3Ajwt-bearer&assertion=' + token; return requestAccessToken(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Bearer ${token}`, Accept: 'application/json' }, body: postData }); } public getProjectId(): Promise { return Promise.resolve(this.projectId); } public getServiceAccountEmail(): Promise { return Promise.resolve(this.clientEmail); } private async fetchAndCacheAccessToken(url: string) { const response = await this.fetchAccessToken(url); accessTokenCache.set(url.toString(), response); return response; } public async getAccessToken( forceRefresh: boolean ): Promise { const url = `https://${GOOGLE_AUTH_TOKEN_HOST}${GOOGLE_AUTH_TOKEN_PATH}`; if (forceRefresh) { return this.fetchAndCacheAccessToken(url); } const cachedResponse = accessTokenCache.get(url); if ( !cachedResponse || cachedResponse.expirationTime - Date.now() <= TOKEN_EXPIRY_THRESHOLD_MILLIS ) { return this.fetchAndCacheAccessToken(url); } return cachedResponse; } private async createJwt(): Promise { const iat = Math.floor(Date.now() / 1000); const payload = { aud: GOOGLE_TOKEN_AUDIENCE, iat, exp: iat + ONE_HOUR_IN_SECONDS, iss: this.clientEmail, sub: this.clientEmail, scope: [ 'https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/firebase.database', 'https://www.googleapis.com/auth/firebase.messaging', 'https://www.googleapis.com/auth/identitytoolkit', 'https://www.googleapis.com/auth/userinfo.email' ].join(' ') } as JWTPayload; return sign({ payload, privateKey: this.privateKey }); } } async function requestAccessToken( urlString: string, init: RequestInit ): Promise { const json = await fetchJson(urlString, init); if (!json.access_token || !json.expires_in) { throw new Error( `Unexpected response while fetching access token: ${JSON.stringify(json)}` ); } return { accessToken: json.access_token, expirationTime: Date.now() + json.expires_in * 1000 }; } export function getExplicitProjectId(): string | null { const projectId = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT; if (projectId) { return projectId; } return null; } const GOOGLE_METADATA_SERVICE_HOST = 'metadata.google.internal'; const GOOGLE_METADATA_SERVICE_TOKEN_PATH = '/computeMetadata/v1/instance/service-accounts/default/token'; const GOOGLE_METADATA_SERVICE_IDENTITY_PATH = '/computeMetadata/v1/instance/service-accounts/default/identity'; const GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH = '/computeMetadata/v1/project/project-id'; const GOOGLE_METADATA_SERVICE_ACCOUNT_ID_PATH = '/computeMetadata/v1/instance/service-accounts/default/email'; async function requestIDToken( url: string, request: RequestInit ): Promise { const text = await fetchText(url, request); if (!text) { throw new Error( 'Unexpected response while fetching id token: response.text is undefined' ); } return text; } export class ComputeEngineCredential implements Credential { private projectId?: string; private accountId?: string; private cachedToken?: FirebaseAccessToken; constructor() {} public async getAccessToken( forceRefresh: boolean = false ): Promise { const url = `http://${GOOGLE_METADATA_SERVICE_HOST}${GOOGLE_METADATA_SERVICE_TOKEN_PATH}`; const request = this.buildRequest(); if ( this.cachedToken && !forceRefresh && this.cachedToken.expirationTime - Date.now() <= TOKEN_EXPIRY_THRESHOLD_MILLIS ) { return this.cachedToken; } return (this.cachedToken = await requestAccessToken(url, request)); } public getIDToken(audience: string): Promise { const url = `http://${GOOGLE_METADATA_SERVICE_HOST}${GOOGLE_METADATA_SERVICE_IDENTITY_PATH}?audience=${audience}`; const request = this.buildRequest(); return requestIDToken(url, request); } public async getProjectId(): Promise { if (this.projectId) { return Promise.resolve(this.projectId); } const url = `http://${GOOGLE_METADATA_SERVICE_HOST}${GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH}`; const request = this.buildRequest(); try { const text = await fetchText(url, request); this.projectId = text; return this.projectId; } catch (err) { throw new Error(`Failed to determine project ID: ${err}`); } } public async getServiceAccountEmail(): Promise { if (this.accountId) { return Promise.resolve(this.accountId); } const url = `http://${GOOGLE_METADATA_SERVICE_HOST}${GOOGLE_METADATA_SERVICE_ACCOUNT_ID_PATH}`; const request = this.buildRequest(); try { const text = await fetchText(url, request); this.accountId = text; return this.accountId; } catch (err) { throw new Error(`Failed to determine service account email: ${err}`); } } private buildRequest(): RequestInit { return { method: 'GET', headers: { 'Metadata-Flavor': 'Google' } }; } } export interface FirebaseAccessToken { accessToken: string; expirationTime: number; } export const getFirebaseAdminTokenProvider = (credential: Credential) => { async function getToken(forceRefresh = false): Promise { return credential.getAccessToken(forceRefresh); } return { getToken }; }; ================================================ FILE: src/auth/custom-token/index.test.ts ================================================ import {errors} from 'jose'; import {CustomJWTPayload, createCustomJWT, verifyCustomJWT} from '.'; describe('custom jwt', () => { const secret = 'very-secure-secret'; const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6Ik1PQ0tfSURfVE9LRU4iLCJyZWZyZXNoX3Rva2VuIjoiTU9DS19SRUZSRVNIX1RPS0VOIiwiY3VzdG9tX3Rva2VuIjoiTU9DS19DVVNUT01fVE9LRU4ifQ.-QNV35-rSl-jCgXHiR3gMW5G-TAkKcT5AimTc7BTPFA'; const payload: CustomJWTPayload = { id_token: 'MOCK_ID_TOKEN', refresh_token: 'MOCK_REFRESH_TOKEN', custom_token: 'MOCK_CUSTOM_TOKEN' }; it('generates custom jwt with id, refresh and custom tokens as a payload', async () => { const token = await createCustomJWT(payload, secret); expect(token).toEqual(jwt); }); it('verifies custom jwt with provided secret', async () => { const result = await verifyCustomJWT(jwt, secret); expect(result.payload).toEqual(payload); }); it('throws error when secret is invalid', async () => { return expect(() => verifyCustomJWT(jwt, 'invalid-secret') ).rejects.toBeInstanceOf(errors.JWSSignatureVerificationFailed); }); }); ================================================ FILE: src/auth/custom-token/index.ts ================================================ import { FlattenedSign, JWTPayload, JWTVerifyResult, SignJWT, errors, jwtVerify } from 'jose'; import {DecodedIdToken} from '../types.js'; import {toUint8Array} from '../utils.js'; export interface CustomTokens { idToken: string; refreshToken: string; customToken: string; } export interface ParsedCookies { idToken: string; refreshToken: string; customToken?: string; metadata: Metadata; } export interface VerifiedCookies { idToken: string; refreshToken: string; customToken?: string; decodedIdToken: DecodedIdToken; metadata: Metadata; } export interface CustomJWTHeader { alg: 'HS256'; typ: 'JWT'; } export interface CustomJWTPayload extends JWTPayload { id_token: string; refresh_token: string; custom_token?: string; metadata?: Metadata; } export async function createCustomSignature( value: ParsedCookies, key: string ) { let data = `${value.idToken}.${value.refreshToken}`; if (value.customToken) { data += `.${value.customToken}`; } if (value.metadata && Object.keys(value.metadata).length > 0) { data += `.${JSON.stringify(value.metadata)}`; } const jws = await new FlattenedSign(toUint8Array(data)) .setProtectedHeader({alg: 'HS256'}) .sign(toUint8Array(key)); return jws.signature; } export async function verifyCustomSignature( value: ParsedCookies, signature: string, key: string ): Promise { if ((await createCustomSignature(value, key)) !== signature) { throw new errors.JWSSignatureVerificationFailed(''); } } export async function createCustomJWT( payload: CustomJWTPayload, secret: string ): Promise { const jwt = new SignJWT(payload); jwt.setProtectedHeader({alg: 'HS256', typ: 'JWT'}); return jwt.sign(toUint8Array(secret)); } export async function verifyCustomJWT( customJWT: string, secret: string ): Promise>> { return jwtVerify(customJWT, toUint8Array(secret)); } ================================================ FILE: src/auth/default-credential.ts ================================================ import {ComputeEngineCredential, Credential} from './credential.js'; export const getApplicationDefault = (): Credential => { return new ComputeEngineCredential(); }; ================================================ FILE: src/auth/error.ts ================================================ export enum AuthErrorCode { USER_NOT_FOUND = 'USER_NOT_FOUND', USER_DISABLED = 'USER_DISABLED', INVALID_CREDENTIAL = 'INVALID_CREDENTIAL', TOKEN_EXPIRED = 'TOKEN_EXPIRED', TOKEN_REVOKED = 'TOKEN_REVOKED', INVALID_ARGUMENT = 'INVALID_ARGUMENT', INTERNAL_ERROR = 'INTERNAL_ERROR', NO_KID_IN_HEADER = 'NO_KID_IN_HEADER', INVALID_SIGNATURE = 'INVALID_SIGNATURE', NO_MATCHING_KID = 'NO_MATCHING_KID', MISMATCHING_TENANT_ID = 'MISMATCHING_TENANT_ID' } export interface HttpError { reason: string; message: string; } const AuthErrorMessages: Record = { [AuthErrorCode.USER_NOT_FOUND]: 'User not found', [AuthErrorCode.INVALID_CREDENTIAL]: 'Invalid credentials', [AuthErrorCode.TOKEN_EXPIRED]: 'Token expired', [AuthErrorCode.USER_DISABLED]: 'User disabled', [AuthErrorCode.TOKEN_REVOKED]: 'Token revoked', [AuthErrorCode.INVALID_ARGUMENT]: 'Invalid argument', [AuthErrorCode.INTERNAL_ERROR]: 'Internal error', [AuthErrorCode.NO_KID_IN_HEADER]: 'No kid in jwt header', [AuthErrorCode.INVALID_SIGNATURE]: 'Invalid token signature.', [AuthErrorCode.NO_MATCHING_KID]: 'Kid is not matching any certificate', [AuthErrorCode.MISMATCHING_TENANT_ID]: 'Provided tenant ID does not match firebase tenant ID from the token' }; function getErrorMessage(code: AuthErrorCode, customMessage?: string) { if (!customMessage) { return AuthErrorMessages[code]; } return `${AuthErrorMessages[code]}: ${customMessage}`; } function mergeStackTraceAndCause(target: Error, original: unknown) { const originalError = original as Error | undefined; const originalErrorStack = typeof originalError?.stack === 'string' ? originalError.stack : ''; const originalCause = typeof originalError?.cause === 'string' ? originalError.cause : ''; target.stack = (target?.stack ?? '') + originalErrorStack; target.cause = (target?.cause ?? '') + originalCause; } export class AuthError extends Error { public static fromError( error: unknown, code: AuthErrorCode, customMessage?: string ) { const authError = new AuthError(code, customMessage); mergeStackTraceAndCause(authError, error); return authError; } constructor( readonly code: AuthErrorCode, customMessage?: string ) { super(getErrorMessage(code, customMessage)); Object.setPrototypeOf(this, AuthError.prototype); } public toJSON(): object { return { code: this.code, message: this.message }; } } export enum InvalidTokenReason { MISSING_CREDENTIALS = 'MISSING_CREDENTIALS', MISSING_REFRESH_TOKEN = 'MISSING_REFRESH_TOKEN', MALFORMED_CREDENTIALS = 'MALFORMED_CREDENTIALS', INVALID_SIGNATURE = 'INVALID_SIGNATURE', INVALID_CREDENTIALS = 'INVALID_CREDENTIALS', INVALID_KID = 'INVALID_KID' } const InvalidTokenMessages: Record = { [InvalidTokenReason.MISSING_CREDENTIALS]: 'Missing credentials', [InvalidTokenReason.MALFORMED_CREDENTIALS]: 'Credentials are incorrectly formatted', [InvalidTokenReason.INVALID_SIGNATURE]: 'Credentials have invalid signature', [InvalidTokenReason.MISSING_REFRESH_TOKEN]: 'Refresh token is missing', [InvalidTokenReason.INVALID_CREDENTIALS]: 'Invalid credentials', [InvalidTokenReason.INVALID_KID]: 'Token has kid claim that cannot be matched with any known Google certificate. This usually indicates that Google certificates have expired and user has to reauthenticate.' }; export class InvalidTokenError extends Error { public static fromError(error: unknown, reason: InvalidTokenReason) { const invalidTokenError = new InvalidTokenError(reason); mergeStackTraceAndCause(invalidTokenError, error); return invalidTokenError; } public isInvalidTokenError = true; constructor(public readonly reason: InvalidTokenReason) { super(`${reason}: ${InvalidTokenMessages[reason]}`); Object.setPrototypeOf(this, InvalidTokenError.prototype); } } export function isInvalidTokenError( error: unknown ): error is InvalidTokenError { return (error as InvalidTokenError | undefined)?.isInvalidTokenError ?? false; } ================================================ FILE: src/auth/firebase.ts ================================================ export const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; export const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; export function emulatorHost(): string | undefined { if (typeof process === 'undefined') return undefined; return process.env.FIREBASE_AUTH_EMULATOR_HOST; } export function useEmulator(): boolean { return !!emulatorHost(); } ================================================ FILE: src/auth/index.ts ================================================ import {debug} from '../debug/index.js'; import { AuthRequestHandler, CreateRequest, UpdateRequest } from './auth-request-handler'; import {filterStandardClaims} from './claims'; import { Credential, ServiceAccount, ServiceAccountCredential } from './credential'; import {CustomTokens, ParsedCookies} from './custom-token/index.js'; import {getApplicationDefault} from './default-credential.js'; import { AuthError, AuthErrorCode, InvalidTokenError, InvalidTokenReason } from './error'; import {useEmulator} from './firebase.js'; import {createFirebaseTokenGenerator} from './token-generator.js'; import {createIdTokenVerifier} from './token-verifier.js'; import {DecodedIdToken, TokenSet, VerifyOptions} from './types.js'; import {UserRecord} from './user-record.js'; export * from './error.js'; export * from './types.js'; const getCustomTokenEndpoint = (apiKey: string) => { if (useEmulator() && process.env.FIREBASE_AUTH_EMULATOR_HOST) { let protocol = 'http://'; if ( (process.env.FIREBASE_AUTH_EMULATOR_HOST as string).startsWith('http://') ) { protocol = ''; } return `${protocol}${process.env .FIREBASE_AUTH_EMULATOR_HOST!}/identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`; } return `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`; }; const getSignUpEndpoint = (apiKey: string) => { return `https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=${apiKey}`; }; const getRefreshTokenEndpoint = (apiKey: string) => { if (useEmulator() && process.env.FIREBASE_AUTH_EMULATOR_HOST) { let protocol = 'http://'; if ( (process.env.FIREBASE_AUTH_EMULATOR_HOST as string).startsWith('http://') ) { protocol = ''; } return `${protocol}${process.env .FIREBASE_AUTH_EMULATOR_HOST!}/securetoken.googleapis.com/v1/token?key=${apiKey}`; } return `https://securetoken.googleapis.com/v1/token?key=${apiKey}`; }; interface CustomTokenToIdAndRefreshTokensOptions { tenantId?: string; appCheckToken?: string; referer?: string; } export async function customTokenToIdAndRefreshTokens( customToken: string, firebaseApiKey: string, options: CustomTokenToIdAndRefreshTokensOptions ): Promise { const headers: Record = { 'Content-Type': 'application/json', ...(options.referer ? {Referer: options.referer} : {}) }; const body: Record = { token: customToken, returnSecureToken: true }; if (options.appCheckToken) { headers['X-Firebase-AppCheck'] = options.appCheckToken; } if (options.tenantId) { body['tenantId'] = options.tenantId; } const refreshTokenResponse = await fetch( getCustomTokenEndpoint(firebaseApiKey), { method: 'POST', headers, body: JSON.stringify(body) } ); const refreshTokenJSON = (await refreshTokenResponse.json()) as DecodedIdToken; if (!refreshTokenResponse.ok) { throw new Error( `Problem getting a refresh token: ${JSON.stringify(refreshTokenJSON)}` ); } return { idToken: refreshTokenJSON.idToken as string, refreshToken: refreshTokenJSON.refreshToken as string }; } export async function createAnonymousAccount( firebaseApiKey: string, options: CreateAnonymousRequest = {} ): Promise { const headers: Record = { 'Content-Type': 'application/json', ...(options.referer ? {Referer: options.referer} : {}) }; const body: Record = { returnSecureToken: true }; if (options.appCheckToken) { headers['X-Firebase-AppCheck'] = options.appCheckToken; } if (options.tenantId) { body['tenantId'] = options.tenantId; } const createResponse = await fetch(getSignUpEndpoint(firebaseApiKey), { method: 'POST', headers, body: JSON.stringify(body) }); return (await createResponse.json()) as AnonymousTokens; } interface ErrorResponse { error: { code: number; message: 'USER_NOT_FOUND' | 'TOKEN_EXPIRED'; status: 'INVALID_ARGUMENT'; }; error_description?: string; } interface UserNotFoundResponse extends ErrorResponse { error: { code: 400; message: 'USER_NOT_FOUND'; status: 'INVALID_ARGUMENT'; }; } const isUserNotFoundResponse = ( data: unknown ): data is UserNotFoundResponse => { return ( (data as UserNotFoundResponse)?.error?.code === 400 && (data as UserNotFoundResponse)?.error?.message === 'USER_NOT_FOUND' ); }; export interface TokenRefreshOptions { apiKey: string; referer?: string; } const refreshExpiredIdToken = async ( refreshToken: string, options: TokenRefreshOptions ): Promise => { // https://firebase.google.com/docs/reference/rest/auth/#section-refresh-token const response = await fetch(getRefreshTokenEndpoint(options.apiKey), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(options.referer ? {Referer: options.referer} : {}) }, body: `grant_type=refresh_token&refresh_token=${refreshToken}` }); if (!response.ok) { const data = await response.json(); const errorMessage = `Error fetching access token: ${JSON.stringify( data.error )} ${data.error_description ? `(${data.error_description})` : ''}`; if (isUserNotFoundResponse(data)) { throw new AuthError(AuthErrorCode.USER_NOT_FOUND); } throw new AuthError(AuthErrorCode.INVALID_CREDENTIAL, errorMessage); } const data = await response.json(); return { idToken: data.id_token, refreshToken: data.refresh_token }; }; export function isUserNotFoundError(error: unknown): error is AuthError { return (error as AuthError)?.code === AuthErrorCode.USER_NOT_FOUND; } export function isInvalidCredentialError(error: unknown): error is AuthError { return (error as AuthError)?.code === AuthErrorCode.INVALID_CREDENTIAL; } async function handleVerifyTokenError( e: unknown, onExpired: (e: AuthError) => Promise, onError: (e: unknown) => Promise ) { try { return await onExpired(e as AuthError); } catch (e) { return onError(e); } } export async function handleExpiredToken( verifyIdToken: () => Promise, onExpired: (e: AuthError) => Promise, onError: (e: unknown) => Promise, shouldExpireOnNoMatchingKidError: boolean ): Promise { try { return await verifyIdToken(); } catch (e: unknown) { switch ((e as AuthError)?.code) { case AuthErrorCode.NO_MATCHING_KID: if (shouldExpireOnNoMatchingKidError) { return handleVerifyTokenError( e, async (e) => { const result = await onExpired(e); debug( 'experimental_refresh_on_expired_kid: Successfully refreshed token after kid has expired' ); return result; }, (e) => { debug( 'experimental_refresh_on_expired_kid: Error when trying to refresh token after kid has expired', {message: (e as Error)?.message, stack: (e as Error)?.stack} ); return onError(e); } ); } return onError(e); case AuthErrorCode.TOKEN_EXPIRED: return handleVerifyTokenError(e, onExpired, onError); default: return onError(e); } } } export interface IdAndRefreshTokens { idToken: string; refreshToken: string; } export interface CreateAnonymousRequest { tenantId?: string; appCheckToken?: string; referer?: string; } export interface AnonymousTokens { idToken: string; refreshToken: string; localId: string; } export interface UsersList { users: UserRecord[]; nextPageToken?: string; } export interface GetCustomIdAndRefreshTokensOptions { appCheckToken?: string; referer?: string; dynamicCustomClaimsKeys?: string[]; } interface AuthOptions { credential: Credential; apiKey: string; tenantId?: string; serviceAccountId?: string; enableCustomToken?: boolean; } export type Auth = ReturnType; const DEFAULT_VERIFY_OPTIONS = {referer: ''}; function getAuth(options: AuthOptions) { const credential = options.credential ?? getApplicationDefault(); const tenantId = options.tenantId; const authRequestHandler = new AuthRequestHandler(credential, { tenantId }); const tokenGenerator = createFirebaseTokenGenerator(credential, tenantId); const handleTokenRefresh = async ( refreshToken: string, tokenRefreshOptions: {referer?: string; enableCustomToken?: boolean} = {} ): Promise => { const {idToken, refreshToken: newRefreshToken} = await refreshExpiredIdToken(refreshToken, { apiKey: options.apiKey, referer: tokenRefreshOptions.referer }); const decodedIdToken = await verifyIdToken(idToken, { referer: tokenRefreshOptions.referer }); if (!tokenRefreshOptions.enableCustomToken) { return { decodedIdToken, idToken, refreshToken: newRefreshToken }; } const customToken = await createCustomToken(decodedIdToken.uid, { email_verified: decodedIdToken.email_verified, source_sign_in_provider: decodedIdToken.firebase.sign_in_provider }); return { decodedIdToken, idToken, refreshToken: newRefreshToken, customToken }; }; async function createSessionCookie( idToken: string, expiresInMs: number ): Promise { // Verify tenant ID before creating session cookie if (tenantId) { await verifyIdToken(idToken); } return authRequestHandler.createSessionCookie(idToken, expiresInMs); } async function getUser(uid: string): Promise { return authRequestHandler.getAccountInfoByUid(uid).then((response) => { return response.users?.length ? new UserRecord(response.users[0]) : null; }); } async function listUsers( nextPageToken?: string, maxResults?: number ): Promise { return authRequestHandler .listUsers(nextPageToken, maxResults) .then((response) => { const result: UsersList = { users: response.users.map((user) => new UserRecord(user)) }; if (response.nextPageToken) { result.nextPageToken = response.nextPageToken; } return result; }); } async function getUserByEmail(email: string): Promise { return authRequestHandler.getAccountInfoByEmail(email).then((response) => { if (!response.users || !response.users.length) { throw new AuthError(AuthErrorCode.USER_NOT_FOUND); } return new UserRecord(response.users[0]); }); } async function verifyDecodedJWTNotRevokedOrDisabled( decodedIdToken: DecodedIdToken ): Promise { return getUser(decodedIdToken.sub).then((user: UserRecord | null) => { if (!user) { throw new AuthError(AuthErrorCode.USER_NOT_FOUND); } if (user.disabled) { throw new AuthError(AuthErrorCode.USER_DISABLED); } if (user.tokensValidAfterTime) { const authTimeUtc = decodedIdToken.auth_time * 1000; const validSinceUtc = new Date(user.tokensValidAfterTime).getTime(); if (authTimeUtc < validSinceUtc) { throw new AuthError(AuthErrorCode.TOKEN_REVOKED); } } return decodedIdToken; }); } async function verifyIdToken( idToken: string, options: VerifyOptions = DEFAULT_VERIFY_OPTIONS ): Promise { const projectId = await credential.getProjectId(); const idTokenVerifier = createIdTokenVerifier(projectId, tenantId); const decodedIdToken = await idTokenVerifier.verifyJWT(idToken, options); const checkRevoked = options.checkRevoked ?? false; if (checkRevoked) { return verifyDecodedJWTNotRevokedOrDisabled(decodedIdToken); } return decodedIdToken; } async function verifyAndRefreshExpiredIdToken( parsedCookies: ParsedCookies, verifyOptions: VerifyOptions & { onTokenRefresh?: (tokens: TokenSet) => Promise; } = DEFAULT_VERIFY_OPTIONS ): Promise { return await handleExpiredToken( async () => { const decodedIdToken = await verifyIdToken( parsedCookies.idToken, verifyOptions ); return { idToken: parsedCookies.idToken, decodedIdToken, refreshToken: parsedCookies.refreshToken, customToken: parsedCookies.customToken }; }, async () => { if (parsedCookies.refreshToken) { const result = await handleTokenRefresh(parsedCookies.refreshToken, { referer: verifyOptions.referer, enableCustomToken: options.enableCustomToken }); await verifyOptions.onTokenRefresh?.(result); return result; } throw new InvalidTokenError(InvalidTokenReason.MISSING_REFRESH_TOKEN); }, async (e) => { if ( e instanceof AuthError && e.code === AuthErrorCode.NO_MATCHING_KID ) { throw InvalidTokenError.fromError(e, InvalidTokenReason.INVALID_KID); } throw InvalidTokenError.fromError( e, InvalidTokenReason.INVALID_CREDENTIALS ); }, verifyOptions.enableTokenRefreshOnExpiredKidHeader ?? false ); } function createCustomToken( uid: string, developerClaims?: {[key: string]: unknown} ): Promise { return tokenGenerator.createCustomToken(uid, developerClaims); } async function getCustomIdAndRefreshTokens( idToken: string, customTokensOptions: GetCustomIdAndRefreshTokensOptions = DEFAULT_VERIFY_OPTIONS ): Promise { const decodedToken = await verifyIdToken(idToken, { referer: customTokensOptions.referer }); const customClaims = filterStandardClaims(decodedToken); if (customTokensOptions.dynamicCustomClaimsKeys?.length) { customTokensOptions.dynamicCustomClaimsKeys.forEach((key) => { delete customClaims[key]; }); } const customToken = await createCustomToken(decodedToken.uid, { ...customClaims, email_verified: decodedToken.email_verified, source_sign_in_provider: decodedToken.firebase.sign_in_provider }); debug('Generated custom token based on provided idToken', {customToken}); const idAndRefreshTokens = await customTokenToIdAndRefreshTokens( customToken, options.apiKey, { tenantId: options.tenantId, appCheckToken: customTokensOptions.appCheckToken, referer: customTokensOptions.referer } ); return { ...idAndRefreshTokens, customToken }; } async function deleteUser(uid: string): Promise { await authRequestHandler.deleteAccount(uid); } async function setCustomUserClaims( uid: string, customUserClaims: object | null ) { await authRequestHandler.setCustomUserClaims(uid, customUserClaims); } async function createUser(properties: CreateRequest): Promise { return authRequestHandler .createNewAccount(properties) .then((uid) => getUser(uid)) .then((user) => { if (!user) { throw new AuthError( AuthErrorCode.INTERNAL_ERROR, 'Could not get recently created user from database. Most likely it was deleted.' ); } return user; }); } async function createAnonymousUser( firebaseApiKey: string ): Promise { return createAnonymousAccount(firebaseApiKey); } async function updateUser( uid: string, properties: UpdateRequest ): Promise { return authRequestHandler .updateExistingAccount(uid, properties) .then((existingUid) => getUser(existingUid)) .then((user) => { if (!user) { throw new AuthError( AuthErrorCode.INTERNAL_ERROR, 'Could not get recently updated user from database. Most likely it was deleted.' ); } return user; }); } return { verifyAndRefreshExpiredIdToken, verifyIdToken, createCustomToken, getCustomIdAndRefreshTokens, handleTokenRefresh, deleteUser, setCustomUserClaims, getUser, getUserByEmail, updateUser, createUser, createAnonymousUser, listUsers, createSessionCookie }; } function isFirebaseAuthOptions( options: FirebaseAuthOptions | ServiceAccount ): options is FirebaseAuthOptions { const serviceAccount = options as ServiceAccount; return ( !serviceAccount.privateKey || !serviceAccount.projectId || !serviceAccount.clientEmail ); } export interface FirebaseAuthOptions { serviceAccount?: ServiceAccount; apiKey: string; tenantId?: string; serviceAccountId?: string; enableCustomToken?: boolean; } export function getFirebaseAuth(options: FirebaseAuthOptions): Auth; /** @deprecated Use `FirebaseAuthOptions` configuration object instead */ export function getFirebaseAuth( serviceAccount: ServiceAccount, apiKey: string, tenantId?: string ): Auth; export function getFirebaseAuth( serviceAccount: ServiceAccount | FirebaseAuthOptions, apiKey?: string, tenantId?: string ): Auth { if (!isFirebaseAuthOptions(serviceAccount)) { const credential = new ServiceAccountCredential(serviceAccount); return getAuth({credential, apiKey: apiKey!, tenantId}); } const options = serviceAccount; return getAuth({ credential: options.serviceAccount ? new ServiceAccountCredential(options.serviceAccount) : getApplicationDefault(), apiKey: options.apiKey, tenantId: options.tenantId, serviceAccountId: options.serviceAccountId, enableCustomToken: options.enableCustomToken }); } ================================================ FILE: src/auth/jwt/consts.ts ================================================ export const ALGORITHMS = { RS256: { name: 'RSASSA-PKCS1-v1_5' as const, hash: 'SHA-256' as const } }; ================================================ FILE: src/auth/jwt/crypto-signer.ts ================================================ import {base64url, JWTPayload} from 'jose'; import {Credential, ServiceAccountCredential} from '../credential.js'; import {fetchText} from '../utils.js'; import {sign, signBlob} from './sign.js'; import {ALGORITHM_RS256} from './verify.js'; export interface CryptoSigner { sign(payload: JWTPayload): Promise; getAccountId(): Promise; } export function createEmulatorToken(payload: JWTPayload) { const header = { alg: 'none', typ: 'JWT' }; return `${base64url.encode(JSON.stringify(header))}.${base64url.encode(JSON.stringify(payload))}.${base64url.encode('')}`; } export class EmulatorSigner implements CryptoSigner { constructor(private readonly tenantId?: string) {} public async sign(payload: JWTPayload): Promise { if (this.tenantId) { payload.tenant_id = this.tenantId; } return createEmulatorToken(payload); } public getAccountId(): Promise { return Promise.resolve('firebase-auth-emulator@example.com'); } } export class ServiceAccountSigner implements CryptoSigner { constructor( private readonly credential: ServiceAccountCredential, private readonly tenantId?: string ) {} public async sign(payload: JWTPayload): Promise { if (this.tenantId) { payload.tenant_id = this.tenantId; } return sign({payload, privateKey: this.credential.privateKey}); } public getAccountId(): Promise { return Promise.resolve(this.credential.clientEmail); } } export class IAMSigner implements CryptoSigner { algorithm = ALGORITHM_RS256; private credential: Credential; private tenantId?: string; private serviceAccountId?: string; constructor( credential: Credential, tenantId?: string, serviceAccountId?: string ) { this.credential = credential; this.tenantId = tenantId; this.serviceAccountId = serviceAccountId; } public async sign(payload: JWTPayload): Promise { if (this.tenantId) { payload.tenant_id = this.tenantId; } const serviceAccount = await this.getAccountId(); const accessToken = await this.credential.getAccessToken(); return signBlob({ accessToken: accessToken.accessToken, serviceAccountId: serviceAccount, payload }); } public async getAccountId(): Promise { if (this.serviceAccountId) { return this.serviceAccountId; } const token = await this.credential.getAccessToken(); const url = 'http://metadata/computeMetadata/v1/instance/service-accounts/default/email'; const request: RequestInit = { method: 'GET', headers: { 'Metadata-Flavor': 'Google', Authorization: `Bearer ${token.accessToken}` } }; return (this.serviceAccountId = await fetchText(url, request)); } } ================================================ FILE: src/auth/jwt/sign.test.ts ================================================ import {sign} from './sign.js'; type GlobalAny = { [key: string]: unknown; crypto: { subtle: { importKey: jest.Mock; sign: jest.Mock; }; }; }; const PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\n' + 'MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDFL3Pc33GkWha1\n' + 'WSMRw5e3spUCQAwpGnsRBMcG6JH+eHeHOJYI0odFPqCujVQTyTOEZwKZUtzRvMWP\n' + 'h+kgIdDw+1+hzsJJTDijHHNi/G/g2kPHpXXVaEdOKnwvOddhC3L79W2vxhcx2e64\n' + 'LwhbdP880GKHTiCzx8CjkpRExyzN935wHQ90IGaN/mGOQcBE/3j8u6oDqRbxt+IG\n' + 'xypQTRZR7TFw/Z9OYNt0pr0BI0jNMMDkmAxkUNH6Qw4eurQ7XXATS3cWnR4qiAIp\n' + 'S2HIxGiCr7PpVJJSWtTVqMgDF6y2xXtiw9H8Gdo36rTytUpyFH0gQeL923+sy9ru\n' + 'y4aWqL19AgMBAAECggEAXBVN6S6btmGvyx6GRwxtNIb8GSHpy+Qm5oqxmyNO0mRV\n' + 'hVtCjXorW4Xkqb8sLVU/bqxgRVOx9WxPYjjZAH1qQq9ROJICnxIuPNXTeL1kTb//\n' + '+SLmxTM+YV1rwu4jC5m6J7m0cGp0eH5Kgc7M+1DGxRKXgJJWqT42Uuznurq8zK3e\n' + 'iEmbqLQrDQuhjAcVsy/1DuQWkUy/TbIQ/9YxgsJfAy9T4QMh8KNngpud/gA8PWEM\n' + 'BkWJlk2e5hdHB9RJdZeBmzOLaKmVQ6oIwi+V7p9udH4lWAiGqBy9ziexTVFDk8Xi\n' + 'dY/zHKEFyQc+7rNhVDsKJvJaZNAZk44hLORcmfcbDQKBgQDknvpqIi3jgcyOy9G4\n' + 'lO2dRIIQY75fZKH1A3etU5k3irL2JlBYIqs5+YKUpAf3gueKF/jY5p/7duxYFvWn\n' + 'fj8UyHWVAweShcrp5hD51DXcWssy8i9UgzqjI/mg0sdVksUbwLllSPJ3sHv1QxY3\n' + 'ylq8ng0o01BdjzKngUiE6RJq0wKBgQDczLnam9EjlqhC/8ud5/tfPyD4go3n/LEo\n' + '1E7VN8IM2rshWZ/YRAw4aZ/mM9mx42pI1LBmaG0VJmGEZMYIzM114KuNiqnQSkOe\n' + 'rj4YCahkwkyHIxPjLDzX/hMIEFS/23nihOuC+FQOluHBLwmUZOOUk94s5O/oQp9x\n' + 'c5JVtwZkbwKBgEuoMMaeuQDpG4DGAolK/7djzIcP+xgmfVJP63L4j2PKCp9a3ovM\n' + 'LU3qPERkZB6Mu4L/m+Jrr9XP7TbZokHjjYybKg4+Cmt6y0PMVyHWEFzzzvr1GqSl\n' + 'KOqEJUALgNvYzlH43WGfWl4xkVQA94FO/egdhc1U4OuVT/YO2qjhWK7xAoGBANyF\n' + 'I8HwCUqP93Ei5IvK20XfWOCaE3x05cMvd6R/0bDg7DB8wKZQIBxfcbGKa4u847Pl\n' + 'qGA/P2L2OELwGtFDKpjmULBGox9CbJKY169ORf6MB76YDA7BaesW+I7/MIWFgA/6\n' + 'TPU7a0g+7S3x+pFYyerkW+teozTHBVNb5/TvnNTFAoGBAJSkdCSHsKUWDeTDDs8W\n' + 'xJc+0rgIjrbsYfu/jFoe4gWomr4b76PVPm7A5PvDkwt91amTPO3QpFj8kTMz4mKP\n' + 'RXOPfGOI0RVxREB6uKYC1H2eMOFformJXm27wxxMhdhAgbK3dye7Bn7Owrj5Ek67\n' + '4SD8Q27MvEowXdJvH32RE8ap\n' + '-----END PRIVATE KEY-----'; describe('sign', () => { const globalOriginal = {...global} as unknown as GlobalAny; let globalAny = global as unknown as GlobalAny; beforeAll(() => { globalAny.crypto = { subtle: { importKey: jest.fn(), sign: jest.fn() } }; }); afterAll(() => { globalAny = globalOriginal; }); beforeEach(() => { const cryptoKey = { algorithm: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, extractable: false, type: 'JWT', usages: ['sign'] }; const signature = new Uint8Array( 'secret'.split('').map((character) => character.charCodeAt(0)) ).buffer; globalAny.crypto.subtle.importKey.mockReturnValue(cryptoKey); globalAny.crypto.subtle.sign.mockReturnValue(signature); }); it('provides expected JWT with privateKey', async () => { expect.assertions(1); const payload = {exp: 946688400}; expect(await sign({payload, privateKey: PRIVATE_KEY})).toBe( 'eyJhbGciOiJSUzI1NiJ9.eyJleHAiOjk0NjY4ODQwMH0.qZUNyJG5IBztEQuTxAIHkLcGxI8on81rsw0MQt6gC3hHebWZx-icF05M-PwbHjJkGIGxvlzwOHdpzV1xiJN32BQtZDPa3SMx7DeMYrNS3h1gV_hAz0ylnrja-zBIGWb_Q1MZU_jMmrvYCk8wd2qU4SqbnC3LNVPxoxVsIMUMdTtA2fZ5Wk99LkYnPn-UMuR0vSMoJ2foCe2Imhwmjrfa47xxUIK0126GdX3qmY2Ico9KgfOQJz1ksJOrd1yCSVEK9QLRLGC4PAruyW_EWln1s6Bzger1v4MPjFmZULMoLGzW3R31rjdF51FFCswHf2miTP2VkJW3i_ng5XdQS-LUKA' ); }); it('adds keyId to ecnoded JWT header', async () => { expect.assertions(1); const payload = {exp: 946688400}; const keyId = 'key123'; expect(await sign({payload, privateKey: PRIVATE_KEY, keyId})).toBe( 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleTEyMyJ9.eyJleHAiOjk0NjY4ODQwMH0.t60FJH4NlQysOhzsvHYwRI5eOQqcwgyUqdkOtwV9US4cl04J3ynb9Rfme1bnq29HVqzpHpTHg8qxOFcCPbBr-kwbpg9dRVXHteTtE-LH9L-9TP9eQ8KeNwx6toNyESdGPsNPduMKSSgXMoITpfN1QGpgiREXCPo7atRmL7Jmzt1-9vocwlLCqx5gx_X9x8uPwzHZPu66Q28rzj_Kib9bBjbRFObA0OYMi2raI5DrbvEj2eZgYP4QhxOmfKkYGskW5Ne0GOy4YIPNMsFnw_rH3UhM_fqb7vwIe0f3zB9vK2WknxkNkNvwCrza_R-o98PG-qmvVWsTgnrmCoFfL40rLA' ); }); }); ================================================ FILE: src/auth/jwt/sign.ts ================================================ import {JWTPayload, KeyLike, SignJWT, base64url, importPKCS8} from 'jose'; import {AuthError, AuthErrorCode} from '../error.js'; import {fetchAny} from '../utils.js'; import {ALGORITHM_RS256} from './verify.js'; export type SignOptions = { readonly payload: JWTPayload; readonly privateKey: string; readonly keyId?: string; }; export async function sign({ payload, privateKey, keyId }: SignOptions): Promise { let key: KeyLike; try { key = await importPKCS8(privateKey, ALGORITHM_RS256); } catch (e) { throw AuthError.fromError( e, AuthErrorCode.INVALID_ARGUMENT, 'It looks like the value provided for `serviceAccount.privateKey` is incorrectly formatted. Please double-check if private key has correct format. See https://github.com/awinogrodzki/next-firebase-auth-edge/issues/246#issuecomment-2321559620 for details' ); } return new SignJWT(payload) .setProtectedHeader({alg: ALGORITHM_RS256, kid: keyId}) .sign(key); } export type SignBlobOptions = { readonly serviceAccountId: string; readonly accessToken: string; readonly payload: JWTPayload; }; function formatBase64(value: string) { return value.replace(/\//g, '_').replace(/\+/g, '-').replace(/=+$/, ''); } function encodeSegment(segment: Record | JWTPayload): string { const value = JSON.stringify(segment); return formatBase64(base64url.encode(value)); } export async function signBlob({ payload, serviceAccountId, accessToken }: SignBlobOptions): Promise { const url = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccountId}:signBlob`; const header = { alg: ALGORITHM_RS256, typ: 'JWT' }; const token = `${encodeSegment(header)}.${encodeSegment(payload)}`; const request: RequestInit = { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, body: JSON.stringify({payload: base64url.encode(token)}) }; const response = await fetchAny(url, request); const blob = await response.blob(); const key = await blob.text(); const {signedBlob} = JSON.parse(key); return `${token}.${formatBase64(signedBlob)}`; } ================================================ FILE: src/auth/jwt/verify.test.ts ================================================ import {sign} from './sign.js'; import {getPublicCryptoKey, verify} from './verify.js'; describe('verify', () => { it('verifies jwt', async () => { const payload = {exp: 9123812123123}; const privateKey = `-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDHDTErwJZxwJQH q+Z6t6qwxuyciqfJauCDD6IUf619noIRQZ4GZCUqFkxX8mOPYnEhLApLQdbIlgWq EEFJLYBP/UH5ojaOMsmO28K+GY/M7UHvZnxweFv48xjED4R2gRcGAcS+LiuUBRke NAKcXs41HET50wwClHkVX8OQeOK5R7q6hwwA3+8D4o/XpPYp13rROHrWDHhYNEIP 4s7n8klmP/wSLhYmzFoGBSzOxC4N0mH0p47vTTWHoZjyvbTDzjn4n5p6DMV7ggps o5+1uUCAu8c5uVUiaj5MnBDtXG+CvRgbkT7RCVMuHdHZx1DYzIOtPvp0JMww201Q JnhZs15pAgMBAAECggEABLRmHh+eLrAbj5bbirj+mtEI1KZeUt9o0RA0h4GBC0AM 2PWRE5uYWUdPpKCBA+mSvPL6h07WEcWh+qQJtv4RU1KsFYdk/LVsmCjPkIiwImrV LSBh/pKJsfek9TVcryRb8/NkwA39T7FTJ6iZCzMecpjpdHItjX4O4pdx2t9QlIp3 vV6Ob7u+NxgnLCOVP0HvxghTwaX5rWaHbt1TcUmK8069TPPj/AItN6a1S30CCgxU yVDdfMOThruJUTcUB0mOTux7ZV+JVEA9oUsrChN1LN30uco2d9n77p2U+R/7/CjV yYOQWT6Nn4m8EmKxmPbn7NfpiHE4gOAmp7r8ZvsndQKBgQD8kV+l63q8jBGQ1maV X+ZeUcdROq9TV6lRbtRvSgsyXEUMCp3MJMut89XD1T3BkxVEIyYgyAUZ30QnQF/q HLnIfBmrnQoUp2b1ZOi/G3emOjNMycYYaeR0Y0MHiiOGxVY20KZe/jsD/knqcYB9 jqv2dPTVL/UHVtB/QFXcPf7wQwKBgQDJwaWyDxXghaNWjHCOQ4x1QHVUQAPYlhN6 kBLI9NYHMq/4glN7G+xOjs87qKi2WE1B57WFrsp02e9eEigKDhxHGTtYKbS7xXfR LpnvGxVE8ZBQK7Rkiiw/0E6xWSXimeYlKPrDQ4jrL0XWh7dgJ4DBfGgpgTYO4DbX /1jvFAKx4wKBgQCYzNaCCfnCUjdaWevMGS3FCFK+uPNTR6ifJJ8PCUvG1v3K8C1R UT2MawV7qennz7VA+MbbdEdpxKJ14MNmXqSjPzlEkwiDQFfQxJDu9Y4omfNpVHUt Vfsp0te9mvwtT/v9w7OzqrlHjDNpy+tBiuxMeauZwp7KJuKS6fhH+5XeAwKBgBVY rcVXH0NwIEYJ+eazcur89O0DEOUbi9gN4k7syLBeRowOjfKak7gEGB0BzUfts87j SytnwPf4DwFu/lmCAK/tFYBQeVTcob66JYNM5EU1IcW5ug5hKClgStMs0XtWOSl5 Wn7KaHQpvkPifB5qT48pMIQjraqJQoQ7+hbhkR9tAoGBAKrNCHGq3edhgnVjHJbj j3rJk3aIe7UffHoNDkq/xE32W1P4ra3t81ItdLRXEJU/XU6ZmbvEpjJUMfXiIZHt wmK2NLjp4+wipPmSidEwyabBAk4Epb0qIG+MM2RvMUOC8kkV2p5lNFkZYR346wtI AIqW5jTJYZYfCnGuCyV0F0C0 -----END PRIVATE KEY-----`; const publicKey = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxw0xK8CWccCUB6vmereq sMbsnIqnyWrggw+iFH+tfZ6CEUGeBmQlKhZMV/Jjj2JxISwKS0HWyJYFqhBBSS2A T/1B+aI2jjLJjtvCvhmPzO1B72Z8cHhb+PMYxA+EdoEXBgHEvi4rlAUZHjQCnF7O NRxE+dMMApR5FV/DkHjiuUe6uocMAN/vA+KP16T2Kdd60Th61gx4WDRCD+LO5/JJ Zj/8Ei4WJsxaBgUszsQuDdJh9KeO7001h6GY8r20w845+J+aegzFe4IKbKOftblA gLvHOblVImo+TJwQ7Vxvgr0YG5E+0QlTLh3R2cdQ2MyDrT76dCTMMNtNUCZ4WbNe aQIDAQAB -----END PUBLIC KEY----- `; const jwt = await sign({ payload, privateKey }); await verify(jwt, async () => getPublicCryptoKey(publicKey), { referer: 'http://localhost:3000' }); }); }); ================================================ FILE: src/auth/jwt/verify.ts ================================================ import { decodeJwt, errors, importSPKI, importX509, jwtVerify, KeyLike } from 'jose'; import {useEmulator} from '../firebase.js'; import {DecodedIdToken, VerifyOptions} from '../types.js'; export const ALGORITHM_RS256 = 'RS256' as const; const keyMap: Map = new Map(); async function importPublicCryptoKey(publicKey: string) { if (publicKey.startsWith('-----BEGIN CERTIFICATE-----')) { return importX509(publicKey, ALGORITHM_RS256); } return importSPKI(publicKey, ALGORITHM_RS256); } export async function getPublicCryptoKey(publicKey: string): Promise { const cachedKey = keyMap.get(publicKey); if (cachedKey) { return cachedKey; } const key = await importPublicCryptoKey(publicKey); keyMap.set(publicKey, key); return key; } export async function verify( jwtString: string, getPublicKey: () => Promise, options: VerifyOptions ) { const currentDate = options.currentDate ?? new Date(); const currentTimestamp = currentDate.getTime() / 1000; const payload = decodeJwt(jwtString); if (!useEmulator()) { const {payload} = await jwtVerify(jwtString, await getPublicKey(), { currentDate }); return payload as DecodedIdToken; } if (typeof payload.nbf !== 'undefined') { if (typeof payload.nbf !== 'number') { throw new errors.JWTInvalid('invalid nbf value'); } if (payload.nbf > currentTimestamp) { throw new errors.JWTExpired( 'jwt not active: ' + new Date(payload.nbf * 1000).toISOString(), payload ); } } if (typeof payload.exp !== 'undefined') { if (typeof payload.exp !== 'number') { throw new errors.JWTInvalid('invalid exp value'); } if (currentTimestamp >= payload.exp) { throw new errors.JWTExpired( 'token expired: ' + new Date(payload.exp * 1000).toISOString(), payload ); } } return payload as DecodedIdToken; } ================================================ FILE: src/auth/rotating-credential.test.ts ================================================ import {errors} from 'jose'; import {CustomJWTPayload, ParsedCookies} from './custom-token/index.js'; import {RotatingCredential} from './rotating-credential.js'; type MockMetadata = {foo: 'bar'}; describe('rotating-credential', () => { const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6Ik1PQ0tfSURfVE9LRU4iLCJyZWZyZXNoX3Rva2VuIjoiTU9DS19SRUZSRVNIX1RPS0VOIiwiY3VzdG9tX3Rva2VuIjoiTU9DS19DVVNUT01fVE9LRU4iLCJtZXRhZGF0YSI6eyJmb28iOiJiYXIifX0.F54FoDyst6RipYvP9pma6ID7rRAcho_4Pl3Sp6Fr2I4'; const signature = 'OjicIJZDY8ZipDpsHnwET1M3F1n7oRwd87SMgH_77Kk'; const payload: CustomJWTPayload = { id_token: 'MOCK_ID_TOKEN', refresh_token: 'MOCK_REFRESH_TOKEN', custom_token: 'MOCK_CUSTOM_TOKEN', metadata: {foo: 'bar'} }; const customTokens: ParsedCookies = { idToken: 'MOCK_ID_TOKEN', refreshToken: 'MOCK_REFRESH_TOKEN', customToken: 'MOCK_CUSTOM_TOKEN', metadata: {foo: 'bar'} }; it('should sign custom jwt payload', async () => { const credential = new RotatingCredential(['key1', 'key2']); const customJWT = await credential.sign(payload); expect(customJWT).toEqual(jwt); }); it('should create signature', async () => { const credential = new RotatingCredential(['key1', 'key2']); const customSignature = await credential.createSignature(customTokens); expect(customSignature).toEqual(signature); }); it('should verify signature', async () => { const credential = new RotatingCredential(['key1', 'key2']); return expect(() => credential.verifySignature(customTokens, signature)) .resolves; }); it('should verify signature with rotated keys', async () => { const credential = new RotatingCredential(['key3', 'key1']); await credential.verifySignature(customTokens, signature); }); it('should throw invalid signature error if no keys match signature', async () => { const credential = new RotatingCredential(['key3']); return expect(() => credential.verifySignature(customTokens, signature) ).rejects.toBeInstanceOf(errors.JWSSignatureVerificationFailed); }); it('should verify custom jwt payload', async () => { const credential = new RotatingCredential(['key1', 'key2']); const result = await credential.verify(jwt); expect(result).toEqual(payload); }); it('should verify custom jwt payload with rotated key', async () => { const credential = new RotatingCredential(['key0', 'key1']); const result = await credential.verify(jwt); expect(result).toEqual(payload); }); it('should throw invalid signature error if no key matches signature', async () => { const credential = new RotatingCredential(['key3', 'key4']); return expect(() => credential.verify(jwt)).rejects.toBeInstanceOf( errors.JWSSignatureVerificationFailed ); }); }); ================================================ FILE: src/auth/rotating-credential.ts ================================================ import {errors} from 'jose'; import { CustomJWTPayload, ParsedCookies, createCustomJWT, createCustomSignature, verifyCustomJWT, verifyCustomSignature } from './custom-token/index.js'; export class RotatingCredential { constructor(private keys: string[]) {} public async sign(payload: CustomJWTPayload) { return createCustomJWT(payload, this.keys[0]); } public async createSignature( value: ParsedCookies ): Promise { return createCustomSignature(value, this.keys[0]); } public async verify(customJWT: string): Promise> { for (const key of this.keys) { try { const result = await verifyCustomJWT(customJWT, key); return result.payload; } catch (e) { if ( e instanceof errors.JWSSignatureVerificationFailed || e instanceof errors.JWSInvalid ) { continue; } throw e; } } throw new errors.JWSSignatureVerificationFailed( 'Custom JWT could not be verified against any of the provided keys' ); } public async verifySignature( value: ParsedCookies, signature: string ): Promise { for (const key of this.keys) { try { return await verifyCustomSignature(value, signature, key); } catch (e) { if ( e instanceof errors.JWSSignatureVerificationFailed || e instanceof errors.JWSInvalid ) { continue; } throw e; } } throw new errors.JWSSignatureVerificationFailed( 'Custom tokens signature could not be verified against any of the provided keys' ); } } ================================================ FILE: src/auth/signature-verifier.test.ts ================================================ import {errors} from 'jose'; import {AuthError, AuthErrorCode} from './error.js'; import {sign} from './jwt/sign.js'; import {KeyFetcher, PublicKeySignatureVerifier} from './signature-verifier.js'; const privateKey = `-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDHDTErwJZxwJQH q+Z6t6qwxuyciqfJauCDD6IUf619noIRQZ4GZCUqFkxX8mOPYnEhLApLQdbIlgWq EEFJLYBP/UH5ojaOMsmO28K+GY/M7UHvZnxweFv48xjED4R2gRcGAcS+LiuUBRke NAKcXs41HET50wwClHkVX8OQeOK5R7q6hwwA3+8D4o/XpPYp13rROHrWDHhYNEIP 4s7n8klmP/wSLhYmzFoGBSzOxC4N0mH0p47vTTWHoZjyvbTDzjn4n5p6DMV7ggps o5+1uUCAu8c5uVUiaj5MnBDtXG+CvRgbkT7RCVMuHdHZx1DYzIOtPvp0JMww201Q JnhZs15pAgMBAAECggEABLRmHh+eLrAbj5bbirj+mtEI1KZeUt9o0RA0h4GBC0AM 2PWRE5uYWUdPpKCBA+mSvPL6h07WEcWh+qQJtv4RU1KsFYdk/LVsmCjPkIiwImrV LSBh/pKJsfek9TVcryRb8/NkwA39T7FTJ6iZCzMecpjpdHItjX4O4pdx2t9QlIp3 vV6Ob7u+NxgnLCOVP0HvxghTwaX5rWaHbt1TcUmK8069TPPj/AItN6a1S30CCgxU yVDdfMOThruJUTcUB0mOTux7ZV+JVEA9oUsrChN1LN30uco2d9n77p2U+R/7/CjV yYOQWT6Nn4m8EmKxmPbn7NfpiHE4gOAmp7r8ZvsndQKBgQD8kV+l63q8jBGQ1maV X+ZeUcdROq9TV6lRbtRvSgsyXEUMCp3MJMut89XD1T3BkxVEIyYgyAUZ30QnQF/q HLnIfBmrnQoUp2b1ZOi/G3emOjNMycYYaeR0Y0MHiiOGxVY20KZe/jsD/knqcYB9 jqv2dPTVL/UHVtB/QFXcPf7wQwKBgQDJwaWyDxXghaNWjHCOQ4x1QHVUQAPYlhN6 kBLI9NYHMq/4glN7G+xOjs87qKi2WE1B57WFrsp02e9eEigKDhxHGTtYKbS7xXfR LpnvGxVE8ZBQK7Rkiiw/0E6xWSXimeYlKPrDQ4jrL0XWh7dgJ4DBfGgpgTYO4DbX /1jvFAKx4wKBgQCYzNaCCfnCUjdaWevMGS3FCFK+uPNTR6ifJJ8PCUvG1v3K8C1R UT2MawV7qennz7VA+MbbdEdpxKJ14MNmXqSjPzlEkwiDQFfQxJDu9Y4omfNpVHUt Vfsp0te9mvwtT/v9w7OzqrlHjDNpy+tBiuxMeauZwp7KJuKS6fhH+5XeAwKBgBVY rcVXH0NwIEYJ+eazcur89O0DEOUbi9gN4k7syLBeRowOjfKak7gEGB0BzUfts87j SytnwPf4DwFu/lmCAK/tFYBQeVTcob66JYNM5EU1IcW5ug5hKClgStMs0XtWOSl5 Wn7KaHQpvkPifB5qT48pMIQjraqJQoQ7+hbhkR9tAoGBAKrNCHGq3edhgnVjHJbj j3rJk3aIe7UffHoNDkq/xE32W1P4ra3t81ItdLRXEJU/XU6ZmbvEpjJUMfXiIZHt wmK2NLjp4+wipPmSidEwyabBAk4Epb0qIG+MM2RvMUOC8kkV2p5lNFkZYR346wtI AIqW5jTJYZYfCnGuCyV0F0C0 -----END PRIVATE KEY-----`; const publicKey = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxw0xK8CWccCUB6vmereq sMbsnIqnyWrggw+iFH+tfZ6CEUGeBmQlKhZMV/Jjj2JxISwKS0HWyJYFqhBBSS2A T/1B+aI2jjLJjtvCvhmPzO1B72Z8cHhb+PMYxA+EdoEXBgHEvi4rlAUZHjQCnF7O NRxE+dMMApR5FV/DkHjiuUe6uocMAN/vA+KP16T2Kdd60Th61gx4WDRCD+LO5/JJ Zj/8Ei4WJsxaBgUszsQuDdJh9KeO7001h6GY8r20w845+J+aegzFe4IKbKOftblA gLvHOblVImo+TJwQ7Vxvgr0YG5E+0QlTLh3R2cdQ2MyDrT76dCTMMNtNUCZ4WbNe aQIDAQAB -----END PUBLIC KEY----- `; const options = { referer: 'http://localhost:3000' }; describe('signature verifier', () => { it('verifies jwt with public key', async () => { const mockKeyId = 'some-key-id'; const mockFetcher = { fetchPublicKeys: jest.fn(() => Promise.resolve({ [mockKeyId]: publicKey }) ) } as KeyFetcher; const payload = {exp: Date.now() / 1000 + 1}; const token = await sign({payload, privateKey, keyId: mockKeyId}); const signatureVerifier = new PublicKeySignatureVerifier(mockFetcher); await signatureVerifier.verify(token, options); }); it('throws token expired error if token is expired', async () => { const mockKeyId = 'some-key-id'; const mockFetcher = { fetchPublicKeys: jest.fn(() => Promise.resolve({ [mockKeyId]: publicKey }) ) } as KeyFetcher; const payload = {exp: Date.now() / 1000 - 1}; const token = await sign({payload, privateKey, keyId: mockKeyId}); const signatureVerifier = new PublicKeySignatureVerifier(mockFetcher); return expect(() => signatureVerifier.verify(token, options) ).rejects.toBeInstanceOf(errors.JWTExpired); }); it('throws no matching kid error when non of the public keys corresponds to kid', async () => { const mockKeyId = 'some-key-id'; const mockFetcher = { fetchPublicKeys: jest.fn(() => Promise.resolve({ 'some-test-key': '', 'any-public-key': publicKey }) ) } as KeyFetcher; const payload = {exp: Date.now() / 1000 + 1}; const token = await sign({payload, privateKey, keyId: mockKeyId}); const signatureVerifier = new PublicKeySignatureVerifier(mockFetcher); return expect(() => signatureVerifier.verify(token, options) ).rejects.toEqual(new AuthError(AuthErrorCode.NO_MATCHING_KID)); }); it('throws expired error if one of certificates matches, but token is expired', async () => { const mockFetcher = { fetchPublicKeys: jest.fn(() => Promise.resolve({ 'some-test-key': '', 'any-public-key': publicKey }) ) } as KeyFetcher; const payload = {exp: Date.now() / 1000 - 1}; const token = await sign({payload, privateKey, keyId: ''}); const signatureVerifier = new PublicKeySignatureVerifier(mockFetcher); return expect(() => signatureVerifier.verify(token, options) ).rejects.toBeInstanceOf(errors.JWTExpired); }); it('validates token against all public keys if key id is missing', async () => { const mockFetcher = { fetchPublicKeys: jest.fn(() => Promise.resolve({ 'any-public-key': publicKey }) ) } as KeyFetcher; const payload = {exp: Date.now() / 1000 + 1}; const token = await sign({payload, privateKey, keyId: ''}); const signatureVerifier = new PublicKeySignatureVerifier(mockFetcher); await signatureVerifier.verify(token, options); }); it('throws invalid signature error if none of existing keys is valid against token', async () => { const mockFetcher = { fetchPublicKeys: jest.fn(() => Promise.resolve({ 'any-kid': '-----BEGIN PUBLIC KEY-----\n' + 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAn3JOtipuElI0FxM9a7Ni\n' + 'IjGBPtZBa8RJofUHJNoGHRS+cN0NU+XUDvwBBozB2jDl6XRg1+fIVX3WiIokFi3O\n' + 'MI0iUc6Ht++lEC2IhSpQ3F7IxeZYlpvTLA+Df5y2SCcK1haa5mxhzCYxbE3Iyu7q\n' + 'Ms4wf7AgNY/zYz9wXlhI6ZomuahkLm4nu1yYnKZOxATsCWBeHx9o+skQbYOQ5fn5\n' + 'e34EVa2fE592Jg4iTXobVSAF1KZIsJerP9P7tkZzrQm6qPz0qV1c7H/1kLN9k3if\n' + 'EXeCUZP7tL38XtlP5iB6F49f7jmc0WgL7wOuUqyrQbkRiOxOaXP2ibAa+TPgPxP3\n' + '1wIDAQAB\n' + '-----END PUBLIC KEY-----' }) ) } as KeyFetcher; const payload = {exp: Date.now() / 1000 - 1}; const token = await sign({payload, privateKey, keyId: ''}); const signatureVerifier = new PublicKeySignatureVerifier(mockFetcher); return expect(() => signatureVerifier.verify(token, options) ).rejects.toEqual(new AuthError(AuthErrorCode.INVALID_SIGNATURE)); }); }); ================================================ FILE: src/auth/signature-verifier.ts ================================================ import { JWTPayload, KeyLike, ProtectedHeaderParameters, createRemoteJWKSet, cryptoRuntime, decodeProtectedHeader, errors } from 'jose'; import {RemoteJWKSetOptions} from 'jose/dist/types/jwks/remote'; import {debug} from '../debug/index.js'; import {AuthError, AuthErrorCode} from './error.js'; import {useEmulator} from './firebase.js'; import {getPublicCryptoKey, verify} from './jwt/verify.js'; import {VerifyOptions} from './types.js'; import {isNonNullObject, isURL} from './validator.js'; export type PublicKeys = {[key: string]: string}; interface PublicKeysResponse { keys: PublicKeys; expiresAt: number; } export type DecodedToken = { header: ProtectedHeaderParameters; payload: JWTPayload; }; export interface SignatureVerifier { verify(token: string, options: VerifyOptions): Promise; } export interface KeyFetcher { fetchPublicKeys(): Promise; } function getExpiresAt(res: Response) { if (!res.headers.has('cache-control')) { return 0; } const cacheControlHeader: string = res.headers.get('cache-control')!; const parts = cacheControlHeader.split(','); const maxAge = parts.reduce((acc, part) => { const subParts = part.trim().split('='); if (subParts[0] === 'max-age') { return +subParts[1]; } return acc; }, 0); return Date.now() + maxAge * 1000; } const keyResponseCache: Map = new Map(); export class UrlKeyFetcher implements KeyFetcher { constructor(private clientCertUrl: string) { if (!isURL(clientCertUrl)) { throw new Error( 'The provided public client certificate URL is not a valid URL.' ); } } private async fetchPublicKeysResponse(url: URL): Promise { const res = await fetch(url); const headers: Record = {}; res.headers.forEach((value, key) => { headers[key] = value; }); debug('Public keys fetched', { responseHeaders: headers, cryptoRuntime }); if (!res.ok) { let errorMessage = 'Error fetching public keys for Google certs: '; const data = await res.json(); if (data.error) { errorMessage += `${data.error}`; if (data.error_description) { errorMessage += ' (' + data.error_description + ')'; } } else { errorMessage += `${await res.text()}`; } throw new Error(errorMessage); } const data = await res.json(); if (data.error) { throw new Error( 'Error fetching public keys for Google certs: ' + data.error ); } const expiresAt = getExpiresAt(res); return { keys: data, expiresAt }; } private async fetchAndCachePublicKeys(url: URL): Promise { debug( 'No public keys found in cache. Fetching public keys from Google...', { cryptoRuntime } ); const response = await this.fetchPublicKeysResponse(url); keyResponseCache.set(url.toString(), response); debug('Public keys cached', { cacheKey: url.toString(), expiresAt: response.expiresAt, cryptoRuntime }); return response.keys; } public async fetchPublicKeys(): Promise { const url = new URL(this.clientCertUrl); const cachedResponse = keyResponseCache.get(url.toString()); if (!cachedResponse) { return this.fetchAndCachePublicKeys(url); } const {keys, expiresAt} = cachedResponse; const now = Date.now(); debug('Get public keys from cache', { expiresAt, now, cryptoRuntime }); if (expiresAt <= now) { return this.fetchAndCachePublicKeys(url); } return keys; } } export class JWKSSignatureVerifier implements SignatureVerifier { private jwksUrl: URL; constructor( jwksUrl: string, private options?: RemoteJWKSetOptions ) { if (!isURL(jwksUrl)) { throw new Error('The provided JWKS URL is not a valid URL.'); } this.jwksUrl = new URL(jwksUrl); } private async getPublicKey( header: ProtectedHeaderParameters ): Promise { const getKey = createRemoteJWKSet(this.jwksUrl, this.options); return getKey(header); } public async verify(token: string, options: VerifyOptions): Promise { const header = decodeProtectedHeader(token); try { await verify(token, () => this.getPublicKey(header), options); } catch (e) { if (e instanceof errors.JWKSMultipleMatchingKeys) { for await (const publicKey of e) { try { await verify(token, () => Promise.resolve(publicKey), options); return; } catch (innerError) { if (innerError instanceof errors.JWSSignatureVerificationFailed) { continue; } throw innerError; } } throw new errors.JWSSignatureVerificationFailed(); } throw e; } } } export class PublicKeySignatureVerifier implements SignatureVerifier { constructor(private keyFetcher: KeyFetcher) { if (!isNonNullObject(keyFetcher)) { throw new Error('The provided key fetcher is not an object or null.'); } } public static withCertificateUrl( clientCertUrl: string ): PublicKeySignatureVerifier { return new PublicKeySignatureVerifier(new UrlKeyFetcher(clientCertUrl)); } private async getPublicKey( header: ProtectedHeaderParameters ): Promise { if (useEmulator()) { return {type: 'none'}; } return fetchPublicKey(this.keyFetcher, header).then(getPublicCryptoKey); } public async verify(token: string, options: VerifyOptions): Promise { const header = decodeProtectedHeader(token); try { await verify(token, () => this.getPublicKey(header), options); } catch (e) { if (e instanceof AuthError && e.code === AuthErrorCode.NO_KID_IN_HEADER) { await this.verifyWithoutKid(token, options); return; } throw e; } } private async verifyWithoutKid( token: string, options: VerifyOptions ): Promise { const publicKeys = await this.keyFetcher.fetchPublicKeys(); return this.verifyWithAllKeys(token, publicKeys, options); } private async verifyWithAllKeys( token: string, keys: {[key: string]: string}, options: VerifyOptions ): Promise { const promises: Promise[] = []; Object.values(keys).forEach((key) => { const promise = verify( token, async () => getPublicCryptoKey(key), options ) .then(() => true) .catch((error) => { if (error instanceof errors.JWTExpired) { throw error; } return false; }); promises.push(promise); }); return Promise.all(promises).then((result) => { if (result.every((r) => r === false)) { throw new AuthError(AuthErrorCode.INVALID_SIGNATURE); } }); } } export async function fetchPublicKey( fetcher: KeyFetcher, header: ProtectedHeaderParameters ): Promise { if (!header.kid) { throw new AuthError(AuthErrorCode.NO_KID_IN_HEADER); } const kid = header.kid; const publicKeys = await fetcher.fetchPublicKeys(); if (!Object.prototype.hasOwnProperty.call(publicKeys, kid)) { throw new AuthError(AuthErrorCode.NO_MATCHING_KID); } return publicKeys[kid]; } ================================================ FILE: src/auth/test/create-custom-token.integration.test.ts ================================================ import {customTokenToIdAndRefreshTokens, getFirebaseAuth} from '../index.js'; import {v4} from 'uuid'; import {AppCheckToken} from '../../app-check/types.js'; import {getAppCheck} from '../../app-check/index.js'; const { FIREBASE_API_KEY, FIREBASE_PROJECT_ID, FIREBASE_ADMIN_CLIENT_EMAIL, FIREBASE_ADMIN_PRIVATE_KEY, FIREBASE_APP_ID } = process.env; const TEST_SERVICE_ACCOUNT = { clientEmail: FIREBASE_ADMIN_CLIENT_EMAIL!, privateKey: FIREBASE_ADMIN_PRIVATE_KEY!.replace(/\\n/g, '\n'), projectId: FIREBASE_PROJECT_ID! }; const REFERER = 'http://localhost:3000'; describe('create custom token integration test', () => { let appCheckToken: AppCheckToken = {token: '', ttlMillis: 0}; beforeAll(async () => { const {createToken} = getAppCheck(TEST_SERVICE_ACCOUNT); appCheckToken = await createToken(FIREBASE_APP_ID!); }); const {createCustomToken, getCustomIdAndRefreshTokens, verifyIdToken} = getFirebaseAuth(TEST_SERVICE_ACCOUNT, FIREBASE_API_KEY!); it('should propagate custom claims when exchanging id tokens', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { paswordless_sign_in: true }); const {idToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {appCheckToken: appCheckToken.token, referer: REFERER} ); const customIdAndRefreshTokens = await getCustomIdAndRefreshTokens( idToken, { appCheckToken: appCheckToken.token } ); const decodedCustomIdToken = await verifyIdToken( customIdAndRefreshTokens.idToken, {referer: REFERER} ); expect(decodedCustomIdToken.paswordless_sign_in).toBe(true); }); }); ================================================ FILE: src/auth/test/no-matching-kid.integration.test.ts ================================================ import {v4} from 'uuid'; import {CLIENT_CERT_URL} from '../firebase.js'; import {customTokenToIdAndRefreshTokens, getFirebaseAuth} from '../index.js'; import {InvalidTokenError, InvalidTokenReason} from '../error.js'; const { FIREBASE_API_KEY, FIREBASE_PROJECT_ID, FIREBASE_ADMIN_CLIENT_EMAIL, FIREBASE_ADMIN_PRIVATE_KEY } = process.env; const TEST_SERVICE_ACCOUNT = { clientEmail: FIREBASE_ADMIN_CLIENT_EMAIL!, privateKey: FIREBASE_ADMIN_PRIVATE_KEY!.replace(/\\n/g, '\n'), projectId: FIREBASE_PROJECT_ID! }; const REFERER = 'http://localhost:3000'; describe('no matching kid integration test', () => { const {createCustomToken, verifyAndRefreshExpiredIdToken} = getFirebaseAuth( TEST_SERVICE_ACCOUNT, FIREBASE_API_KEY! ); beforeEach(() => { let numberOfCalls = 0; const actualFetch = global.fetch; // eslint-disable-next-line @typescript-eslint/no-explicit-any global.fetch = jest.fn((url: URL, ...args: any[]) => { if (url?.href === CLIENT_CERT_URL && !numberOfCalls) { numberOfCalls++; return { ok: true, headers: { forEach: () => {}, has: () => false }, json: () => Promise.resolve({}) }; } return actualFetch(url, ...args); }) as jest.Mock; }); it('should throw invalid token error if kid header does not match public keys', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); const {idToken, refreshToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {referer: REFERER} ); return expect(() => verifyAndRefreshExpiredIdToken( {idToken, refreshToken, customToken}, { referer: REFERER } ) ).rejects.toEqual(new InvalidTokenError(InvalidTokenReason.INVALID_KID)); }); it('should refresh the token if kid header does not match public keys and experimental flag is provided', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); const {idToken, refreshToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {referer: REFERER} ); const onTokenRefresh = jest.fn(); const result = await verifyAndRefreshExpiredIdToken( {idToken, refreshToken, customToken}, { referer: REFERER, enableTokenRefreshOnExpiredKidHeader: true, onTokenRefresh } ); expect(onTokenRefresh).toHaveBeenCalledWith(result); }); }); ================================================ FILE: src/auth/test/session-cookie.test.ts ================================================ import {v4} from 'uuid'; import {customTokenToIdAndRefreshTokens, getFirebaseAuth} from '../index.js'; const { FIREBASE_API_KEY, FIREBASE_PROJECT_ID, FIREBASE_ADMIN_CLIENT_EMAIL, FIREBASE_ADMIN_PRIVATE_KEY, FIREBASE_AUTH_TENANT_ID } = process.env; const TEST_SERVICE_ACCOUNT = { clientEmail: FIREBASE_ADMIN_CLIENT_EMAIL!, privateKey: FIREBASE_ADMIN_PRIVATE_KEY!.replace(/\\n/g, '\n'), projectId: FIREBASE_PROJECT_ID! }; const REFERER = 'http://localhost:3000'; describe('session cookie integration test', () => { const scenarios = [ { desc: 'single-tenant', tenantID: undefined }, { desc: 'multi-tenant', tenantId: FIREBASE_AUTH_TENANT_ID } ]; for (const {desc, tenantId} of scenarios) { describe(desc, () => { const {createCustomToken, createSessionCookie} = getFirebaseAuth( TEST_SERVICE_ACCOUNT, FIREBASE_API_KEY!, tenantId ); it('should create session cookie', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); const {idToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {tenantId, referer: REFERER} ); const cookie = await createSessionCookie(idToken, 60 * 60 * 1000); console.log({cookie}); }); }); } }); ================================================ FILE: src/auth/test/set-custom-user-claims.integration.test.ts ================================================ import {customTokenToIdAndRefreshTokens, getFirebaseAuth} from '../index.js'; import {v4} from 'uuid'; import {AppCheckToken} from '../../app-check/types.js'; import {getAppCheck} from '../../app-check.js'; const { FIREBASE_API_KEY, FIREBASE_PROJECT_ID, FIREBASE_ADMIN_CLIENT_EMAIL, FIREBASE_ADMIN_PRIVATE_KEY, FIREBASE_APP_ID } = process.env; const TEST_SERVICE_ACCOUNT = { clientEmail: FIREBASE_ADMIN_CLIENT_EMAIL!, privateKey: FIREBASE_ADMIN_PRIVATE_KEY!.replace(/\\n/g, '\n'), projectId: FIREBASE_PROJECT_ID! }; const REFERER = 'http://localhost:3000'; describe('set custom user claims integration test', () => { let appCheckToken: AppCheckToken = {token: '', ttlMillis: 0}; beforeAll(async () => { const {createToken} = getAppCheck(TEST_SERVICE_ACCOUNT); appCheckToken = await createToken(FIREBASE_APP_ID!); }); const {createCustomToken, getUser, setCustomUserClaims, verifyIdToken} = getFirebaseAuth(TEST_SERVICE_ACCOUNT, FIREBASE_API_KEY!); it('should create custom user claims', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); const {idToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {appCheckToken: appCheckToken.token, referer: REFERER} ); await verifyIdToken(idToken, {referer: REFERER}); await setCustomUserClaims(userId, { newCustomClaim: 'newCustomClaimValue' }); const user = await getUser(userId); expect(user?.uid).toEqual(userId); expect(user?.customClaims).toEqual({ newCustomClaim: 'newCustomClaimValue' }); }); }); ================================================ FILE: src/auth/test/user.integration.test.ts ================================================ import {getFirebaseAuth} from '../index.js'; const { FIREBASE_API_KEY, FIREBASE_PROJECT_ID, FIREBASE_ADMIN_CLIENT_EMAIL, FIREBASE_ADMIN_PRIVATE_KEY, FIREBASE_AUTH_TENANT_ID } = process.env; const TEST_USER_ID = '39d14e52-6e22-4afd-a844-c8aa2e685224'; jest.setTimeout(30000); describe('user integration test', () => { const scenarios = [ { desc: 'single-tenant' }, { desc: 'multi-tenant', tenantId: FIREBASE_AUTH_TENANT_ID } ]; for (const {desc, tenantId} of scenarios) { describe(desc, () => { const {createUser, getUser, deleteUser, updateUser, listUsers} = getFirebaseAuth( { clientEmail: FIREBASE_ADMIN_CLIENT_EMAIL!, privateKey: FIREBASE_ADMIN_PRIVATE_KEY!.replace(/\\n/g, '\n'), projectId: FIREBASE_PROJECT_ID! }, FIREBASE_API_KEY!, tenantId ); beforeEach(async () => { try { await deleteUser(TEST_USER_ID); // eslint-disable-next-line no-empty } catch {} }); it('should create user', async () => { await createUser({ uid: TEST_USER_ID, displayName: 'John Doe', email: 'user-integration-test@next-firebase-auth-edge.github' }); expect(await getUser(TEST_USER_ID)).toEqual( expect.objectContaining({ displayName: 'John Doe', email: 'user-integration-test@next-firebase-auth-edge.github', uid: TEST_USER_ID, tenantId }) ); /** * Firebase returns list of all users in not defined order * * @TODO: * This test needs to be improved. Currently, Github Actions is using next-firebase-auth-edge-starter Firebase credentials for running tests. * * 1. Create separate Firebase credentials for local and test environment * 2. Cleanup users from Firebase after each integration test * 3. Add new test that covers listing of users with and without tenantId */ const listUserResponse = await listUsers(); expect(listUserResponse.users.length).toBeGreaterThan(0); expect(listUserResponse.users[0]).toEqual( expect.objectContaining({ uid: expect.any(String) }) ); }); it('should update user', async () => { await createUser({ uid: TEST_USER_ID, displayName: 'John Doe', email: 'john-doe@next-firebase-auth-edge.github' }); await updateUser(TEST_USER_ID, { displayName: 'John Smith', email: 'john-smith@next-firebase-auth-edge.github' }); expect(await getUser(TEST_USER_ID)).toEqual( expect.objectContaining({ displayName: 'John Smith', email: 'john-smith@next-firebase-auth-edge.github', uid: TEST_USER_ID, tenantId }) ); }); }); } it('should get user by email', async () => { const {createUser, getUser, getUserByEmail, deleteUser} = getFirebaseAuth( { clientEmail: FIREBASE_ADMIN_CLIENT_EMAIL!, privateKey: FIREBASE_ADMIN_PRIVATE_KEY!.replace(/\\n/g, '\n'), projectId: FIREBASE_PROJECT_ID! }, FIREBASE_API_KEY! ); try { await deleteUser(TEST_USER_ID); // eslint-disable-next-line no-empty } catch {} await createUser({ uid: TEST_USER_ID, displayName: 'John Doe', email: 'john-doe@ensite.in', emailVerified: true }); const user = await getUserByEmail('john-doe@ensite.in'); expect(await getUser(TEST_USER_ID)).toEqual(user); }); }); ================================================ FILE: src/auth/test/verify-token.integration.test.ts ================================================ import { customTokenToIdAndRefreshTokens, getFirebaseAuth, isUserNotFoundError } from '../index'; import {v4} from 'uuid'; import {AuthError, AuthErrorCode} from '../error.js'; import {getAppCheck} from '../../app-check.js'; import {AppCheckToken} from '../../app-check/types.js'; const { FIREBASE_API_KEY, FIREBASE_PROJECT_ID, FIREBASE_ADMIN_CLIENT_EMAIL, FIREBASE_ADMIN_PRIVATE_KEY, FIREBASE_AUTH_TENANT_ID, FIREBASE_APP_ID } = process.env; const TEST_SERVICE_ACCOUNT = { clientEmail: FIREBASE_ADMIN_CLIENT_EMAIL!, privateKey: FIREBASE_ADMIN_PRIVATE_KEY!.replace(/\\n/g, '\n'), projectId: FIREBASE_PROJECT_ID! }; const REFERER = 'http://localhost:3000'; describe('verify token integration test', () => { const scenarios = [ { desc: 'single-tenant', tenantID: undefined }, { desc: 'multi-tenant', tenantId: FIREBASE_AUTH_TENANT_ID } ]; for (const {desc, tenantId} of scenarios) { let appCheckToken: AppCheckToken = {token: '', ttlMillis: 0}; beforeAll(async () => { const {createToken} = getAppCheck(TEST_SERVICE_ACCOUNT, tenantId); appCheckToken = await createToken(FIREBASE_APP_ID!); }); describe(desc, () => { const { handleTokenRefresh, createCustomToken, verifyAndRefreshExpiredIdToken, verifyIdToken, deleteUser } = getFirebaseAuth(TEST_SERVICE_ACCOUNT, FIREBASE_API_KEY!, tenantId); it('should create and verify custom token', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); const {idToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {tenantId, appCheckToken: appCheckToken.token, referer: REFERER} ); const tenant = await verifyIdToken(idToken, {referer: REFERER}); expect(tenant.uid).toEqual(userId); expect(tenant.customClaim).toEqual('customClaimValue'); expect(tenant.firebase.tenant).toEqual(tenantId); }); it('should throw AuthError if token is expired', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); const {idToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {tenantId, appCheckToken: appCheckToken.token, referer: REFERER} ); return expect(() => verifyIdToken(idToken, { currentDate: new Date(Date.now() + 7200 * 1000), referer: REFERER }) ).rejects.toHaveProperty('code', AuthErrorCode.TOKEN_EXPIRED); }); it('should refresh token if expired', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); const {idToken, refreshToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {tenantId, appCheckToken: appCheckToken.token, referer: REFERER} ); const onTokenRefresh = jest.fn(); const result = await verifyAndRefreshExpiredIdToken( {idToken, refreshToken, customToken}, { currentDate: new Date(Date.now() + 7200 * 1000), referer: REFERER, onTokenRefresh } ); expect(result?.decodedIdToken?.customClaim).toEqual('customClaimValue'); expect(onTokenRefresh).toHaveBeenCalledWith(result); }); it('should verify token', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); const {idToken, refreshToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {tenantId, appCheckToken: appCheckToken.token, referer: REFERER} ); const tokens = await verifyAndRefreshExpiredIdToken( {idToken, refreshToken, customToken}, {referer: REFERER} ); expect(tokens?.decodedIdToken.uid).toEqual(userId); expect(tokens?.decodedIdToken.customClaim).toEqual('customClaimValue'); expect(tokens?.decodedIdToken.firebase.tenant).toEqual(tenantId); }); it('should checked revoked token', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); const {idToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {tenantId, appCheckToken: appCheckToken.token, referer: REFERER} ); const tenant = await verifyIdToken(idToken, { checkRevoked: true, referer: REFERER }); expect(tenant.uid).toEqual(userId); expect(tenant.customClaim).toEqual('customClaimValue'); expect(tenant.firebase.tenant).toEqual(tenantId); }); it('should refresh token', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); const {idToken, refreshToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {tenantId, appCheckToken: appCheckToken.token, referer: REFERER} ); const {decodedIdToken} = await handleTokenRefresh(refreshToken, { referer: REFERER }); expect(decodedIdToken.uid).toEqual(userId); expect(decodedIdToken.customClaim).toEqual('customClaimValue'); expect(decodedIdToken.token).not.toEqual(idToken); }); it('should throw firebase auth error when user is not found during token refresh', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); const {refreshToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {tenantId, appCheckToken: appCheckToken.token, referer: REFERER} ); await deleteUser(userId); return expect(() => handleTokenRefresh(refreshToken, {referer: REFERER}) ).rejects.toEqual(new AuthError(AuthErrorCode.USER_NOT_FOUND)); }); it('should be able to catch "user not found" error and return null', async () => { const userId = v4(); const customToken = await createCustomToken(userId, { customClaim: 'customClaimValue' }); async function customGetToken() { try { return await handleTokenRefresh(refreshToken, {referer: REFERER}); } catch (e: unknown) { if (isUserNotFoundError(e)) { return null; } throw e; } } const {refreshToken} = await customTokenToIdAndRefreshTokens( customToken, FIREBASE_API_KEY!, {tenantId, appCheckToken: appCheckToken.token, referer: REFERER} ); await deleteUser(userId); expect(await customGetToken()).toEqual(null); }); }); } }); ================================================ FILE: src/auth/token-generator.ts ================================================ import {JWTPayload} from 'jose'; import {Credential, ServiceAccountCredential} from './credential.js'; import {AuthError, AuthErrorCode} from './error.js'; import { CryptoSigner, EmulatorSigner, IAMSigner, ServiceAccountSigner } from './jwt/crypto-signer'; import {isNonNullObject} from './validator.js'; import {useEmulator} from './firebase.js'; const ONE_HOUR_IN_SECONDS = 60 * 60; export const BLACKLISTED_CLAIMS = [ 'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', 'exp', 'iat', 'iss', 'jti', 'nbf', 'nonce' ]; const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; export class FirebaseTokenGenerator { private readonly signer: CryptoSigner; constructor(signer: CryptoSigner) { this.signer = signer; } public createCustomToken( uid: string, developerClaims?: {[key: string]: unknown} ): Promise { let errorMessage: string | undefined; if (uid.length > 128) { errorMessage = '`uid` argument must a uid with less than or equal to 128 characters.'; } else if ( !FirebaseTokenGenerator.isDeveloperClaimsValid_(developerClaims) ) { errorMessage = '`developerClaims` argument must be a valid, non-null object containing the developer claims.'; } if (errorMessage) { throw new AuthError(AuthErrorCode.INVALID_ARGUMENT, errorMessage); } const claims: {[key: string]: unknown} = {}; if (typeof developerClaims !== 'undefined') { for (const key in developerClaims) { if (Object.prototype.hasOwnProperty.call(developerClaims, key)) { if (BLACKLISTED_CLAIMS.indexOf(key) !== -1) { throw new AuthError( AuthErrorCode.INVALID_ARGUMENT, `Developer claim "${key}" is reserved and cannot be specified.` ); } claims[key] = developerClaims[key]; } } } return this.signer.getAccountId().then(async (account) => { const iat = Math.floor(Date.now() / 1000); const body: JWTPayload = { aud: FIREBASE_AUDIENCE, iat, exp: iat + ONE_HOUR_IN_SECONDS, iss: account, sub: account, uid }; if (Object.keys(claims).length > 0) { body.claims = claims; } return this.signer.sign(body); }); } private static isDeveloperClaimsValid_(developerClaims?: object): boolean { if (typeof developerClaims === 'undefined') { return true; } return isNonNullObject(developerClaims); } } export function cryptoSignerFromCredential( credential: Credential, tenantId?: string, serviceAccountId?: string ): CryptoSigner { if (credential instanceof ServiceAccountCredential) { return new ServiceAccountSigner(credential, tenantId); } return new IAMSigner(credential, tenantId, serviceAccountId); } export function createFirebaseTokenGenerator( credential: Credential, tenantId?: string, serviceAccountId?: string ): FirebaseTokenGenerator { if (useEmulator()) { return new FirebaseTokenGenerator(new EmulatorSigner(tenantId)); } const signer = cryptoSignerFromCredential( credential, tenantId, serviceAccountId ); return new FirebaseTokenGenerator(signer); } ================================================ FILE: src/auth/token-verifier.ts ================================================ import {decodeJwt, decodeProtectedHeader, errors} from 'jose'; import {JOSEError} from 'jose/dist/types/util/errors'; import {AuthError, AuthErrorCode} from './error.js'; import {CLIENT_CERT_URL, FIREBASE_AUDIENCE, useEmulator} from './firebase.js'; import {ALGORITHM_RS256} from './jwt/verify.js'; import { DecodedToken, PublicKeySignatureVerifier, SignatureVerifier } from './signature-verifier'; import {DecodedIdToken, VerifyOptions} from './types.js'; import {mapJwtPayloadToDecodedIdToken} from './utils.js'; import {isURL} from './validator.js'; export class FirebaseTokenVerifier { private readonly signatureVerifier: SignatureVerifier; constructor( clientCertUrl: string, private issuer: string, private projectId: string, private tenantId?: string ) { if (!isURL(clientCertUrl)) { throw new AuthError( AuthErrorCode.INVALID_ARGUMENT, 'The provided public client certificate URL is an invalid URL.' ); } this.signatureVerifier = PublicKeySignatureVerifier.withCertificateUrl(clientCertUrl); } public async verifyJWT( jwtToken: string, options: VerifyOptions ): Promise { const decoded = await this.decodeAndVerify( jwtToken, this.projectId, options ); const decodedIdToken = mapJwtPayloadToDecodedIdToken(decoded.payload); if (this.tenantId && decodedIdToken.firebase.tenant !== this.tenantId) { throw new AuthError(AuthErrorCode.MISMATCHING_TENANT_ID); } return decodedIdToken; } private async decodeAndVerify( token: string, projectId: string, options: VerifyOptions ): Promise { const header = decodeProtectedHeader(token); const payload = decodeJwt(token); this.verifyContent({header, payload}, projectId); await this.verifySignature(token, options); return {header, payload}; } private verifyContent( fullDecodedToken: DecodedToken, projectId: string | null ): void { const header = fullDecodedToken && fullDecodedToken.header; const payload = fullDecodedToken && fullDecodedToken.payload; let errorMessage: string | undefined; if (!useEmulator() && typeof header.kid === 'undefined') { const isCustomToken = payload.aud === FIREBASE_AUDIENCE; if (isCustomToken) { errorMessage = `idToken was expected, but custom token was provided`; } else { errorMessage = `idToken has no "kid" claim.`; } } else if (!useEmulator() && header.alg !== ALGORITHM_RS256) { errorMessage = `Incorrect algorithm. ${ALGORITHM_RS256} expected, ${header.alg} provided`; } else if (payload.iss !== this.issuer + projectId) { errorMessage = `idToken has incorrect "iss" (issuer) claim. Expected ${this.issuer}${projectId}, but got ${payload.iss}`; } else if (payload.sub === '') { errorMessage = `idToken has an empty string "sub" (subject) claim.`; } else if (typeof payload.sub === 'string' && payload.sub.length > 128) { errorMessage = `idToken has "sub" (subject) claim longer than 128 characters.`; } if (errorMessage) { throw new AuthError(AuthErrorCode.INVALID_ARGUMENT, errorMessage); } } private verifySignature( jwtToken: string, options: VerifyOptions ): Promise { return this.signatureVerifier.verify(jwtToken, options).catch((error) => { throw this.mapJoseErrorToAuthError(error); }); } private mapJoseErrorToAuthError(error: JOSEError): Error { if (error instanceof errors.JWTExpired) { return AuthError.fromError( error, AuthErrorCode.TOKEN_EXPIRED, error.message ); } if (error instanceof errors.JWSSignatureVerificationFailed) { return AuthError.fromError(error, AuthErrorCode.INVALID_SIGNATURE); } if (error instanceof AuthError) { return error; } return AuthError.fromError( error, AuthErrorCode.INTERNAL_ERROR, error.message ); } } export function createIdTokenVerifier( projectId: string, tenantId?: string ): FirebaseTokenVerifier { return new FirebaseTokenVerifier( CLIENT_CERT_URL, 'https://securetoken.google.com/', projectId, tenantId ); } ================================================ FILE: src/auth/types.ts ================================================ export interface FirebaseClaims { identities: { [key: string]: unknown; }; sign_in_provider: string; sign_in_second_factor?: string; second_factor_identifier?: string; tenant?: string; [key: string]: unknown; } export interface DecodedIdToken { aud: string; auth_time: number; email?: string; email_verified?: boolean; name?: string; exp: number; firebase: FirebaseClaims; source_sign_in_provider: string; iat: number; iss: string; phone_number?: string; picture?: string; sub: string; uid: string; [key: string]: unknown; } export interface VerifyOptions { currentDate?: Date; checkRevoked?: boolean; referer?: string; enableTokenRefreshOnExpiredKidHeader?: boolean; } export interface Tokens { decodedToken: DecodedIdToken; token: string; // Set `enableCustomToken` to true in `authMiddleware` to enable custom token customToken?: string; // Pass `getMetadata` to `authMiddleware` to save token metadata metadata: Metadata; } export interface TokenSet { idToken: string; refreshToken: string; decodedIdToken: DecodedIdToken; customToken?: string; } ================================================ FILE: src/auth/user-record.ts ================================================ import {addReadonlyGetter, deepCopy} from './utils.js'; import {isNonNullObject} from './validator.js'; import {base64url} from 'jose'; import {AuthError, AuthErrorCode} from './error.js'; const B64_REDACTED = base64url.encode('REDACTED'); function parseDate(time: unknown): string | null { try { const date = new Date(parseInt(time as string, 10)); if (!isNaN(date.getTime())) { return date.toUTCString(); } } catch { return null; } return null; } export interface MultiFactorInfoResponse { mfaEnrollmentId: string; displayName?: string; phoneInfo?: string; enrolledAt?: string; [key: string]: unknown; } export interface ProviderUserInfoResponse { rawId: string; displayName?: string; email?: string; photoUrl?: string; phoneNumber?: string; providerId: string; federatedId?: string; } export interface GetAccountInfoUserResponse { localId: string; email?: string; emailVerified?: boolean; phoneNumber?: string; displayName?: string; photoUrl?: string; disabled?: boolean; passwordHash?: string; salt?: string; customAttributes?: string; validSince?: string; tenantId?: string; providerUserInfo?: ProviderUserInfoResponse[]; mfaInfo?: MultiFactorInfoResponse[]; createdAt?: string; lastLoginAt?: string; lastRefreshAt?: string; [key: string]: unknown; } enum MultiFactorId { Phone = 'phone' } export interface MultiFactorInfoType { uid?: string; displayName?: string; factorId?: string; enrollmentTime?: string; } export abstract class MultiFactorInfo { public readonly uid!: string; public readonly displayName?: string; public readonly factorId!: string; public readonly enrollmentTime?: string; public static initMultiFactorInfo( response: MultiFactorInfoResponse ): MultiFactorInfo | null { let multiFactorInfo: MultiFactorInfo | null = null; try { multiFactorInfo = new PhoneMultiFactorInfo(response); } catch { return null; } return multiFactorInfo; } constructor(response: MultiFactorInfoResponse) { this.initFromServerResponse(response); } public toJSON(): MultiFactorInfoType { return { uid: this.uid, displayName: this.displayName, factorId: this.factorId, enrollmentTime: this.enrollmentTime }; } protected abstract getFactorId( response: MultiFactorInfoResponse ): string | null; private initFromServerResponse(response: MultiFactorInfoResponse): void { const factorId = response && this.getFactorId(response); if (!factorId || !response || !response.mfaEnrollmentId) { throw new AuthError( AuthErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Invalid multi-factor info response' ); } addReadonlyGetter(this, 'uid', response.mfaEnrollmentId); addReadonlyGetter(this, 'factorId', factorId); addReadonlyGetter(this, 'displayName', response.displayName); if (response.enrolledAt) { addReadonlyGetter( this, 'enrollmentTime', new Date(response.enrolledAt).toUTCString() ); } else { addReadonlyGetter(this, 'enrollmentTime', null); } } } export class PhoneMultiFactorInfo extends MultiFactorInfo { public readonly phoneNumber!: string; constructor(response: MultiFactorInfoResponse) { super(response); addReadonlyGetter(this, 'phoneNumber', response.phoneInfo); } public toJSON(): object { return Object.assign(super.toJSON(), { phoneNumber: this.phoneNumber }); } protected getFactorId(response: MultiFactorInfoResponse): string | null { return response && response.phoneInfo ? MultiFactorId.Phone : null; } } export class MultiFactorSettings { public enrolledFactors!: MultiFactorInfo[]; constructor(response: GetAccountInfoUserResponse) { const parsedEnrolledFactors: MultiFactorInfo[] = []; if (!isNonNullObject(response)) { throw new AuthError( AuthErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Invalid multi-factor response' ); } else if (response.mfaInfo) { response.mfaInfo.forEach((factorResponse) => { const multiFactorInfo = MultiFactorInfo.initMultiFactorInfo(factorResponse); if (multiFactorInfo) { parsedEnrolledFactors.push(multiFactorInfo); } }); } addReadonlyGetter( this, 'enrolledFactors', Object.freeze(parsedEnrolledFactors) ); } public toJSON(): object { return { enrolledFactors: this.enrolledFactors.map((info) => info.toJSON()) }; } } export interface UserMetadataType { lastSignInTime?: string; creationTime?: string; lastRefreshTime?: string | null; } export class UserMetadata { public readonly creationTime!: string; public readonly lastSignInTime!: string; public readonly lastRefreshTime?: string | null; constructor(response: GetAccountInfoUserResponse) { addReadonlyGetter(this, 'creationTime', parseDate(response.createdAt)); addReadonlyGetter(this, 'lastSignInTime', parseDate(response.lastLoginAt)); const lastRefreshAt = response.lastRefreshAt ? new Date(response.lastRefreshAt).toUTCString() : null; addReadonlyGetter(this, 'lastRefreshTime', lastRefreshAt); } public toJSON(): UserMetadataType { return { lastSignInTime: this.lastSignInTime, creationTime: this.creationTime, lastRefreshTime: this.lastRefreshTime }; } } export type UserInfoType = { uid?: string; displayName?: string; email?: string; photoURL?: string; providerId?: string; phoneNumber?: string; }; export class UserInfo { public readonly uid!: string; public readonly displayName!: string; public readonly email!: string; public readonly photoURL!: string; public readonly providerId!: string; public readonly phoneNumber!: string; constructor(response: ProviderUserInfoResponse) { if (!response.rawId || !response.providerId) { throw new AuthError( AuthErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Invalid user info response' ); } addReadonlyGetter(this, 'uid', response.rawId); addReadonlyGetter(this, 'displayName', response.displayName); addReadonlyGetter(this, 'email', response.email); addReadonlyGetter(this, 'photoURL', response.photoUrl); addReadonlyGetter(this, 'providerId', response.providerId); addReadonlyGetter(this, 'phoneNumber', response.phoneNumber); } public toJSON(): UserInfoType { return { uid: this.uid, displayName: this.displayName, email: this.email, photoURL: this.photoURL, providerId: this.providerId, phoneNumber: this.phoneNumber }; } } export class UserRecord { public readonly uid!: string; public readonly email?: string; public readonly emailVerified!: boolean; public readonly displayName?: string; public readonly photoURL?: string; public readonly phoneNumber?: string; public readonly disabled!: boolean; public readonly metadata!: UserMetadata; public readonly providerData!: UserInfo[]; public readonly passwordHash?: string; public readonly passwordSalt?: string; public readonly customClaims?: {[key: string]: unknown}; public readonly tenantId?: string | null; public readonly tokensValidAfterTime?: string; public readonly multiFactor?: MultiFactorSettings; constructor(response: GetAccountInfoUserResponse) { if (!response.localId) { throw new AuthError( AuthErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Invalid user response' ); } addReadonlyGetter(this, 'uid', response.localId); addReadonlyGetter(this, 'email', response.email); addReadonlyGetter(this, 'emailVerified', !!response.emailVerified); addReadonlyGetter(this, 'displayName', response.displayName); addReadonlyGetter(this, 'photoURL', response.photoUrl); addReadonlyGetter(this, 'phoneNumber', response.phoneNumber); addReadonlyGetter(this, 'disabled', response.disabled || false); addReadonlyGetter(this, 'metadata', new UserMetadata(response)); const providerData: UserInfo[] = []; for (const entry of response.providerUserInfo || []) { providerData.push(new UserInfo(entry)); } addReadonlyGetter(this, 'providerData', providerData); if (response.passwordHash === B64_REDACTED) { addReadonlyGetter(this, 'passwordHash', undefined); } else { addReadonlyGetter(this, 'passwordHash', response.passwordHash); } addReadonlyGetter(this, 'passwordSalt', response.salt); if (response.customAttributes) { addReadonlyGetter( this, 'customClaims', JSON.parse(response.customAttributes) ); } let validAfterTime: string | null = null; if (typeof response.validSince !== 'undefined') { validAfterTime = parseDate(parseInt(response.validSince, 10) * 1000); } addReadonlyGetter( this, 'tokensValidAfterTime', validAfterTime || undefined ); addReadonlyGetter(this, 'tenantId', response.tenantId); const multiFactor = new MultiFactorSettings(response); if (multiFactor.enrolledFactors.length > 0) { addReadonlyGetter(this, 'multiFactor', multiFactor); } } public toJSON(): UserRecordType { const json: UserRecordType = { uid: this.uid, email: this.email, emailVerified: this.emailVerified, displayName: this.displayName, photoURL: this.photoURL, phoneNumber: this.phoneNumber, disabled: this.disabled, metadata: this.metadata.toJSON(), passwordHash: this.passwordHash, passwordSalt: this.passwordSalt, customClaims: deepCopy(this.customClaims), tokensValidAfterTime: this.tokensValidAfterTime, tenantId: this.tenantId }; if (this.multiFactor) { json.multiFactor = this.multiFactor.toJSON(); } json.providerData = []; for (const entry of this.providerData) { json.providerData.push(entry.toJSON()); } return json; } } export interface UserRecordType { uid?: string; email?: string; emailVerified?: boolean; displayName?: string; photoURL?: string; phoneNumber?: string; multiFactor?: MultiFactorInfoType; disabled?: boolean; metadata?: UserMetadataType; passwordHash?: string; passwordSalt?: string; customClaims?: {[key: string]: unknown}; providerData?: UserInfoType[]; tokensValidAfterTime?: string; tenantId?: string | null; } ================================================ FILE: src/auth/utils.ts ================================================ import {JWTPayload} from 'jose'; import {DecodedIdToken} from './types.js'; export function formatString(str: string, params?: object): string { let formatted = str; Object.keys(params || {}).forEach((key) => { formatted = formatted.replace( new RegExp('{' + key + '}', 'g'), (params as {[key: string]: string})[key] ); }); return formatted; } async function getDetailFromResponse(response: Response): Promise { const json = await response.json(); if (!json) { return 'Missing error payload'; } let detail = typeof json.error === 'string' ? json.error : (json.error?.message ?? 'Missing error payload'); if (json.error_description) { detail += ' (' + json.error_description + ')'; } return detail; } export async function fetchJson(url: string, init: RequestInit) { return (await fetchAny(url, init)).json(); } export async function fetchText(url: string, init: RequestInit) { return (await fetchAny(url, init)).text(); } export async function fetchAny(url: string, init: RequestInit) { const response = await fetch(url, init); if (!response.ok) { throw new Error(await getDetailFromResponse(response)); } return response; } export function mapJwtPayloadToDecodedIdToken(payload: JWTPayload) { const decodedIdToken = payload as DecodedIdToken; decodedIdToken.uid = decodedIdToken.sub; return decodedIdToken; } export function addReadonlyGetter( obj: object, prop: string, value: unknown ): void { Object.defineProperty(obj, prop, { value, writable: false, enumerable: true }); } export function deepCopy(value: T): T { return deepExtend(undefined, value) as T; } export function deepExtend(target: T, source: T): T { if (!(source instanceof Object)) { return source; } switch (source.constructor) { case Date: { const dateValue = source as unknown as Date; return new Date(dateValue.getTime()) as T; } case Object: if (target === undefined) { target = {} as T; } break; case Array: target = [] as T; break; default: return source; } for (const prop in source) { if (!Object.prototype.hasOwnProperty.call(source, prop)) { continue; } const objectTarget = target as {[key: string]: unknown}; objectTarget[prop] = deepExtend(objectTarget[prop], objectTarget[prop]); } return target; } const encoder = new TextEncoder(); export function toUint8Array(key: string) { return encoder.encode(key); } ================================================ FILE: src/auth/validator.ts ================================================ export function isObject(value: unknown): boolean { return typeof value === 'object' && !isArray(value); } export function isArray(value: unknown): value is T[] { return Array.isArray(value); } export function isNonNullObject(value: T | null | undefined): value is T { return isObject(value) && value !== null; } export function isEmail(email: unknown): boolean { if (typeof email !== 'string') { return false; } // There must at least one character before the @ symbol and another after. const re = /^[^@]+@[^@]+$/; return re.test(email); } export function isURL(urlStr: unknown): boolean { if (typeof urlStr !== 'string') { return false; } const re = /[^a-z0-9:/?#[\]@!$&'()*+,;=.\-_~%]/i; if (re.test(urlStr)) { return false; } try { const uri = new URL(urlStr); const scheme = uri.protocol; const hostname = uri.hostname; const pathname = uri.pathname; if (scheme !== 'http:' && scheme !== 'https:') { return false; } if ( !hostname || !/^[a-zA-Z0-9]+[\w-]*([.]?[a-zA-Z0-9]+[\w-]*)*$/.test(hostname) ) { return false; } const pathnameRe = /^(\/[\w\-.~!$'()*+,;=:@%]+)*\/?$/; if (pathname && pathname !== '/' && !pathnameRe.test(pathname)) { return false; } } catch { return false; } return true; } ================================================ FILE: src/debug/index.ts ================================================ let debugEnabled = false; export function enableDebugMode() { debugEnabled = true; } export function debug( message: string, metadata?: Record ) { if (!debugEnabled) { return; } console.log('ⓘ next-firebase-auth-edge:', message); if (!metadata) { return; } for (const key in metadata) { if (metadata[key]) { console.log('\t', `${key}:`, metadata[key]); } } } ================================================ FILE: src/index.ts ================================================ export { authMiddleware, redirectToHome, redirectToLogin, redirectToPath } from './next/middleware.js'; export { getTokens, getTokensFromObject, getApiRequestTokens } from './next/tokens.js'; export {getFirebaseAuth} from './auth/index.js'; export type {Tokens} from './auth/index.js'; ================================================ FILE: src/next/api.ts ================================================ import type {IncomingHttpHeaders} from 'http'; import {NextApiRequest, NextApiResponse} from 'next'; import {ParsedCookies, VerifiedCookies} from '../auth/custom-token/index.js'; import {getFirebaseAuth} from '../auth/index.js'; import {AuthCookies} from './cookies/AuthCookies.js'; import {CookiesObject, SetAuthCookiesOptions} from './cookies/index.js'; import {ObjectCookiesProvider} from './cookies/parser/ObjectCookiesProvider.js'; import {getCookiesTokens} from './tokens.js'; import {getMetadataInternal} from './metadata.js'; export async function refreshApiResponseCookies( request: NextApiRequest, response: NextApiResponse, options: SetAuthCookiesOptions ): Promise { const value = await refreshApiCookies( request.cookies, request.headers, options ); await appendAuthCookiesApi(request.cookies, response, value, options); return response; } export async function appendAuthCookiesApi( cookies: CookiesObject, response: NextApiResponse, value: ParsedCookies, options: SetAuthCookiesOptions ) { const authCookies = new AuthCookies( new ObjectCookiesProvider(cookies), options ); await authCookies.setAuthNextApiResponseHeaders(value, response); } export async function refreshApiCookies( cookies: CookiesObject, headers: IncomingHttpHeaders, options: SetAuthCookiesOptions ): Promise> { const referer = headers['referer'] ?? ''; const tokens = await getCookiesTokens(cookies, options); const {handleTokenRefresh} = getFirebaseAuth({ serviceAccount: options.serviceAccount, apiKey: options.apiKey, tenantId: options.tenantId }); const tokenRefreshResult = await handleTokenRefresh(tokens.refreshToken, { referer, enableCustomToken: options.enableCustomToken }); const metadata = await getMetadataInternal( tokenRefreshResult, options ); return { customToken: tokenRefreshResult.customToken, idToken: tokenRefreshResult.idToken, refreshToken: tokenRefreshResult.refreshToken, decodedIdToken: tokenRefreshResult.decodedIdToken, metadata }; } ================================================ FILE: src/next/client.ts ================================================ import {decodeJwt} from 'jose'; import {AuthError, AuthErrorCode, HttpError} from '../auth/error.js'; class ClientTokenCache { private cacheMap: Record = {}; constructor() {} public get(value: string) { if (!this.cacheMap[value]) { return value; } return this.cacheMap[value]; } public set(originalValue: string, value: string) { this.cacheMap = {[originalValue]: value}; } } const idTokenCache = new ClientTokenCache(); const customTokenCache = new ClientTokenCache(); export interface GetValidIdTokenOptions { serverIdToken: string; refreshTokenUrl: string; checkRevoked?: boolean; } export async function getValidIdToken({ serverIdToken, refreshTokenUrl, checkRevoked }: GetValidIdTokenOptions): Promise { // If serverIdToken is empty, we assume user is unauthenticated and token refresh will yield null if (!serverIdToken) { return null; } const token = idTokenCache.get(serverIdToken); const payload = decodeJwt(token); const exp = payload?.exp ?? 0; if (!checkRevoked && exp > Date.now() / 1000) { return token || serverIdToken; } const response = await fetchApi<{idToken: string}>(refreshTokenUrl); if (!response?.idToken) { throw new AuthError( AuthErrorCode.INTERNAL_ERROR, 'Refresh token endpoint returned invalid response. This URL should point to endpoint exposed by the middleware and configured using refreshTokenPath option' ); } idTokenCache.set(serverIdToken, response.idToken); return response.idToken; } export interface GetValidCustomTokenOptions { serverCustomToken: string; refreshTokenUrl: string; checkRevoked?: boolean; } export async function getValidCustomToken({ serverCustomToken, refreshTokenUrl, checkRevoked }: GetValidCustomTokenOptions): Promise { // If serverCustomToken is empty, we assume user is unauthenticated and token refresh will yield null if (!serverCustomToken) { return null; } const token = customTokenCache.get(serverCustomToken); const payload = decodeJwt(token); const exp = payload?.exp ?? 0; if (!checkRevoked && exp > Date.now() / 1000) { return token || serverCustomToken; } const response = await fetchApi<{customToken: string}>(refreshTokenUrl); if (!response) { throw new AuthError( AuthErrorCode.INTERNAL_ERROR, 'Refresh token endpoint returned invalid response. This URL should point to endpoint exposed by the middleware and configured using refreshTokenPath option.' ); } if (!response.customToken) { throw new AuthError( AuthErrorCode.INTERNAL_ERROR, 'Refresh token endpoint returned empty custom token. Make sure you have set `enableCustomToken` option to `true` in `authMiddleware`' ); } customTokenCache.set(serverCustomToken, response.customToken); return response.customToken; } async function mapResponseToAuthError( response: Response, input: RequestInfo | URL, init?: RequestInit ) { if (response.status === 401) { const data = await safeResponse(response); return new AuthError(AuthErrorCode.INVALID_CREDENTIAL, data?.message); } const text = await safeResponse(response); return new AuthError( AuthErrorCode.INTERNAL_ERROR, `next-firebase-auth-edge: Internal request to ${ init?.method ?? 'GET' } ${input.toString()} has failed: ${text}` ); } function safeResponse(response: Response): Promise { const contentType = response.headers.get('content-type'); if (contentType && contentType.indexOf('application/json') !== -1) { return response.json(); } else { return response.text() as Promise; } } async function fetchApi( input: RequestInfo | URL, init?: RequestInit ): Promise { const response = await fetch(input, { ...init, headers: { ...init?.headers, Accept: 'application/json', 'Content-Type': 'application/json' } }); if (!response.ok) { throw await mapResponseToAuthError(response, input, init); } return safeResponse(response); } ================================================ FILE: src/next/cookies/AuthCookies.test.ts ================================================ import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import {ParsedCookies} from '../../auth/custom-token/index.ts'; import {AuthCookies} from './AuthCookies.ts'; import {SetAuthCookiesOptions} from './index.ts'; import {ObjectCookiesProvider} from './parser/ObjectCookiesProvider.ts'; const cookieName = 'TestCookie'; const cookieSerializeOptions = { path: '/', httpOnly: true, secure: true, sameSite: 'lax' as const, maxAge: 12 * 60 * 60 * 24, expires: new Date(1727373870 * 1000) }; const setAuthCookiesOptions: SetAuthCookiesOptions = { cookieName, cookieSerializeOptions, cookieSignatureKeys: ['secret'], apiKey: 'test-api-key', enableCustomToken: true }; const mockTokens: ParsedCookies = { idToken: 'id-token', refreshToken: 'refresh-token', customToken: 'custom-token', metadata: {} as never }; describe('AuthCookies', () => { describe('headers', () => { it('should set single cookie', async () => { const provider = new ObjectCookiesProvider({}); const cookies = new AuthCookies(provider, setAuthCookiesOptions); const headers = {append: jest.fn()} as unknown as Headers; await cookies.setAuthHeaders(mockTokens, headers); expect(headers.append).toHaveBeenCalledTimes(1); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4iLCJjdXN0b21fdG9rZW4iOiJjdXN0b20tdG9rZW4ifQ.ExxN2rNayg2XCR6WNeZmY8tAyc_qyiZ2YdzITRbQocs; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); }); it('should set multiple cookies', async () => { const provider = new ObjectCookiesProvider({}); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableMultipleCookies: true }); const headers = {append: jest.fn()} as unknown as Headers; await cookies.setAuthHeaders(mockTokens, headers); expect(headers.append).toHaveBeenCalledTimes(4); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.id=id-token; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.refresh=refresh-token; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.custom=custom-token; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.sig=QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); }); it('should set multiple cookies and remove single cookie if exists', async () => { const provider = new ObjectCookiesProvider({ TestCookie: 'legacy-token' }); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableMultipleCookies: true }); const headers = {append: jest.fn()} as unknown as Headers; await cookies.setAuthHeaders(mockTokens, headers); expect(headers.append).toHaveBeenCalledTimes(5); expect(headers.append).toHaveBeenNthCalledWith( 1, 'Set-Cookie', 'TestCookie=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.id=id-token; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.refresh=refresh-token; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.custom=custom-token; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.sig=QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); }); it('should set multiple cookies and remove custom cookie if not enabled', async () => { const provider = new ObjectCookiesProvider({ TestCookie: 'legacy-token', 'TestCookie.custom': 'custom-token' }); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableMultipleCookies: true, enableCustomToken: false }); const headers = {append: jest.fn()} as unknown as Headers; await cookies.setAuthHeaders(mockTokens, headers); expect(headers.append).toHaveBeenCalledTimes(5); expect(headers.append).toHaveBeenNthCalledWith( 1, 'Set-Cookie', 'TestCookie.custom=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenNthCalledWith( 2, 'Set-Cookie', 'TestCookie=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.id=id-token; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.refresh=refresh-token; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenNthCalledWith( 5, 'Set-Cookie', 'TestCookie.sig=g-7yXxxJfMmzsR7BqkJjguoUWsOqCTGz2AndxjJBrkw; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); }); it('should set single cookie and remove multiple cookie if exists', async () => { const provider = new ObjectCookiesProvider({ 'TestCookie.id': 'legacy-id-token', 'TestCookie.refresh': 'legacy-refresh-token', 'TestCookie.custom': 'legacy-custom-token', 'TestCookie.sig': 'legacy-signature' }); const cookies = new AuthCookies(provider, setAuthCookiesOptions); const headers = {append: jest.fn()} as unknown as Headers; await cookies.setAuthHeaders(mockTokens, headers); expect(headers.append).toHaveBeenCalledTimes(6); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.refresh=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.custom=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.metadata=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.sig=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenNthCalledWith( 6, 'Set-Cookie', 'TestCookie=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4iLCJjdXN0b21fdG9rZW4iOiJjdXN0b20tdG9rZW4ifQ.ExxN2rNayg2XCR6WNeZmY8tAyc_qyiZ2YdzITRbQocs; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); }); it('should set single cookie and remove custom token if exists', async () => { const provider = new ObjectCookiesProvider({ 'TestCookie.custom': 'legacy-custom-token' }); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableCustomToken: false }); const headers = {append: jest.fn()} as unknown as Headers; await cookies.setAuthHeaders(mockTokens, headers); expect(headers.append).toHaveBeenCalledTimes(2); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.custom=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenNthCalledWith( 2, 'Set-Cookie', 'TestCookie=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4ifQ.Zf81UFf9nyW96_0M0eymGmfPABKYben_nGMc1_9l86k; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); }); it('should set single cookie and remove legacy multiple cookie if exists', async () => { const provider = new ObjectCookiesProvider({ TestCookie: 'legacy-id-token:legacy-refresh-token', 'TestCookie.custom': 'legacy-custom-token', 'TestCookie.sig': 'legacy-signature' }); const cookies = new AuthCookies(provider, setAuthCookiesOptions); const headers = {append: jest.fn()} as unknown as Headers; await cookies.setAuthHeaders(mockTokens, headers); expect(headers.append).toHaveBeenCalledTimes(6); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.refresh=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.custom=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.metadata=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.sig=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenNthCalledWith( 6, 'Set-Cookie', 'TestCookie=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4iLCJjdXN0b21fdG9rZW4iOiJjdXN0b20tdG9rZW4ifQ.ExxN2rNayg2XCR6WNeZmY8tAyc_qyiZ2YdzITRbQocs; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); }); it('should set single cookie without custom token', async () => { const provider = new ObjectCookiesProvider({}); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableCustomToken: false }); const headers = {append: jest.fn()} as unknown as Headers; await cookies.setAuthHeaders(mockTokens, headers); expect(headers.append).toHaveBeenCalledTimes(1); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4ifQ.Zf81UFf9nyW96_0M0eymGmfPABKYben_nGMc1_9l86k; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); }); it('should set multiple cookies without custom token', async () => { const provider = new ObjectCookiesProvider({}); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableMultipleCookies: true, enableCustomToken: false }); const headers = {append: jest.fn()} as unknown as Headers; await cookies.setAuthHeaders(mockTokens, headers); expect(headers.append).toHaveBeenCalledTimes(3); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.id=id-token; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.refresh=refresh-token; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.sig=g-7yXxxJfMmzsR7BqkJjguoUWsOqCTGz2AndxjJBrkw; Max-Age=1036800; Path=/; Expires=Thu, 26 Sep 2024 18:04:30 GMT; HttpOnly; Secure; SameSite=Lax' ); }); }); describe('cookies', () => { it('should set single cookie', async () => { const provider = new ObjectCookiesProvider({}); const cookies = new AuthCookies(provider, setAuthCookiesOptions); const requestCookies = { set: jest.fn(), delete: jest.fn() } as unknown as RequestCookies; await cookies.setAuthCookies(mockTokens, requestCookies); expect(requestCookies.set).toHaveBeenCalledTimes(1); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4iLCJjdXN0b21fdG9rZW4iOiJjdXN0b20tdG9rZW4ifQ.ExxN2rNayg2XCR6WNeZmY8tAyc_qyiZ2YdzITRbQocs', cookieSerializeOptions ); }); it('should set multiple cookies', async () => { const provider = new ObjectCookiesProvider({}); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableMultipleCookies: true }); const requestCookies = { set: jest.fn(), delete: jest.fn() } as unknown as RequestCookies; await cookies.setAuthCookies(mockTokens, requestCookies); expect(requestCookies.set).toHaveBeenCalledTimes(4); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.id', 'id-token', cookieSerializeOptions ); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.refresh', 'refresh-token', cookieSerializeOptions ); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.custom', 'custom-token', cookieSerializeOptions ); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.sig', 'QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0', cookieSerializeOptions ); }); it('should set multiple cookies and remove single cookie if exists', async () => { const provider = new ObjectCookiesProvider({ TestCookie: 'legacy-token' }); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableMultipleCookies: true }); const requestCookies = { set: jest.fn(), delete: jest.fn() } as unknown as RequestCookies; await cookies.setAuthCookies(mockTokens, requestCookies); expect(requestCookies.delete).toHaveBeenCalledTimes(1); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie'); expect(requestCookies.set).toHaveBeenCalledTimes(4); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.id', 'id-token', cookieSerializeOptions ); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.refresh', 'refresh-token', cookieSerializeOptions ); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.custom', 'custom-token', cookieSerializeOptions ); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.sig', 'QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0', cookieSerializeOptions ); }); it('should set multiple cookies and remove custom cookie if not enabled', async () => { const provider = new ObjectCookiesProvider({ TestCookie: 'legacy-token', 'TestCookie.custom': 'custom-token' }); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableMultipleCookies: true, enableCustomToken: false }); const requestCookies = { set: jest.fn(), delete: jest.fn() } as unknown as RequestCookies; await cookies.setAuthCookies(mockTokens, requestCookies); expect(requestCookies.delete).toHaveBeenCalledTimes(2); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie'); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.custom'); expect(requestCookies.set).toHaveBeenCalledTimes(3); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.id', 'id-token', cookieSerializeOptions ); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.refresh', 'refresh-token', cookieSerializeOptions ); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.sig', 'g-7yXxxJfMmzsR7BqkJjguoUWsOqCTGz2AndxjJBrkw', cookieSerializeOptions ); }); it('should set single cookie and remove multiple cookie if exists', async () => { const provider = new ObjectCookiesProvider({ 'TestCookie.id': 'legacy-id-token', 'TestCookie.refresh': 'legacy-refresh-token', 'TestCookie.custom': 'legacy-custom-token', 'TestCookie.sig': 'legacy-signature' }); const cookies = new AuthCookies(provider, setAuthCookiesOptions); const requestCookies = { set: jest.fn(), delete: jest.fn() } as unknown as RequestCookies; await cookies.setAuthCookies(mockTokens, requestCookies); expect(requestCookies.delete).toHaveBeenCalledTimes(5); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.id'); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.refresh'); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.custom'); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.metadata'); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.sig'); expect(requestCookies.set).toHaveBeenCalledTimes(1); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4iLCJjdXN0b21fdG9rZW4iOiJjdXN0b20tdG9rZW4ifQ.ExxN2rNayg2XCR6WNeZmY8tAyc_qyiZ2YdzITRbQocs', cookieSerializeOptions ); }); it('should set single cookie and remove legacy multiple cookie if exists', async () => { const provider = new ObjectCookiesProvider({ TestCookie: 'legacy-id-token:legacy-refresh-token', 'TestCookie.custom': 'legacy-custom-token', 'TestCookie.sig': 'legacy-signature' }); const cookies = new AuthCookies(provider, setAuthCookiesOptions); const requestCookies = { set: jest.fn(), delete: jest.fn() } as unknown as RequestCookies; await cookies.setAuthCookies(mockTokens, requestCookies); expect(requestCookies.delete).toHaveBeenCalledTimes(5); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.id'); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.refresh'); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.custom'); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.metadata'); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.sig'); expect(requestCookies.set).toHaveBeenCalledTimes(1); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4iLCJjdXN0b21fdG9rZW4iOiJjdXN0b20tdG9rZW4ifQ.ExxN2rNayg2XCR6WNeZmY8tAyc_qyiZ2YdzITRbQocs', cookieSerializeOptions ); }); it('should set single cookie and remove custom cookie if exists', async () => { const provider = new ObjectCookiesProvider({ 'TestCookie.id': 'legacy-id-token', 'TestCookie.custom': 'legacy-custom-token' }); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableCustomToken: false }); const requestCookies = { set: jest.fn(), delete: jest.fn() } as unknown as RequestCookies; await cookies.setAuthCookies(mockTokens, requestCookies); expect(requestCookies.delete).toHaveBeenCalledTimes(1); expect(requestCookies.delete).toHaveBeenCalledWith('TestCookie.custom'); expect(requestCookies.set).toHaveBeenCalledTimes(1); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4ifQ.Zf81UFf9nyW96_0M0eymGmfPABKYben_nGMc1_9l86k', cookieSerializeOptions ); }); it('should set single cookie without custom token', async () => { const provider = new ObjectCookiesProvider({}); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableCustomToken: false }); const requestCookies = { set: jest.fn(), delete: jest.fn() } as unknown as RequestCookies; await cookies.setAuthCookies(mockTokens, requestCookies); expect(requestCookies.set).toHaveBeenCalledTimes(1); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4ifQ.Zf81UFf9nyW96_0M0eymGmfPABKYben_nGMc1_9l86k', cookieSerializeOptions ); }); it('should set multiple cookies without custom token', async () => { const provider = new ObjectCookiesProvider({}); const cookies = new AuthCookies(provider, { ...setAuthCookiesOptions, enableMultipleCookies: true, enableCustomToken: false }); const requestCookies = { set: jest.fn(), delete: jest.fn() } as unknown as RequestCookies; await cookies.setAuthCookies(mockTokens, requestCookies); expect(requestCookies.set).toHaveBeenCalledTimes(3); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.id', 'id-token', cookieSerializeOptions ); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.refresh', 'refresh-token', cookieSerializeOptions ); expect(requestCookies.set).toHaveBeenCalledWith( 'TestCookie.sig', 'g-7yXxxJfMmzsR7BqkJjguoUWsOqCTGz2AndxjJBrkw', cookieSerializeOptions ); }); }); }); ================================================ FILE: src/next/cookies/AuthCookies.ts ================================================ import type {NextApiResponse} from 'next'; import type {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import {ParsedCookies} from '../../auth/custom-token/index.js'; import {Cookie, CookieBuilder} from './builder/CookieBuilder.js'; import {CookieBuilderFactory} from './builder/CookieBuilderFactory.js'; import {MultipleCookieExpiration} from './expiration/MultipleCookieExpiration.js'; import {SingleCookieExpiration} from './expiration/SingleCookieExpiration.js'; import {CookieParserFactory} from './parser/CookieParserFactory.js'; import {CookiesProvider} from './parser/CookiesProvider.js'; import {CookieSetter} from './setter/CookieSetter.js'; import {CookieSetterFactory} from './setter/CookieSetterFactory.js'; import {NextApiResponseCookieSetter} from './setter/NextApiResponseHeadersCookieSetter.js'; import {SetAuthCookiesOptions} from './types.js'; export class AuthCookies { private builder: CookieBuilder; private targetCookies: Cookie[] | null = null; constructor( private provider: CookiesProvider, private options: SetAuthCookiesOptions ) { this.builder = CookieBuilderFactory.fromOptions(options); } private shouldClearMultipleCookies() { return ( !this.options.enableMultipleCookies && (CookieParserFactory.hasMultipleCookies( this.provider, this.options.cookieName ) || CookieParserFactory.hasLegacyMultipleCookies( this.provider, this.options.cookieName )) ); } private shouldClearCustomTokenCookie() { return ( !this.options.enableCustomToken && CookieParserFactory.hasCustomTokenCookie( this.provider, this.options.cookieName ) ); } private shouldClearSingleCookie() { const hasSingleCookie = Boolean(this.provider.get(this.options.cookieName)); return this.options.enableMultipleCookies && hasSingleCookie; } private clearUnusedCookies(setter: CookieSetter) { if (this.shouldClearMultipleCookies()) { const expiration = new MultipleCookieExpiration( this.options.cookieName, setter ); expiration.expireCookies(this.options.cookieSerializeOptions); } else if (this.shouldClearCustomTokenCookie()) { const expiration = new MultipleCookieExpiration( this.options.cookieName, setter ); expiration.expireCustomCookie(this.options.cookieSerializeOptions); } if (this.shouldClearSingleCookie()) { const expiration = new SingleCookieExpiration( this.options.cookieName, setter ); expiration.expireCookies(this.options.cookieSerializeOptions); } } private async getCookies(value: ParsedCookies): Promise { const targetValue = this.options.enableCustomToken ? value : { idToken: value.idToken, refreshToken: value.refreshToken, metadata: value.metadata }; if (this.targetCookies) { return this.targetCookies; } return (this.targetCookies = await this.builder.buildCookies(targetValue)); } public async setAuthCookies( value: ParsedCookies, requestCookies: RequestCookies | ReadonlyRequestCookies ) { const cookies = await this.getCookies(value); const setter = CookieSetterFactory.fromRequestCookies(requestCookies); this.clearUnusedCookies(setter); setter.setCookies(cookies, this.options.cookieSerializeOptions); } public async setAuthHeaders( value: ParsedCookies, headers: Headers ) { const cookies = await this.getCookies(value); const setter = CookieSetterFactory.fromHeaders(headers); this.clearUnusedCookies(setter); setter.setCookies(cookies, this.options.cookieSerializeOptions); } public async setAuthNextApiResponseHeaders( value: ParsedCookies, response: NextApiResponse ) { const cookies = await this.getCookies(value); const setter = new NextApiResponseCookieSetter(response); this.clearUnusedCookies(setter); setter.setCookies(cookies, this.options.cookieSerializeOptions); } } ================================================ FILE: src/next/cookies/builder/CookieBuilder.ts ================================================ import {ParsedCookies} from '../../../auth/custom-token/index.js'; export interface Cookie { name: string; value: string; } export interface CookieBuilder { buildCookies(tokens: ParsedCookies): Promise; } ================================================ FILE: src/next/cookies/builder/CookieBuilderFactory.ts ================================================ import {SetAuthCookiesOptions} from '../types.js'; import {MultipleCookieBuilder} from './MultipleCookieBuilder.js'; import {SingleCookieBuilder} from './SingleCookieBuilder.js'; export class CookieBuilderFactory { static fromOptions( options: SetAuthCookiesOptions ) { if (options.enableMultipleCookies) { return new MultipleCookieBuilder( options.cookieName, options.cookieSignatureKeys ); } return new SingleCookieBuilder( options.cookieName, options.cookieSignatureKeys ); } } ================================================ FILE: src/next/cookies/builder/MultipleCookieBuilder.test.ts ================================================ import {MultipleCookieBuilder} from './MultipleCookieBuilder.js'; describe('MultipleCookieBuilder', () => { it('should create four cookies, representing all tokens and signature', async () => { const builder = new MultipleCookieBuilder('TestCookie', ['secret']); expect( await builder.buildCookies({ idToken: 'id-token', refreshToken: 'refresh-token', customToken: 'custom-token', metadata: {} }) ).toEqual([ { name: 'TestCookie.id', value: 'id-token' }, { name: 'TestCookie.refresh', value: 'refresh-token' }, { name: 'TestCookie.custom', value: 'custom-token' }, { name: 'TestCookie.sig', value: 'QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0' } ]); }); it('should create cookies with metadata and signature', async () => { const builder = new MultipleCookieBuilder('TestCookie', ['secret']); expect( await builder.buildCookies({ idToken: 'id-token', refreshToken: 'refresh-token', customToken: 'custom-token', metadata: {foo: 'bar'} }) ).toEqual([ { name: 'TestCookie.id', value: 'id-token' }, { name: 'TestCookie.refresh', value: 'refresh-token' }, { name: 'TestCookie.custom', value: 'custom-token' }, { name: 'TestCookie.metadata', value: 'eyJmb28iOiJiYXIifQ' }, { name: 'TestCookie.sig', value: '4LS2ty2sdecHjVR9dSSMO8jY0gvITmMgJH1stLFKVlA' } ]); }); it('should skip custom token if not provided', async () => { const builder = new MultipleCookieBuilder('TestCookie', ['secret']); expect( await builder.buildCookies({ idToken: 'id-token', refreshToken: 'refresh-token', metadata: {} }) ).toEqual([ { name: 'TestCookie.id', value: 'id-token' }, { name: 'TestCookie.refresh', value: 'refresh-token' }, { name: 'TestCookie.sig', value: 'g-7yXxxJfMmzsR7BqkJjguoUWsOqCTGz2AndxjJBrkw' } ]); }); }); ================================================ FILE: src/next/cookies/builder/MultipleCookieBuilder.ts ================================================ import {base64url} from 'jose'; import {ParsedCookies} from '../../../auth/custom-token/index.js'; import {RotatingCredential} from '../../../auth/rotating-credential.js'; import {Cookie, CookieBuilder} from './CookieBuilder.js'; export class MultipleCookieBuilder implements CookieBuilder { private credential: RotatingCredential; constructor( private cookieName: string, signatureKeys: string[] ) { this.credential = new RotatingCredential(signatureKeys); } public async buildCookies(value: ParsedCookies): Promise { const signature = await this.credential.createSignature(value); const result: Cookie[] = [ { name: `${this.cookieName}.id`, value: value.idToken }, { name: `${this.cookieName}.refresh`, value: value.refreshToken } ]; if (value.customToken) { result.push({ name: `${this.cookieName}.custom`, value: value.customToken }); } if (value.metadata && Object.keys(value.metadata).length > 0) { result.push({ name: `${this.cookieName}.metadata`, value: base64url.encode(JSON.stringify(value.metadata)) }); } result.push({ name: `${this.cookieName}.sig`, value: signature }); return result; } } ================================================ FILE: src/next/cookies/builder/SingleCookieBuilder.test.ts ================================================ import {SingleCookieBuilder} from './SingleCookieBuilder.js'; describe('SingleCookieBuilder', () => { it('should create a signed jwt token cookie based on all tokens', async () => { const builder = new SingleCookieBuilder('TestCookie', ['secret']); expect( await builder.buildCookies({ idToken: 'id-token', refreshToken: 'refresh-token', customToken: 'custom-token', metadata: {} }) ).toEqual([ { name: 'TestCookie', value: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4iLCJjdXN0b21fdG9rZW4iOiJjdXN0b20tdG9rZW4ifQ.ExxN2rNayg2XCR6WNeZmY8tAyc_qyiZ2YdzITRbQocs' } ]); }); it('should create a signed jwt token cookie based on all tokens and metadata', async () => { const builder = new SingleCookieBuilder('TestCookie', ['secret']); expect( await builder.buildCookies({ idToken: 'id-token', refreshToken: 'refresh-token', customToken: 'custom-token', metadata: {foo: 'bar'} }) ).toEqual([ { name: 'TestCookie', value: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4iLCJjdXN0b21fdG9rZW4iOiJjdXN0b20tdG9rZW4iLCJtZXRhZGF0YSI6eyJmb28iOiJiYXIifX0.tm6HY-N7NZMq9ipez53--tXfixsHhcF59hj9s13iEII' } ]); }); }); ================================================ FILE: src/next/cookies/builder/SingleCookieBuilder.ts ================================================ import { CustomJWTPayload, ParsedCookies } from '../../../auth/custom-token/index.js'; import {RotatingCredential} from '../../../auth/rotating-credential.js'; import {Cookie, CookieBuilder} from './CookieBuilder.js'; export class SingleCookieBuilder implements CookieBuilder { private credential: RotatingCredential; constructor( private cookieName: string, signatureKeys: string[] ) { this.credential = new RotatingCredential(signatureKeys); } public async buildCookies(value: ParsedCookies): Promise { const payload: CustomJWTPayload = { id_token: value.idToken, refresh_token: value.refreshToken, custom_token: value.customToken }; if (value.metadata && Object.keys(value.metadata).length > 0) { payload['metadata'] = value.metadata; } const jwtToken = await this.credential.sign(payload); return [ { name: this.cookieName, value: jwtToken } ]; } } ================================================ FILE: src/next/cookies/expiration/CombinedCookieExpiration.ts ================================================ import type {CookieSerializeOptions} from 'cookie'; import {CookieExpiration} from './CookieExpiration.js'; import {MultipleCookieExpiration} from './MultipleCookieExpiration.js'; import {SingleCookieExpiration} from './SingleCookieExpiration.js'; export class CombinedCookieExpiration implements CookieExpiration { constructor( private multi: MultipleCookieExpiration, private single: SingleCookieExpiration ) {} expireCookies(options: CookieSerializeOptions): void { this.multi.expireCookies(options); this.single.expireCookies(options); } } ================================================ FILE: src/next/cookies/expiration/CookieExpiration.ts ================================================ import type {CookieSerializeOptions} from 'cookie'; export interface CookieExpiration { expireCookies(options: CookieSerializeOptions): void; } export function getExpiredSerializeOptions(options: CookieSerializeOptions) { const cookieOptions = { ...options, expires: new Date(0) }; delete cookieOptions['maxAge']; return cookieOptions; } ================================================ FILE: src/next/cookies/expiration/CookieExpirationFactory.test.ts ================================================ import {Cookie} from '../builder/CookieBuilder.js'; import {RequestCookiesProvider} from '../parser/RequestCookiesProvider.js'; import {CookieExpirationFactory} from './CookieExpirationFactory.js'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; const cookieName = 'TestCookie'; const cookieSerializeOptions = { path: '/', httpOnly: true, secure: true, sameSite: 'lax' as const, maxAge: 12 * 60 * 60 * 24, expires: new Date(1727373870 * 1000) }; const testCookies: Cookie[] = [ { name: 'TestCookie.id', value: 'id-token' }, { name: 'TestCookie.refresh', value: 'refresh-token' }, { name: 'TestCookie.custom', value: 'custom-token' }, { name: 'TestCookie.sig', value: 'QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0' } ]; const legacyTestCookies: Cookie[] = [ { name: 'TestCookie', value: 'id-token:refresh-token' }, { name: 'TestCookie.custom', value: 'custom-token' }, { name: 'TestCookie.sig', value: 'QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0' } ]; function getTestCookie(name: string) { return testCookies.find((it) => it.name === name); } function getLegacyTestCookie(name: string) { return legacyTestCookies.find((it) => it.name === name); } function getSingleCookie(name: string) { if (name === cookieName) { return { name: cookieName, value: 'single-cookie' }; } return undefined; } describe('CookieExpirationFactory', () => { it('should remove a single cookie', () => { const headers = {append: jest.fn()} as unknown as Headers; const cookies = {get: jest.fn()} as unknown as RequestCookies; const expiration = CookieExpirationFactory.fromHeaders( headers, new RequestCookiesProvider(cookies), cookieName ); expiration.expireCookies(cookieSerializeOptions); expect(headers.append).toHaveBeenCalledTimes(1); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); }); it('should remove multiple cookies', () => { const headers = {append: jest.fn()} as unknown as Headers; const cookies = {get: jest.fn(getTestCookie)} as unknown as RequestCookies; const expiration = CookieExpirationFactory.fromHeaders( headers, new RequestCookiesProvider(cookies), cookieName ); expiration.expireCookies(cookieSerializeOptions); expect(headers.append).toHaveBeenCalledTimes(5); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.refresh=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.custom=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.metadata=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.sig=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); }); it('should remove multiple and single cookies when there are both', () => { const headers = {append: jest.fn()} as unknown as Headers; const cookies = { get: jest.fn((name) => { return getSingleCookie(name) ?? getTestCookie(name); }) } as unknown as RequestCookies; const expiration = CookieExpirationFactory.fromHeaders( headers, new RequestCookiesProvider(cookies), cookieName ); expiration.expireCookies(cookieSerializeOptions); expect(headers.append).toHaveBeenCalledTimes(6); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.refresh=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.custom=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.metadata=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.sig=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); }); it('should remove multiple and single cookies when there are legacy cookies', () => { const headers = {append: jest.fn()} as unknown as Headers; const cookies = { get: jest.fn((name) => { return getLegacyTestCookie(name); }) } as unknown as RequestCookies; const expiration = CookieExpirationFactory.fromHeaders( headers, new RequestCookiesProvider(cookies), cookieName ); expiration.expireCookies(cookieSerializeOptions); expect(headers.append).toHaveBeenCalledTimes(6); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.refresh=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.custom=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.metadata=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); expect(headers.append).toHaveBeenCalledWith( 'Set-Cookie', 'TestCookie.sig=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' ); }); }); ================================================ FILE: src/next/cookies/expiration/CookieExpirationFactory.ts ================================================ import {CookieParserFactory} from '../parser/CookieParserFactory.js'; import {CookiesProvider} from '../parser/CookiesProvider.js'; import {CookieSetter} from '../setter/CookieSetter.js'; import {HeadersCookieSetter} from '../setter/HeadersCookieSetter.js'; import {CombinedCookieExpiration} from './CombinedCookieExpiration.js'; import {MultipleCookieExpiration} from './MultipleCookieExpiration.js'; import {SingleCookieExpiration} from './SingleCookieExpiration.js'; export class CookieExpirationFactory { private static fromSetter( setter: CookieSetter, provider: CookiesProvider, cookieName: string ) { const singleCookie = provider.get(cookieName); const hasEnabledMultipleCookies = CookieParserFactory.hasMultipleCookies( provider, cookieName ); const hasEnabledLegacyMultipleCookies = CookieParserFactory.hasLegacyMultipleCookies(provider, cookieName); if ( singleCookie && (hasEnabledMultipleCookies || hasEnabledLegacyMultipleCookies) ) { return new CombinedCookieExpiration( new MultipleCookieExpiration(cookieName, setter), new SingleCookieExpiration(cookieName, setter) ); } if (hasEnabledMultipleCookies) { return new MultipleCookieExpiration(cookieName, setter); } return new SingleCookieExpiration(cookieName, setter); } static fromHeaders( headers: Headers, provider: CookiesProvider, cookieName: string ) { const setter = new HeadersCookieSetter(headers); return CookieExpirationFactory.fromSetter(setter, provider, cookieName); } } ================================================ FILE: src/next/cookies/expiration/MultipleCookieExpiration.test.ts ================================================ import {CookieSetter} from '../setter/CookieSetter.js'; import {MultipleCookieExpiration} from './MultipleCookieExpiration.js'; const mockSetter: CookieSetter = { setCookies: jest.fn() }; const cookieSerializeOptions = { path: '/', httpOnly: true, secure: true, sameSite: 'lax' as const, maxAge: 12 * 60 * 60 * 24, expires: new Date(1727373870 * 1000) }; describe('MultipleCookieExpiration', () => { beforeEach(() => { jest.resetAllMocks(); }); it('should remove multiple cookies', () => { const expiration = new MultipleCookieExpiration('TestCookie', mockSetter); expiration.expireCookies(cookieSerializeOptions); expect(mockSetter.setCookies).toHaveBeenCalledWith( [ {name: 'TestCookie.id', value: ''}, {name: 'TestCookie.refresh', value: ''}, {name: 'TestCookie.custom', value: ''}, {name: 'TestCookie.metadata', value: ''}, {name: 'TestCookie.sig', value: ''} ], { path: '/', httpOnly: true, secure: true, sameSite: 'lax' as const, expires: new Date(0) } ); }); it('should remove custom cookie', () => { const expiration = new MultipleCookieExpiration('TestCookie', mockSetter); expiration.expireCustomCookie(cookieSerializeOptions); expect(mockSetter.setCookies).toHaveBeenCalledWith( [{name: 'TestCookie.custom', value: ''}], { path: '/', httpOnly: true, secure: true, sameSite: 'lax' as const, expires: new Date(0) } ); }); }); ================================================ FILE: src/next/cookies/expiration/MultipleCookieExpiration.ts ================================================ import type {CookieSerializeOptions} from 'cookie'; import {Cookie} from '../builder/CookieBuilder.js'; import {CookieSetter} from '../setter/CookieSetter.js'; import { CookieExpiration, getExpiredSerializeOptions } from './CookieExpiration.js'; export class MultipleCookieExpiration implements CookieExpiration { public constructor( private cookieName: string, private setter: CookieSetter ) {} expireCustomCookie(options: CookieSerializeOptions) { const cookies: Cookie[] = [ { name: `${this.cookieName}.custom`, value: '' } ]; this.setter.setCookies(cookies, getExpiredSerializeOptions(options)); } expireCookies(options: CookieSerializeOptions): void { const cookies: Cookie[] = [ { name: `${this.cookieName}.id`, value: '' }, { name: `${this.cookieName}.refresh`, value: '' }, { name: `${this.cookieName}.custom`, value: '' }, { name: `${this.cookieName}.metadata`, value: '' }, { name: `${this.cookieName}.sig`, value: '' } ]; this.setter.setCookies(cookies, getExpiredSerializeOptions(options)); } } ================================================ FILE: src/next/cookies/expiration/SingleCookieExpiration.test.ts ================================================ import {CookieSetter} from '../setter/CookieSetter.js'; import {SingleCookieExpiration} from './SingleCookieExpiration.js'; const mockSetter: CookieSetter = { setCookies: jest.fn() }; const cookieSerializeOptions = { path: '/', httpOnly: true, secure: true, sameSite: 'lax' as const, maxAge: 12 * 60 * 60 * 24, expires: new Date(1727373870 * 1000) }; describe('SingleCookieExpiration', () => { beforeEach(() => { jest.resetAllMocks(); }); it('should remove single cookie', () => { const expiration = new SingleCookieExpiration('TestCookie', mockSetter); expiration.expireCookies(cookieSerializeOptions); expect(mockSetter.setCookies).toHaveBeenCalledWith( [{name: 'TestCookie', value: ''}], { path: '/', httpOnly: true, secure: true, sameSite: 'lax' as const, expires: new Date(0) } ); }); }); ================================================ FILE: src/next/cookies/expiration/SingleCookieExpiration.ts ================================================ import type {CookieSerializeOptions} from 'cookie'; import {Cookie} from '../builder/CookieBuilder.js'; import {CookieSetter} from '../setter/CookieSetter.js'; import { CookieExpiration, getExpiredSerializeOptions } from './CookieExpiration.js'; export class SingleCookieExpiration implements CookieExpiration { public constructor( private cookieName: string, private setter: CookieSetter ) {} expireCookies(options: CookieSerializeOptions): void { const cookies: Cookie[] = [ { name: this.cookieName, value: '' } ]; this.setter.setCookies(cookies, getExpiredSerializeOptions(options)); } } ================================================ FILE: src/next/cookies/index.test.ts ================================================ import type {NextRequest} from 'next/server'; import {NextResponse} from 'next/server'; import { SetAuthCookiesOptions, appendAuthCookies, refreshCredentials, setAuthCookies } from '../cookies/index.js'; // Suppress "Property 'headers' does not exist on type NextRequest/NextResponse" error declare module 'next/server' { export interface NextRequest { headers: Headers; } export interface NextResponse { headers: Headers; } } jest.mock('../../auth/index.js', () => ({ getFirebaseAuth: () => ({ handleTokenRefresh: () => ({ idToken: 'TEST_ID_TOKEN', refreshToken: 'TEST_REFRESH_TOKEN', customToken: 'TEST_CUSTOM_TOKEN' }) }) })); const secret = 'very-secure-secret'; const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6Ik1PQ0tfSURfVE9LRU4iLCJjdXN0b21fdG9rZW4iOiJNT0NLX0NVU1RPTV9UT0tFTiIsInJlZnJlc2hfdG9rZW4iOiJNT0NLX1JFRlJFU0hfVE9LRU4ifQ.K5jwTcAlfffzuM2_WaKJ93QwgqeCpWjg7TMx1lulSO4'; const refreshedJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6IlRFU1RfSURfVE9LRU4iLCJyZWZyZXNoX3Rva2VuIjoiVEVTVF9SRUZSRVNIX1RPS0VOIiwiY3VzdG9tX3Rva2VuIjoiVEVTVF9DVVNUT01fVE9LRU4ifQ.2tjn-__AKP3J7w9vIDuFDFkYmPzpuGpWvHvBFksMh5E'; const MOCK_OPTIONS: SetAuthCookiesOptions = { cookieName: 'TestCookie', cookieSignatureKeys: [secret], cookieSerializeOptions: {maxAge: 123, path: '/test-path', sameSite: 'lax'}, apiKey: 'API_KEY', authorizationHeaderName: 'Next-Authorization', enableCustomToken: true }; describe('cookies', () => { let MOCK_REQUEST: jest.Mocked; beforeEach(() => { const mockHeaders = new Headers(); mockHeaders.set('Cookie', `TestCookie=${jwt}`); MOCK_REQUEST = { cookies: { has: (key: string) => { if (key === 'TestCookie') { return true; } return false; }, get: jest.fn((key: string) => { if (key === 'TestCookie') { return {value: jwt}; } return undefined; }), set: jest.fn(), delete: jest.fn() }, headers: mockHeaders } as unknown as jest.Mocked; }); const customValue = { idToken: 'MOCK_ID_TOKEN', refreshToken: 'MOCK_REFRESH_TOKEN', customToken: 'MOCK_CUSTOM_TOKEN', metadata: {} }; it('appends fresh cookie headers to the response', async () => { const MOCK_RESPONSE = { headers: { append: jest.fn() } } as unknown as jest.Mocked; const result = await refreshCredentials( MOCK_REQUEST, MOCK_OPTIONS, () => MOCK_RESPONSE ); expect(MOCK_REQUEST.cookies.set).toHaveBeenCalledWith( 'TestCookie', refreshedJwt, {maxAge: 123, path: '/test-path', sameSite: 'lax'} ); expect(MOCK_RESPONSE.headers.append).toHaveBeenCalledWith( 'Set-Cookie', `TestCookie=${refreshedJwt}; Max-Age=123; Path=/test-path; SameSite=Lax` ); expect(result).toBe(MOCK_RESPONSE); }); it('appends fresh cookie headers without custom token to the response', async () => { const MOCK_RESPONSE = { headers: { append: jest.fn() } } as unknown as jest.Mocked; const result = await refreshCredentials( MOCK_REQUEST, {...MOCK_OPTIONS, enableCustomToken: false}, () => MOCK_RESPONSE ); expect(MOCK_REQUEST.cookies.set).toHaveBeenCalledWith( 'TestCookie', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6IlRFU1RfSURfVE9LRU4iLCJyZWZyZXNoX3Rva2VuIjoiVEVTVF9SRUZSRVNIX1RPS0VOIn0.Na_3Et62K3bs5WcTnvh6sEW_pnoiFw022gKXDCkuW-s', {maxAge: 123, path: '/test-path', sameSite: 'lax'} ); expect(MOCK_RESPONSE.headers.append).toHaveBeenCalledWith( 'Set-Cookie', `TestCookie=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6IlRFU1RfSURfVE9LRU4iLCJyZWZyZXNoX3Rva2VuIjoiVEVTVF9SRUZSRVNIX1RPS0VOIn0.Na_3Et62K3bs5WcTnvh6sEW_pnoiFw022gKXDCkuW-s; Max-Age=123; Path=/test-path; SameSite=Lax` ); expect(result).toBe(MOCK_RESPONSE); }); it('accepts async response factory function', async () => { const MOCK_RESPONSE = { headers: { append: jest.fn() } } as unknown as jest.Mocked; const result = await refreshCredentials(MOCK_REQUEST, MOCK_OPTIONS, () => Promise.resolve(MOCK_RESPONSE) ); expect(MOCK_RESPONSE.headers.append).toHaveBeenCalledWith( 'Set-Cookie', `TestCookie=${refreshedJwt}; Max-Age=123; Path=/test-path; SameSite=Lax` ); expect(result).toBe(MOCK_RESPONSE); }); it('generates multiple cookies', async () => { const MOCK_RESPONSE = { headers: { append: jest.fn() } } as unknown as jest.Mocked; const result = await refreshCredentials( MOCK_REQUEST, {...MOCK_OPTIONS, enableMultipleCookies: true}, () => Promise.resolve(MOCK_RESPONSE) ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 1, 'Set-Cookie', 'TestCookie=; Path=/test-path; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 2, 'Set-Cookie', 'TestCookie.id=TEST_ID_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 3, 'Set-Cookie', 'TestCookie.refresh=TEST_REFRESH_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 4, 'Set-Cookie', 'TestCookie.custom=TEST_CUSTOM_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 5, 'Set-Cookie', 'TestCookie.sig=MqBNRBcWwj7xL948-Yy89kj5dwPEf7fTACNx93rOFX4; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(result).toBe(MOCK_RESPONSE); }); it('generates multiple cookies with metadata', async () => { const MOCK_RESPONSE = { headers: { append: jest.fn() } } as unknown as jest.Mocked; const result = await refreshCredentials( MOCK_REQUEST, { ...MOCK_OPTIONS, enableMultipleCookies: true, getMetadata: () => Promise.resolve({foo: 'bar'}) }, () => Promise.resolve(MOCK_RESPONSE) ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 1, 'Set-Cookie', 'TestCookie=; Path=/test-path; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 2, 'Set-Cookie', 'TestCookie.id=TEST_ID_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 3, 'Set-Cookie', 'TestCookie.refresh=TEST_REFRESH_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 4, 'Set-Cookie', 'TestCookie.custom=TEST_CUSTOM_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 5, 'Set-Cookie', 'TestCookie.metadata=eyJmb28iOiJiYXIifQ; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 6, 'Set-Cookie', 'TestCookie.sig=YICw1pt9h0SbHDrQOZYCoyapBd5Y5haBs7nnXDl4kGE; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(result).toBe(MOCK_RESPONSE); }); it('appends multiple cookie headers', async () => { const MOCK_RESPONSE = { headers: { append: jest.fn() } } as unknown as jest.Mocked; const mockHeaders = new Headers(); await appendAuthCookies(mockHeaders, MOCK_RESPONSE, customValue, { ...MOCK_OPTIONS, enableMultipleCookies: true }); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 1, 'Set-Cookie', 'TestCookie.id=MOCK_ID_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 2, 'Set-Cookie', 'TestCookie.refresh=MOCK_REFRESH_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 3, 'Set-Cookie', 'TestCookie.custom=MOCK_CUSTOM_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 4, 'Set-Cookie', 'TestCookie.sig=AuSOlUSJENTLtShQpjf7SMRiPY4aILyFNmjr7Tc3Fig; Max-Age=123; Path=/test-path; SameSite=Lax' ); }); it('appends multiple cookie headers with metadata', async () => { const MOCK_RESPONSE = { headers: { append: jest.fn() } } as unknown as jest.Mocked; const mockHeaders = new Headers(); await appendAuthCookies( mockHeaders, MOCK_RESPONSE, {...customValue, metadata: {foo: 'bar'}}, { ...MOCK_OPTIONS, enableMultipleCookies: true } ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 1, 'Set-Cookie', 'TestCookie.id=MOCK_ID_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 2, 'Set-Cookie', 'TestCookie.refresh=MOCK_REFRESH_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 3, 'Set-Cookie', 'TestCookie.custom=MOCK_CUSTOM_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 4, 'Set-Cookie', 'TestCookie.metadata=eyJmb28iOiJiYXIifQ; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 5, 'Set-Cookie', 'TestCookie.sig=1jhc7bzIu7TiqZh5X55nJ-_ZfSWhTb7Ui8W7SdGqttA; Max-Age=123; Path=/test-path; SameSite=Lax' ); }); it('skips custom token in multiple cookie headers', async () => { const MOCK_RESPONSE = { headers: { append: jest.fn() } } as unknown as jest.Mocked; const mockHeaders = new Headers(); await appendAuthCookies(mockHeaders, MOCK_RESPONSE, customValue, { ...MOCK_OPTIONS, enableMultipleCookies: true, enableCustomToken: false }); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 1, 'Set-Cookie', 'TestCookie.id=MOCK_ID_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 2, 'Set-Cookie', 'TestCookie.refresh=MOCK_REFRESH_TOKEN; Max-Age=123; Path=/test-path; SameSite=Lax' ); expect(MOCK_RESPONSE.headers.append).toHaveBeenNthCalledWith( 3, 'Set-Cookie', 'TestCookie.sig=kD-gd5CZhwndsyIvECTkfFsumUjj5UE1UpuxlxX5HWk; Max-Age=123; Path=/test-path; SameSite=Lax' ); }); it('appends custom auth headers', async () => { const MOCK_RESPONSE = { headers: { append: jest.fn(), get: jest.fn() } } as unknown as jest.Mocked; await setAuthCookies(MOCK_RESPONSE.headers, MOCK_OPTIONS); expect(MOCK_RESPONSE.headers.get).toHaveBeenCalledWith( 'Next-Authorization' ); }); }); ================================================ FILE: src/next/cookies/index.ts ================================================ import {type CookieSerializeOptions} from 'cookie'; import type {IncomingHttpHeaders} from 'http'; import type {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import type {NextRequest} from 'next/server'; import {NextResponse} from 'next/server'; import {ParsedCookies, VerifiedCookies} from '../../auth/custom-token/index.js'; import {getFirebaseAuth, TokenSet} from '../../auth/index.js'; import {debug} from '../../debug/index.js'; import {getCookiesTokens, getRequestCookiesTokens} from '../tokens.js'; import {getReferer} from '../utils.js'; import {AuthCookies} from './AuthCookies.js'; import {RequestCookiesProvider} from './parser/RequestCookiesProvider.js'; import {CookieExpirationFactory} from './expiration/CookieExpirationFactory.js'; import {CookiesObject, SetAuthCookiesOptions} from './types.js'; import {CookieRemoverFactory} from './remover/CookieRemoverFactory.js'; import {getMetadataInternal} from '../metadata.js'; import {mapJwtPayloadToDecodedIdToken} from '../../auth/utils.js'; import {decodeJwt} from 'jose'; export async function appendAuthCookies( headers: Headers, response: NextResponse, value: ParsedCookies, options: SetAuthCookiesOptions ) { debug('Updating response headers with authenticated cookies'); const authCookies = new AuthCookies( RequestCookiesProvider.fromHeaders(headers), options ); await authCookies.setAuthHeaders(value, response.headers); } export async function setAuthCookies( headers: Headers, options: SetAuthCookiesOptions ): Promise { const {getCustomIdAndRefreshTokens} = getFirebaseAuth({ serviceAccount: options.serviceAccount, apiKey: options.apiKey, tenantId: options.tenantId }); const authHeader = options.authorizationHeaderName ?? 'Authorization'; const token = headers.get(authHeader)?.split(' ')[1] ?? ''; if (!token) { const response = new NextResponse( JSON.stringify({success: false, message: 'Missing token'}), { status: 400, headers: {'content-type': 'application/json'} } ); return response; } const appCheckToken = headers.get('X-Firebase-AppCheck') ?? undefined; const referer = getReferer(headers) ?? ''; const customTokens = await getCustomIdAndRefreshTokens(token, { appCheckToken, referer, dynamicCustomClaimsKeys: options.dynamicCustomClaimsKeys }); debug('Successfully generated custom tokens'); const decodedIdToken = mapJwtPayloadToDecodedIdToken( decodeJwt(customTokens.idToken) ); const metadata = await getMetadataInternal( {...customTokens, decodedIdToken}, options ); const response = new NextResponse(JSON.stringify({success: true}), { status: 200, headers: {'content-type': 'application/json'} }); await appendAuthCookies( headers, response, {...customTokens, metadata}, options ); return response; } export interface RemoveServerCookiesOptions { cookieName: string; } export function removeServerCookies( cookies: RequestCookies | ReadonlyRequestCookies, options: RemoveServerCookiesOptions ) { const remover = CookieRemoverFactory.fromRequestCookies( cookies, RequestCookiesProvider.fromRequestCookies(cookies), options.cookieName ); return remover.removeCookies(); } export interface RemoveAuthCookiesOptions { cookieName: string; cookieSerializeOptions: CookieSerializeOptions; } export function removeCookies( headers: Headers, response: NextResponse, options: RemoveAuthCookiesOptions ) { const expiration = CookieExpirationFactory.fromHeaders( response.headers, RequestCookiesProvider.fromHeaders(headers), options.cookieName ); return expiration.expireCookies(options.cookieSerializeOptions); } export function removeAuthCookies( headers: Headers, options: RemoveAuthCookiesOptions ): NextResponse { const response = new NextResponse(JSON.stringify({success: true}), { status: 200, headers: {'content-type': 'application/json'} }); removeCookies(headers, response, options); debug('Updating response with empty authentication cookie headers', { cookieName: options.cookieName }); return response; } export async function verifyApiCookies( cookies: CookiesObject, headers: IncomingHttpHeaders, options: SetAuthCookiesOptions ): Promise> { const tokens = await getCookiesTokens(cookies, options); const {verifyAndRefreshExpiredIdToken} = getFirebaseAuth({ serviceAccount: options.serviceAccount, apiKey: options.apiKey, tenantId: options.tenantId }); const verifyTokenResult = await verifyAndRefreshExpiredIdToken(tokens, { referer: headers.referer ?? '' }); const metadata = await getMetadataInternal( verifyTokenResult, options ); return {...verifyTokenResult, metadata}; } export async function verifyNextCookies( cookies: RequestCookies | ReadonlyRequestCookies, headers: Headers, options: SetAuthCookiesOptions ): Promise> { const {verifyAndRefreshExpiredIdToken} = getFirebaseAuth({ serviceAccount: options.serviceAccount, apiKey: options.apiKey, tenantId: options.tenantId, enableCustomToken: options.enableCustomToken }); const referer = getReferer(headers) ?? ''; const tokens = await getRequestCookiesTokens(cookies, options); const verifyTokenResult = await verifyAndRefreshExpiredIdToken(tokens, { referer, enableTokenRefreshOnExpiredKidHeader: options.enableTokenRefreshOnExpiredKidHeader }); const metadata = await getMetadataInternal( verifyTokenResult, options ); return {...verifyTokenResult, metadata}; } export async function refreshNextCookies( cookies: RequestCookies | ReadonlyRequestCookies, headers: Headers, options: SetAuthCookiesOptions ): Promise> { const {handleTokenRefresh} = getFirebaseAuth({ serviceAccount: options.serviceAccount, apiKey: options.apiKey, tenantId: options.tenantId }); const referer = getReferer(headers) ?? ''; const tokens = await getRequestCookiesTokens(cookies, options); const tokenRefreshResult = await handleTokenRefresh(tokens.refreshToken, { referer, enableCustomToken: options.enableCustomToken }); const metadata = await getMetadataInternal( tokenRefreshResult, options ); return { idToken: tokenRefreshResult.idToken, refreshToken: tokenRefreshResult.refreshToken, customToken: tokenRefreshResult.customToken, decodedIdToken: tokenRefreshResult.decodedIdToken, metadata }; } export async function refreshCredentials( request: NextRequest, options: SetAuthCookiesOptions, responseFactory: (options: { headers: Headers; tokens: TokenSet; metadata: Metadata; }) => NextResponse | Promise ): Promise { const value = await refreshNextCookies( request.cookies, request.headers, options ); const cookies = new AuthCookies( RequestCookiesProvider.fromHeaders(request.headers), options ); await cookies.setAuthCookies(value, request.cookies); const responseOrPromise = responseFactory({ headers: request.headers, tokens: { idToken: value.idToken, decodedIdToken: value.decodedIdToken, refreshToken: value.refreshToken, customToken: value.customToken }, metadata: value.metadata }); const response = responseOrPromise instanceof Promise ? await responseOrPromise : responseOrPromise; await cookies.setAuthHeaders(value, response.headers); return response; } export async function refreshNextResponseCookiesWithToken< Metadata extends object >( idToken: string, request: NextRequest, response: NextResponse, options: SetAuthCookiesOptions ): Promise { const appCheckToken = request.headers.get('X-Firebase-AppCheck') ?? undefined; const referer = getReferer(request.headers) ?? ''; const {getCustomIdAndRefreshTokens} = getFirebaseAuth({ serviceAccount: options.serviceAccount, apiKey: options.apiKey, tenantId: options.tenantId }); const customTokens = await getCustomIdAndRefreshTokens(idToken, { appCheckToken, referer, dynamicCustomClaimsKeys: options.dynamicCustomClaimsKeys }); const decodedIdToken = mapJwtPayloadToDecodedIdToken( decodeJwt(customTokens.idToken) ); const metadata = await getMetadataInternal( {...customTokens, decodedIdToken}, options ); await appendAuthCookies( request.headers, response, {...customTokens, metadata}, options ); return response; } export async function refreshCookiesWithIdToken( idToken: string, headers: Headers, cookies: RequestCookies | ReadonlyRequestCookies, options: SetAuthCookiesOptions ): Promise { const appCheckToken = headers.get('X-Firebase-AppCheck') ?? undefined; const referer = getReferer(headers) ?? ''; const {getCustomIdAndRefreshTokens} = getFirebaseAuth({ serviceAccount: options.serviceAccount, apiKey: options.apiKey, tenantId: options.tenantId }); const customTokens = await getCustomIdAndRefreshTokens(idToken, { appCheckToken, referer, dynamicCustomClaimsKeys: options.dynamicCustomClaimsKeys }); const decodedIdToken = mapJwtPayloadToDecodedIdToken( decodeJwt(customTokens.idToken) ); const metadata = await getMetadataInternal( {...customTokens, decodedIdToken}, options ); const authCookies = new AuthCookies( RequestCookiesProvider.fromHeaders(headers), options ); await authCookies.setAuthCookies({...customTokens, metadata}, cookies); } export async function refreshNextResponseCookies( request: NextRequest, response: NextResponse, options: SetAuthCookiesOptions ): Promise { const customTokens = await refreshNextCookies( request.cookies, request.headers, options ); await appendAuthCookies(request.headers, response, customTokens, options); return response; } export async function refreshServerCookies( cookies: RequestCookies | ReadonlyRequestCookies, headers: Headers, options: SetAuthCookiesOptions ): Promise { const customTokens = await refreshNextCookies(cookies, headers, options); const authCookies = new AuthCookies( RequestCookiesProvider.fromHeaders(headers), options ); await authCookies.setAuthCookies(customTokens, cookies); await authCookies.setAuthHeaders(customTokens, headers); } export * from './types.js'; ================================================ FILE: src/next/cookies/parser/CookieParser.ts ================================================ import {ParsedCookies} from '../../../auth/custom-token/index.js'; export interface CookieParser { parseCookies(): Promise>; } ================================================ FILE: src/next/cookies/parser/CookieParserFactory.test.ts ================================================ import {InvalidTokenError, InvalidTokenReason} from '../../../auth/error.ts'; import {Cookie} from '../builder/CookieBuilder.js'; import {GetCookiesTokensOptions} from '../types.ts'; import {CookieParserFactory} from './CookieParserFactory.js'; import {MultipleCookiesParser} from './MultipleCookiesParser.ts'; import {SingleCookieParser} from './SingleCookieParser.ts'; const testCookie = { name: 'TestCookie', value: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4iLCJjdXN0b21fdG9rZW4iOiJjdXN0b20tdG9rZW4ifQ.ExxN2rNayg2XCR6WNeZmY8tAyc_qyiZ2YdzITRbQocs' }; const testCookieHeader = toHeader(testCookie); function toHeader(cookie: Cookie): string { return `${cookie.name}=${cookie.value}`; } const testCookies: Cookie[] = [ { name: 'TestCookie.id', value: 'id-token' }, { name: 'TestCookie.refresh', value: 'refresh-token' }, { name: 'TestCookie.custom', value: 'custom-token' }, { name: 'TestCookie.sig', value: 'QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0' } ]; const testCookiesHeader = testCookies.map(toHeader).join(';'); const legacyTestCookies: Cookie[] = [ { name: 'TestCookie', value: 'id-token:refresh-token' }, { name: 'TestCookie.custom', value: 'custom-token' }, { name: 'TestCookie.sig', value: 'QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0' } ]; const legacyCookiesHeader = legacyTestCookies.map(toHeader).join(';'); const testCookiesObj = testCookies.reduce( (acc, cookie) => ({ ...acc, [cookie.name]: cookie.value }), { [testCookie.name]: testCookie.value } ); const legacyTestCookiesObj = legacyTestCookies.reduce( (acc, cookie) => ({ ...acc, [cookie.name]: cookie.value }), { [testCookie.name]: testCookie.value } ); const mockOptions = { cookieName: 'TestCookie', cookieSignatureKeys: ['secret'] } as unknown as GetCookiesTokensOptions; describe('CookieParserFactory', () => { describe('fromHeaders', () => { it('should create single cookie parser if request does not have multiple cookies', () => { const mockHeaders = new Headers(); mockHeaders.set('Cookie', testCookieHeader); const result = CookieParserFactory.fromHeaders(mockHeaders, mockOptions); expect(result).toBeInstanceOf(SingleCookieParser); }); it('should create single cookie parser if request does not have any cookies', () => { const result = CookieParserFactory.fromHeaders( new Headers(), mockOptions ); expect(result).toBeInstanceOf(SingleCookieParser); return expect(() => result.parseCookies()).rejects.toEqual( new InvalidTokenError(InvalidTokenReason.MISSING_CREDENTIALS) ); }); it('should create multiple cookie parser if request does have all required cookies', () => { const mockHeaders = new Headers(); mockHeaders.set('Cookie', testCookiesHeader); const result = CookieParserFactory.fromHeaders(mockHeaders, mockOptions); expect(result).toBeInstanceOf(MultipleCookiesParser); }); it('should throw invalid credentials error if deprecated notation is used', () => { const mockHeaders = new Headers(); mockHeaders.set( 'Cookie', `TestCookie=${testCookies[0].value}:${testCookies[1].value}` ); return expect(() => CookieParserFactory.fromHeaders(mockHeaders, mockOptions) ).toThrow(new InvalidTokenError(InvalidTokenReason.INVALID_CREDENTIALS)); }); it('should create multiple cookie parser if request has legacy cookies', async () => { const mockHeaders = new Headers(); mockHeaders.set('Cookie', legacyCookiesHeader); const parser = CookieParserFactory.fromHeaders(mockHeaders, mockOptions); expect(parser).toBeInstanceOf(MultipleCookiesParser); const result = await parser.parseCookies(); expect(result).toEqual({ customToken: 'custom-token', idToken: 'id-token', refreshToken: 'refresh-token', metadata: {} }); }); }); describe('fromObject', () => { it('should create single cookie parser if request does not have multiple cookies', async () => { const parser = CookieParserFactory.fromObject( {TestCookie: testCookiesObj['TestCookie']}, mockOptions ); expect(parser).toBeInstanceOf(SingleCookieParser); const result = await parser.parseCookies(); expect(result).toEqual({ customToken: 'custom-token', idToken: 'id-token', refreshToken: 'refresh-token', metadata: {} }); }); it('should create single cookie parser if request does not have any cookies', () => { const result = CookieParserFactory.fromObject({}, mockOptions); expect(result).toBeInstanceOf(SingleCookieParser); return expect(() => result.parseCookies()).rejects.toEqual( new InvalidTokenError(InvalidTokenReason.MISSING_CREDENTIALS) ); }); it('should create multiple cookie parser if request does have all required cookies', async () => { const parser = CookieParserFactory.fromObject( testCookiesObj, mockOptions ); expect(parser).toBeInstanceOf(MultipleCookiesParser); const result = await parser.parseCookies(); expect(result).toEqual({ customToken: 'custom-token', idToken: 'id-token', refreshToken: 'refresh-token', metadata: {} }); }); it('should create multiple cookie parser if request has legacy cookies', async () => { const parser = CookieParserFactory.fromObject( legacyTestCookiesObj, mockOptions ); expect(parser).toBeInstanceOf(MultipleCookiesParser); const result = await parser.parseCookies(); expect(result).toEqual({ customToken: 'custom-token', idToken: 'id-token', refreshToken: 'refresh-token', metadata: {} }); }); it('should throw invalid credentials error if deprecated notation is used', () => { return expect(() => CookieParserFactory.fromObject( { TestCookie: `${testCookies[0].value}:${testCookies[1].value}` }, mockOptions ) ).toThrow(new InvalidTokenError(InvalidTokenReason.INVALID_CREDENTIALS)); }); }); }); ================================================ FILE: src/next/cookies/parser/CookieParserFactory.ts ================================================ import type {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import {InvalidTokenError, InvalidTokenReason} from '../../../auth/error.js'; import {debug} from '../../../debug/index.js'; import {CookiesObject, GetCookiesTokensOptions} from '../types.js'; import {CookiesProvider} from './CookiesProvider.js'; import {MultipleCookiesParser} from './MultipleCookiesParser.js'; import {ObjectCookiesProvider} from './ObjectCookiesProvider.js'; import {RequestCookiesProvider} from './RequestCookiesProvider.js'; import {SingleCookieParser} from './SingleCookieParser.js'; export class CookieParserFactory { public static hasMultipleCookies( provider: CookiesProvider, cookieName: string ) { return ['id', 'refresh', 'sig'] .map((it) => `${cookieName}.${it}`) .every((it) => Boolean(provider.get(it))); } public static hasCustomTokenCookie( provider: CookiesProvider, cookieName: string ) { return Boolean(provider.get(`${cookieName}.custom`)); } public static hasLegacyMultipleCookies( provider: CookiesProvider, cookieName: string ) { return ( ['custom', 'sig'] .map((it) => `${cookieName}.${it}`) .every((it) => Boolean(provider.get(it))) && provider.get(cookieName)?.includes(':') ); } private static getCompatibleProvider( legacyProvider: CookiesProvider, options: GetCookiesTokensOptions ) { const legacyToken = legacyProvider.get(options.cookieName); const [idToken, refreshToken] = legacyToken?.split(':') ?? []; const adaptedCookies = { [`${options.cookieName}.id`]: idToken, [`${options.cookieName}.refresh`]: refreshToken, [`${options.cookieName}.custom`]: legacyProvider.get( `${options.cookieName}.custom` ), [`${options.cookieName}.sig`]: legacyProvider.get( `${options.cookieName}.sig` ) }; return new ObjectCookiesProvider(adaptedCookies); } private static fromProvider( provider: CookiesProvider, options: GetCookiesTokensOptions ) { const singleCookie = provider.get(options.cookieName); const hasLegacyCookie = singleCookie?.includes(':'); const enableMultipleCookies = CookieParserFactory.hasMultipleCookies( provider, options.cookieName ); if (enableMultipleCookies) { return new MultipleCookiesParser( provider, options.cookieName, options.cookieSignatureKeys ); } if ( CookieParserFactory.hasLegacyMultipleCookies(provider, options.cookieName) ) { return new MultipleCookiesParser( CookieParserFactory.getCompatibleProvider(provider, options), options.cookieName, options.cookieSignatureKeys ); } if (hasLegacyCookie) { debug( "Authentication cookie is in multiple cookie format, but lacks signature and custom cookies. Clear your browser cookies and try again. If the issue keeps happening and you're using `enableMultipleCookies` option, make sure that server returns all required cookies: https://next-firebase-auth-edge-docs.vercel.app/docs/usage/middleware#multiple-cookies" ); throw new InvalidTokenError(InvalidTokenReason.INVALID_CREDENTIALS); } return new SingleCookieParser( provider, options.cookieName, options.cookieSignatureKeys ); } static fromRequestCookies( cookies: RequestCookies | ReadonlyRequestCookies, options: GetCookiesTokensOptions ) { const provider = RequestCookiesProvider.fromRequestCookies(cookies); return CookieParserFactory.fromProvider(provider, options); } static fromHeaders( headers: Headers, options: GetCookiesTokensOptions ) { const provider = RequestCookiesProvider.fromHeaders(headers); return CookieParserFactory.fromProvider(provider, options); } static fromObject( cookies: CookiesObject, options: GetCookiesTokensOptions ) { const provider = new ObjectCookiesProvider(cookies); return CookieParserFactory.fromProvider(provider, options); } } ================================================ FILE: src/next/cookies/parser/CookiesProvider.ts ================================================ export interface CookiesProvider { get(key: string): string | undefined; } ================================================ FILE: src/next/cookies/parser/MultipleCookiesParser.test.ts ================================================ import {InvalidTokenError, InvalidTokenReason} from '../../../auth/error.ts'; import {CookiesProvider} from './CookiesProvider.ts'; import {MultipleCookiesParser} from './MultipleCookiesParser.js'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import {RequestCookiesProvider} from './RequestCookiesProvider.ts'; const testCookies = [ { name: 'TestCookie.id', value: 'id-token' }, { name: 'TestCookie.refresh', value: 'refresh-token' }, { name: 'TestCookie.custom', value: 'custom-token' }, { name: 'TestCookie.sig', value: 'QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0' } ]; const testCookiesNoCustom = [ { name: 'TestCookie.id', value: 'id-token' }, { name: 'TestCookie.refresh', value: 'refresh-token' }, { name: 'TestCookie.sig', value: 'g-7yXxxJfMmzsR7BqkJjguoUWsOqCTGz2AndxjJBrkw' } ]; const testCookiesWithMetadata = [ { name: 'TestCookie.id', value: 'id-token' }, { name: 'TestCookie.refresh', value: 'refresh-token' }, { name: 'TestCookie.custom', value: 'custom-token' }, { name: 'TestCookie.metadata', value: 'eyJmb28iOiJiYXIifQ' }, { name: 'TestCookie.sig', value: '4LS2ty2sdecHjVR9dSSMO8jY0gvITmMgJH1stLFKVlA' } ]; describe('MultipleCookiesParser', () => { let mockCookies: RequestCookies; let mockCookiesProvider: CookiesProvider; beforeEach(() => { mockCookies = { get: jest.fn((name: string) => testCookies.find((cookie) => name === cookie.name) ) } as unknown as RequestCookies; mockCookiesProvider = RequestCookiesProvider.fromRequestCookies(mockCookies); }); it('should parse multiple cookies', async () => { const parser = new MultipleCookiesParser( mockCookiesProvider, 'TestCookie', ['secret'] ); const result = await parser.parseCookies(); expect(result).toEqual({ customToken: 'custom-token', idToken: 'id-token', refreshToken: 'refresh-token', metadata: {} }); expect(mockCookies.get).toHaveBeenNthCalledWith(1, 'TestCookie.id'); expect(mockCookies.get).toHaveBeenNthCalledWith(2, 'TestCookie.refresh'); expect(mockCookies.get).toHaveBeenNthCalledWith(3, 'TestCookie.custom'); expect(mockCookies.get).toHaveBeenNthCalledWith(4, 'TestCookie.metadata'); expect(mockCookies.get).toHaveBeenNthCalledWith(5, 'TestCookie.sig'); }); it('should parse multiple cookies with metadata', async () => { (mockCookies.get as jest.Mock).mockImplementation((name: string) => testCookiesWithMetadata.find((cookie) => name === cookie.name) ); const parser = new MultipleCookiesParser( mockCookiesProvider, 'TestCookie', ['secret'] ); const result = await parser.parseCookies(); expect(result).toEqual({ customToken: 'custom-token', idToken: 'id-token', refreshToken: 'refresh-token', metadata: {foo: 'bar'} }); expect(mockCookies.get).toHaveBeenNthCalledWith(1, 'TestCookie.id'); expect(mockCookies.get).toHaveBeenNthCalledWith(2, 'TestCookie.refresh'); expect(mockCookies.get).toHaveBeenNthCalledWith(3, 'TestCookie.custom'); expect(mockCookies.get).toHaveBeenNthCalledWith(4, 'TestCookie.metadata'); expect(mockCookies.get).toHaveBeenNthCalledWith(5, 'TestCookie.sig'); }); it('should throw missing credentials error if id token is empty', () => { (mockCookies.get as jest.Mock).mockImplementation((name: string) => testCookies .filter((it) => !it.name.endsWith('.id')) .find((it) => it.name === name) ); const parser = new MultipleCookiesParser( mockCookiesProvider, 'TestCookie', ['secret'] ); return expect(() => parser.parseCookies()).rejects.toEqual( new InvalidTokenError(InvalidTokenReason.MISSING_CREDENTIALS) ); }); it('should throw missing credentials error if refresh token is empty', () => { (mockCookies.get as jest.Mock).mockImplementation((name: string) => testCookies .filter((it) => !it.name.endsWith('.refresh')) .find((it) => it.name === name) ); const parser = new MultipleCookiesParser( mockCookiesProvider, 'TestCookie', ['secret'] ); return expect(() => parser.parseCookies()).rejects.toEqual( new InvalidTokenError(InvalidTokenReason.MISSING_CREDENTIALS) ); }); it('should throw invalid signature error if custom token is empty and multiple cookies signed with custom token are provided', () => { (mockCookies.get as jest.Mock).mockImplementation((name: string) => testCookies .filter((it) => !it.name.endsWith('.custom')) .find((it) => it.name === name) ); const parser = new MultipleCookiesParser( mockCookiesProvider, 'TestCookie', ['secret'] ); return expect(() => parser.parseCookies()).rejects.toEqual( new InvalidTokenError(InvalidTokenReason.INVALID_SIGNATURE) ); }); it('should parse multiple cookies without custom token', async () => { (mockCookies.get as jest.Mock).mockImplementation((name: string) => testCookiesNoCustom.find((cookie) => name === cookie.name) ); const parser = new MultipleCookiesParser( RequestCookiesProvider.fromRequestCookies(mockCookies), 'TestCookie', ['secret'] ); const result = await parser.parseCookies(); expect(result).toEqual({ idToken: 'id-token', refreshToken: 'refresh-token', metadata: {} }); expect(mockCookies.get).toHaveBeenNthCalledWith(1, 'TestCookie.id'); expect(mockCookies.get).toHaveBeenNthCalledWith(2, 'TestCookie.refresh'); expect(mockCookies.get).toHaveBeenNthCalledWith(3, 'TestCookie.custom'); expect(mockCookies.get).toHaveBeenNthCalledWith(4, 'TestCookie.metadata'); expect(mockCookies.get).toHaveBeenNthCalledWith(5, 'TestCookie.sig'); }); it('should throw missing credentials error if signature is empty', () => { (mockCookies.get as jest.Mock).mockImplementation((name: string) => testCookies .filter((it) => !it.name.endsWith('.sig')) .find((it) => it.name === name) ); const parser = new MultipleCookiesParser( mockCookiesProvider, 'TestCookie', ['secret'] ); return expect(() => parser.parseCookies()).rejects.toEqual( new InvalidTokenError(InvalidTokenReason.MISSING_CREDENTIALS) ); }); it('should throw invalid signature error if signature is incorrect', () => { const parser = new MultipleCookiesParser( mockCookiesProvider, 'TestCookie', ['incorrect-secret'] ); return expect(() => parser.parseCookies()).rejects.toEqual( new InvalidTokenError(InvalidTokenReason.INVALID_SIGNATURE) ); }); }); ================================================ FILE: src/next/cookies/parser/MultipleCookiesParser.ts ================================================ import {base64url, errors} from 'jose'; import {ParsedCookies} from '../../../auth/custom-token/index.js'; import {InvalidTokenError, InvalidTokenReason} from '../../../auth/error.js'; import {RotatingCredential} from '../../../auth/rotating-credential.js'; import {CookieParser} from './CookieParser.js'; import {CookiesProvider} from './CookiesProvider.js'; const textDecoder = new TextDecoder(); export class MultipleCookiesParser implements CookieParser { constructor( private cookies: CookiesProvider, private cookieName: string, private signatureKeys: string[] ) {} async parseCookies(): Promise> { const idTokenCookie = this.cookies.get(`${this.cookieName}.id`); const refreshTokenCookie = this.cookies.get(`${this.cookieName}.refresh`); const customTokenCookie = this.cookies.get(`${this.cookieName}.custom`); const metadataCookie = this.cookies.get(`${this.cookieName}.metadata`); const signatureCookie = this.cookies.get(`${this.cookieName}.sig`); if (![idTokenCookie, refreshTokenCookie, signatureCookie].every(Boolean)) { throw new InvalidTokenError(InvalidTokenReason.MISSING_CREDENTIALS); } const signature = signatureCookie!; const customTokens: ParsedCookies = { idToken: idTokenCookie!, refreshToken: refreshTokenCookie!, customToken: customTokenCookie, metadata: metadataCookie ? JSON.parse(textDecoder.decode(base64url.decode(metadataCookie))) : {} }; const credential = new RotatingCredential(this.signatureKeys); try { await credential.verifySignature(customTokens, signature); return customTokens; } catch (e) { if (e instanceof errors.JWSSignatureVerificationFailed) { throw new InvalidTokenError(InvalidTokenReason.INVALID_SIGNATURE); } throw e; } } } ================================================ FILE: src/next/cookies/parser/ObjectCookiesProvider.ts ================================================ import {CookiesObject} from '../types.js'; export class ObjectCookiesProvider { constructor(private cookies: CookiesObject) {} get(key: string) { return this.cookies[key]; } } ================================================ FILE: src/next/cookies/parser/RequestCookiesProvider.test.ts ================================================ import {RequestCookiesProvider} from './RequestCookiesProvider.js'; describe('RequestCookiesProvider', () => { it('should copy initial headers', () => { const headers = new Headers(); headers.set('Cookie', 'TestCookie=TestToken'); const provider = RequestCookiesProvider.fromHeaders(headers); expect(provider.get('TestCookie')).toEqual('TestToken'); headers.set('Cookie', 'NewCookie=SomeNewCookie'); expect(provider.get('TestCookie')).toEqual('TestToken'); expect(provider.get('NewCookie')).toEqual(undefined); }); }); ================================================ FILE: src/next/cookies/parser/RequestCookiesProvider.ts ================================================ import type {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; export class RequestCookiesProvider { static fromHeaders(headers: Headers) { const cookies = new RequestCookies(new Headers(headers)); return new RequestCookiesProvider(cookies); } static fromRequestCookies(cookies: RequestCookies | ReadonlyRequestCookies) { return new RequestCookiesProvider(cookies); } constructor(private cookies: RequestCookies | ReadonlyRequestCookies) {} get(key: string) { return this.cookies.get(key)?.value; } } ================================================ FILE: src/next/cookies/parser/SingleCookieParser.test.ts ================================================ import {InvalidTokenError, InvalidTokenReason} from '../../../auth/error.ts'; import {RequestCookiesProvider} from './RequestCookiesProvider.ts'; import {SingleCookieParser} from './SingleCookieParser.js'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; const mockCookie = { name: 'TestCookie', value: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4iLCJjdXN0b21fdG9rZW4iOiJjdXN0b20tdG9rZW4ifQ.ExxN2rNayg2XCR6WNeZmY8tAyc_qyiZ2YdzITRbQocs' }; const mockCookieWithMetadata = { name: 'TestCookie', value: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6ImlkLXRva2VuIiwicmVmcmVzaF90b2tlbiI6InJlZnJlc2gtdG9rZW4iLCJjdXN0b21fdG9rZW4iOiJjdXN0b20tdG9rZW4iLCJtZXRhZGF0YSI6eyJmb28iOiJiYXIifX0.tm6HY-N7NZMq9ipez53--tXfixsHhcF59hj9s13iEII' }; describe('SingleCookieParser', () => { let mockCookies: RequestCookies; let mockCookiesProvider: RequestCookiesProvider; beforeEach(() => { mockCookies = { get: jest.fn(() => mockCookie) } as unknown as RequestCookies; mockCookiesProvider = new RequestCookiesProvider(mockCookies); }); it('should parse a jwt cookie', async () => { const parser = new SingleCookieParser(mockCookiesProvider, 'TestCookie', [ 'secret' ]); const result = await parser.parseCookies(); expect(result).toEqual({ customToken: 'custom-token', idToken: 'id-token', refreshToken: 'refresh-token', metadata: {} }); expect(mockCookies.get).toHaveBeenCalledWith('TestCookie'); }); it('should parse a jwt cookie with metadata', async () => { (mockCookies.get as jest.Mock).mockImplementationOnce( () => mockCookieWithMetadata ); const parser = new SingleCookieParser(mockCookiesProvider, 'TestCookie', [ 'secret' ]); const result = await parser.parseCookies(); expect(result).toEqual({ customToken: 'custom-token', idToken: 'id-token', refreshToken: 'refresh-token', metadata: {foo: 'bar'} }); expect(mockCookies.get).toHaveBeenCalledWith('TestCookie'); }); it('should throw missing credentials error if cookie is empty', () => { (mockCookies.get as jest.Mock).mockImplementationOnce(() => ({ name: 'TestCookie', value: undefined })); const parser = new SingleCookieParser(mockCookiesProvider, 'TestCookie', [ 'secret' ]); return expect(() => parser.parseCookies()).rejects.toEqual( new InvalidTokenError(InvalidTokenReason.MISSING_CREDENTIALS) ); }); it('should throw invalid signature error if signature is incorrect', () => { const parser = new SingleCookieParser(mockCookiesProvider, 'TestCookie', [ 'incorrect-secret' ]); return expect(() => parser.parseCookies()).rejects.toEqual( new InvalidTokenError(InvalidTokenReason.INVALID_SIGNATURE) ); }); }); ================================================ FILE: src/next/cookies/parser/SingleCookieParser.ts ================================================ import {errors} from 'jose'; import {ParsedCookies} from '../../../auth/custom-token/index.js'; import {InvalidTokenError, InvalidTokenReason} from '../../../auth/error.js'; import {RotatingCredential} from '../../../auth/rotating-credential.js'; import {CookieParser} from './CookieParser.js'; import {CookiesProvider} from './CookiesProvider.js'; export class SingleCookieParser implements CookieParser { constructor( private cookies: CookiesProvider, private cookieName: string, private signatureKeys: string[] ) {} async parseCookies(): Promise> { const jwtCookie = this.cookies.get(this.cookieName); if (!jwtCookie) { throw new InvalidTokenError(InvalidTokenReason.MISSING_CREDENTIALS); } const credential = new RotatingCredential(this.signatureKeys); try { const result = await credential.verify(jwtCookie); return { idToken: result.id_token, refreshToken: result.refresh_token, customToken: result.custom_token, metadata: (result.metadata ?? {}) as Metadata }; } catch (e) { if (e instanceof errors.JWSSignatureVerificationFailed) { throw new InvalidTokenError(InvalidTokenReason.INVALID_SIGNATURE); } throw e; } } } ================================================ FILE: src/next/cookies/remover/CombinedCookieRemover.ts ================================================ import {SingleCookieRemover} from './SingleCookieRemover.js'; import {CookieRemover} from './CookieRemover.js'; import {MultipleCookieRemover} from './MultipleCookieRemover.js'; export class CombinedCookieRemover implements CookieRemover { constructor( private multi: MultipleCookieRemover, private single: SingleCookieRemover ) {} removeCookies(): void { this.multi.removeCookies(); this.single.removeCookies(); } } ================================================ FILE: src/next/cookies/remover/CookieRemover.ts ================================================ export interface CookieRemover { removeCookies(): void; } ================================================ FILE: src/next/cookies/remover/CookieRemoverFactory.test.ts ================================================ import {Cookie} from '../builder/CookieBuilder.js'; import {RequestCookiesProvider} from '../parser/RequestCookiesProvider.js'; import {CookieRemoverFactory} from './CookieRemoverFactory'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; const cookieName = 'TestCookie'; const testCookies: Cookie[] = [ { name: 'TestCookie.id', value: 'id-token' }, { name: 'TestCookie.refresh', value: 'refresh-token' }, { name: 'TestCookie.custom', value: 'custom-token' }, { name: 'TestCookie.sig', value: 'QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0' } ]; const legacyTestCookies: Cookie[] = [ { name: 'TestCookie', value: 'id-token:refresh-token' }, { name: 'TestCookie.custom', value: 'custom-token' }, { name: 'TestCookie.sig', value: 'QupyAMaPmI6d90CqB0lvec5Q517onmUvXEk6bONTQM0' } ]; function getTestCookie(name: string) { return testCookies.find((it) => it.name === name); } function getLegacyTestCookie(name: string) { return legacyTestCookies.find((it) => it.name === name); } function getSingleCookie(name: string) { if (name === cookieName) { return { name: cookieName, value: 'single-cookie' }; } return undefined; } describe('CookieRemoverFactory', () => { it('should remove a single cookie', () => { const cookies = { get: jest.fn(), delete: jest.fn() } as unknown as RequestCookies; const remover = CookieRemoverFactory.fromRequestCookies( cookies, new RequestCookiesProvider(cookies), cookieName ); remover.removeCookies(); expect(cookies.delete).toHaveBeenCalledTimes(1); expect(cookies.delete).toHaveBeenCalledWith('TestCookie'); }); it('should remove multiple cookies', () => { const cookies = { get: jest.fn(getTestCookie), delete: jest.fn() } as unknown as RequestCookies; const remover = CookieRemoverFactory.fromRequestCookies( cookies, new RequestCookiesProvider(cookies), cookieName ); remover.removeCookies(); expect(cookies.delete).toHaveBeenCalledTimes(5); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.id'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.refresh'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.custom'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.metadata'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.sig'); }); it('should remove multiple and single cookies when there are both', () => { const cookies = { get: jest.fn((name) => { return getSingleCookie(name) ?? getTestCookie(name); }), delete: jest.fn() } as unknown as RequestCookies; const remover = CookieRemoverFactory.fromRequestCookies( cookies, new RequestCookiesProvider(cookies), cookieName ); remover.removeCookies(); expect(cookies.delete).toHaveBeenCalledTimes(6); expect(cookies.delete).toHaveBeenCalledWith('TestCookie'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.id'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.refresh'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.custom'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.metadata'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.sig'); }); it('should remove multiple and single cookies when there are legacy cookies', () => { const cookies = { get: jest.fn((name) => { return getLegacyTestCookie(name); }), delete: jest.fn() } as unknown as RequestCookies; const remover = CookieRemoverFactory.fromRequestCookies( cookies, new RequestCookiesProvider(cookies), cookieName ); remover.removeCookies(); expect(cookies.delete).toHaveBeenCalledTimes(6); expect(cookies.delete).toHaveBeenCalledWith('TestCookie'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.id'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.refresh'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.custom'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.metadata'); expect(cookies.delete).toHaveBeenCalledWith('TestCookie.sig'); }); }); ================================================ FILE: src/next/cookies/remover/CookieRemoverFactory.ts ================================================ import {CookieParserFactory} from '../parser/CookieParserFactory.js'; import {CookiesProvider} from '../parser/CookiesProvider.js'; import {CombinedCookieRemover} from './CombinedCookieRemover'; import {MultipleCookieRemover} from './MultipleCookieRemover'; import {SingleCookieRemover} from './SingleCookieRemover'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import type {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; export class CookieRemoverFactory { static fromRequestCookies( cookies: RequestCookies | ReadonlyRequestCookies, provider: CookiesProvider, cookieName: string ) { const singleCookie = provider.get(cookieName); const hasEnabledMultipleCookies = CookieParserFactory.hasMultipleCookies( provider, cookieName ); const hasEnabledLegacyMultipleCookies = CookieParserFactory.hasLegacyMultipleCookies(provider, cookieName); if ( singleCookie && (hasEnabledMultipleCookies || hasEnabledLegacyMultipleCookies) ) { return new CombinedCookieRemover( new MultipleCookieRemover(cookieName, cookies), new SingleCookieRemover(cookieName, cookies) ); } if (hasEnabledMultipleCookies) { return new MultipleCookieRemover(cookieName, cookies); } return new SingleCookieRemover(cookieName, cookies); } } ================================================ FILE: src/next/cookies/remover/MultipleCookieRemover.test.ts ================================================ import {MultipleCookieRemover} from './MultipleCookieRemover.js'; import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; const mockCookies: RequestCookies = { delete: jest.fn() } as unknown as RequestCookies; describe('MultipleCookieRemover', () => { beforeEach(() => { jest.resetAllMocks(); }); it('should remove multiple cookies', () => { const remover = new MultipleCookieRemover('TestCookie', mockCookies); remover.removeCookies(); expect(mockCookies.delete).toHaveBeenNthCalledWith(1, 'TestCookie.id'); expect(mockCookies.delete).toHaveBeenNthCalledWith(2, 'TestCookie.refresh'); expect(mockCookies.delete).toHaveBeenNthCalledWith(3, 'TestCookie.custom'); expect(mockCookies.delete).toHaveBeenNthCalledWith( 4, 'TestCookie.metadata' ); expect(mockCookies.delete).toHaveBeenNthCalledWith(5, 'TestCookie.sig'); }); }); ================================================ FILE: src/next/cookies/remover/MultipleCookieRemover.ts ================================================ import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import type {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {CookieRemover} from './CookieRemover.js'; export class MultipleCookieRemover implements CookieRemover { public constructor( private cookieName: string, private cookies: RequestCookies | ReadonlyRequestCookies ) {} removeCookies(): void { [ `${this.cookieName}.id`, `${this.cookieName}.refresh`, `${this.cookieName}.custom`, `${this.cookieName}.metadata`, `${this.cookieName}.sig` ].forEach((name) => this.cookies.delete(name)); } } ================================================ FILE: src/next/cookies/remover/SingleCookieRemover.ts ================================================ import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import type {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import {CookieRemover} from './CookieRemover.js'; export class SingleCookieRemover implements CookieRemover { public constructor( private cookieName: string, private cookies: RequestCookies | ReadonlyRequestCookies ) {} removeCookies(): void { this.cookies.delete(this.cookieName); } } ================================================ FILE: src/next/cookies/setter/CookieSetter.ts ================================================ import {CookieSerializeOptions} from 'cookie'; import type {Cookie} from '../builder/CookieBuilder.js'; export interface CookieSetter { setCookies(cookies: Cookie[], options: CookieSerializeOptions): void; } ================================================ FILE: src/next/cookies/setter/CookieSetterFactory.ts ================================================ import type {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import {HeadersCookieSetter} from './HeadersCookieSetter.js'; import {RequestCookieSetter} from './RequestCookieSetter.js'; export class CookieSetterFactory { static fromRequestCookies(cookies: RequestCookies | ReadonlyRequestCookies) { return new RequestCookieSetter(cookies); } static fromHeaders(headers: Headers) { return new HeadersCookieSetter(headers); } } ================================================ FILE: src/next/cookies/setter/HeadersCookieSetter.test.ts ================================================ import {HeadersCookieSetter} from './HeadersCookieSetter.ts'; describe('HeadersCookieSetter', () => { it('should append cookie with options on provided headers', () => { const mockHeaders = {append: jest.fn()} as unknown as Headers; const serializeOptions = { path: '/', httpOnly: true, secure: true, sameSite: 'lax' as const, maxAge: 12 * 60 * 60 * 24 }; const setter = new HeadersCookieSetter(mockHeaders); setter.setCookies( [ { name: 'FirstCookie', value: 'first' }, { name: 'SecondCookie', value: 'second' } ], serializeOptions ); expect(mockHeaders.append).toHaveBeenNthCalledWith( 1, 'Set-Cookie', 'FirstCookie=first; Max-Age=1036800; Path=/; HttpOnly; Secure; SameSite=Lax' ); expect(mockHeaders.append).toHaveBeenNthCalledWith( 2, 'Set-Cookie', 'SecondCookie=second; Max-Age=1036800; Path=/; HttpOnly; Secure; SameSite=Lax' ); }); }); ================================================ FILE: src/next/cookies/setter/HeadersCookieSetter.ts ================================================ import {CookieSerializeOptions, serialize} from 'cookie'; import type {Cookie} from '../builder/CookieBuilder.js'; import type {CookieSetter} from './CookieSetter.js'; export class HeadersCookieSetter implements CookieSetter { constructor(private headers: Headers) {} setCookies(cookies: Cookie[], options: CookieSerializeOptions): void { for (const cookie of cookies) { this.headers.append( 'Set-Cookie', serialize(cookie.name, cookie.value, options) ); } } } ================================================ FILE: src/next/cookies/setter/NextApiResponseHeadersCookieSetter.ts ================================================ import {CookieSerializeOptions, serialize} from 'cookie'; import type {NextApiResponse} from 'next'; import type {Cookie} from '../builder/CookieBuilder.js'; import type {CookieSetter} from './CookieSetter.js'; export class NextApiResponseCookieSetter implements CookieSetter { constructor(private response: NextApiResponse) {} setCookies(cookies: Cookie[], options: CookieSerializeOptions): void { for (const cookie of cookies) { this.response.setHeader('Set-Cookie', [ serialize(cookie.name, cookie.value, options) ]); } } } ================================================ FILE: src/next/cookies/setter/RequestCookieSetter.test.ts ================================================ import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import {RequestCookieSetter} from './RequestCookieSetter.js'; describe('RequestCookieSetter', () => { it('should set cookie with options on provided request', () => { const mockCookies = {set: jest.fn()} as unknown as RequestCookies; const serializeOptions = { path: '/', httpOnly: true, secure: true, sameSite: 'lax' as const, maxAge: 12 * 60 * 60 * 24 }; const setter = new RequestCookieSetter(mockCookies); setter.setCookies( [ { name: 'FirstCookie', value: 'first' }, { name: 'SecondCookie', value: 'second' } ], serializeOptions ); expect(mockCookies.set).toHaveBeenNthCalledWith( 1, 'FirstCookie', 'first', serializeOptions ); expect(mockCookies.set).toHaveBeenNthCalledWith( 2, 'SecondCookie', 'second', serializeOptions ); }); it('should delete empty cookies', () => { const mockCookies = { set: jest.fn(), delete: jest.fn() } as unknown as RequestCookies; const serializeOptions = { path: '/', httpOnly: true, secure: true, sameSite: 'lax' as const, maxAge: 12 * 60 * 60 * 24 }; const setter = new RequestCookieSetter(mockCookies); setter.setCookies( [ { name: 'FirstCookie', value: '' }, { name: 'SecondCookie', value: '' } ], serializeOptions ); expect(mockCookies.set).not.toHaveBeenCalled(); expect(mockCookies.delete).toHaveBeenNthCalledWith(1, 'FirstCookie'); expect(mockCookies.delete).toHaveBeenNthCalledWith(2, 'SecondCookie'); }); }); ================================================ FILE: src/next/cookies/setter/RequestCookieSetter.ts ================================================ import type {CookieSerializeOptions} from 'cookie'; import type {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import type {Cookie} from '../builder/CookieBuilder.js'; import type {CookieSetter} from './CookieSetter.js'; export class RequestCookieSetter implements CookieSetter { constructor(private cookies: RequestCookies | ReadonlyRequestCookies) {} setCookies(cookies: Cookie[], options: CookieSerializeOptions): void { for (const cookie of cookies) { if (cookie.value) { this.cookies.set(cookie.name, cookie.value, options); } else { this.cookies.delete(cookie.name); } } } } ================================================ FILE: src/next/cookies/types.ts ================================================ import type {CookieSerializeOptions} from 'cookie'; import {ServiceAccount} from '../../auth/credential.js'; import {TokenSet} from '../../auth/types.js'; export interface SetAuthCookiesOptions { cookieName: string; cookieSignatureKeys: string[]; cookieSerializeOptions: CookieSerializeOptions; enableMultipleCookies?: boolean; enableCustomToken?: boolean; serviceAccount?: ServiceAccount; apiKey: string; tenantId?: string; authorizationHeaderName?: string; dynamicCustomClaimsKeys?: string[]; getMetadata?: (tokens: TokenSet) => Promise; enableTokenRefreshOnExpiredKidHeader?: boolean; } export type CookiesObject = Partial<{[K in string]: string}>; export interface GetCookiesTokensOptions { cookieName: string; cookieSignatureKeys: string[]; } ================================================ FILE: src/next/metadata.ts ================================================ import {TokenSet} from '../auth/types.js'; import {SetAuthCookiesOptions} from './cookies/types.js'; export async function getMetadataInternal( tokens: TokenSet, options: SetAuthCookiesOptions ): Promise { if (!options.getMetadata) { return {} as Metadata; } return await options.getMetadata(tokens); } ================================================ FILE: src/next/middleware.ts ================================================ import type {NextRequest} from 'next/server'; import {NextResponse} from 'next/server'; import {ServiceAccount} from '../auth/credential.js'; import { AuthError, AuthErrorCode, InvalidTokenError, InvalidTokenReason, isInvalidTokenError } from '../auth/error.js'; import {getFirebaseAuth, handleExpiredToken, Tokens} from '../auth/index.js'; import {debug, enableDebugMode} from '../debug/index.js'; import {AuthCookies} from './cookies/AuthCookies.js'; import { removeAuthCookies, setAuthCookies, SetAuthCookiesOptions } from './cookies/index.js'; import {RequestCookiesProvider} from './cookies/parser/RequestCookiesProvider.js'; import {refreshToken} from './refresh-token.js'; import {getRequestCookiesTokens, validateOptions} from './tokens.js'; import {getReferer} from './utils.js'; import {getMetadataInternal} from './metadata.js'; import {mapJwtPayloadToDecodedIdToken} from '../auth/utils.js'; import {decodeJwt} from 'jose'; export interface CreateAuthMiddlewareOptions extends SetAuthCookiesOptions { loginPath: string; logoutPath: string; refreshTokenPath?: string; experimental_createAnonymousUserIfUserNotFound?: boolean; } interface RedirectToPathOptions { shouldClearSearchParams: boolean; } export function redirectToPath( request: NextRequest, path: string, options: RedirectToPathOptions = {shouldClearSearchParams: false} ) { const url = request.nextUrl.clone(); url.pathname = path; if (options.shouldClearSearchParams) { url.search = ''; } return NextResponse.redirect(url); } interface RedirectToHomeOptions { path: string; } export function redirectToHome( request: NextRequest, options: RedirectToHomeOptions = { path: '/' } ) { return redirectToPath(request, options.path, {shouldClearSearchParams: true}); } export type Path = string | RegExp; // @deprecated - Use `Path` instead export type PublicPath = Path; export interface RedirectToLoginOptions { path: string; redirectParamKeyName?: string; publicPaths?: Path[]; privatePaths?: Path[]; } function doesRequestPathnameMatchPath(request: NextRequest, path: Path) { if (typeof path === 'string') { return path === getUrlWithoutTrailingSlash(request.nextUrl.pathname); } return path.test(getUrlWithoutTrailingSlash(request.nextUrl.pathname)); } function doesRequestPathnameMatchOneOfPaths( request: NextRequest, paths: Path[] ) { return paths.some((path) => doesRequestPathnameMatchPath(request, path)); } function getUrlWithoutTrailingSlash(url: string) { if (url === '/') { return '/'; } return url.endsWith('/') ? url.slice(0, -1) : url; } function createLoginRedirectResponse( request: NextRequest, options: RedirectToLoginOptions ) { const redirectKey = options.redirectParamKeyName || 'redirect'; const url = request.nextUrl.clone(); url.pathname = options.path; const encodedRedirect = encodeURIComponent( `${request.nextUrl.pathname}${url.search}` ); url.search = `${redirectKey}=${encodedRedirect}`; return NextResponse.redirect(url); } export function redirectToLogin( request: NextRequest, options: RedirectToLoginOptions = { path: '/login', publicPaths: ['/login'] } ) { if ( options.publicPaths && doesRequestPathnameMatchOneOfPaths(request, options.publicPaths) ) { return NextResponse.next(); } if ( options.privatePaths && !doesRequestPathnameMatchOneOfPaths(request, options.privatePaths) ) { return NextResponse.next(); } return createLoginRedirectResponse(request, options); } export async function createAuthMiddlewareResponse( request: NextRequest, options: CreateAuthMiddlewareOptions ): Promise { const url = getUrlWithoutTrailingSlash(request.nextUrl.pathname); if (url === getUrlWithoutTrailingSlash(options.loginPath)) { return setAuthCookies(request.headers, options); } if (url === getUrlWithoutTrailingSlash(options.logoutPath)) { return removeAuthCookies(request.headers, { cookieName: options.cookieName, cookieSerializeOptions: options.cookieSerializeOptions }); } if ( options.refreshTokenPath && url === getUrlWithoutTrailingSlash(options.refreshTokenPath) ) { return refreshToken(request, options); } return NextResponse.next(); } export type HandleInvalidToken = ( reason: InvalidTokenReason ) => Promise; export type HandleValidToken = ( tokens: Tokens, headers: Headers ) => Promise; export type HandleError = (e: unknown) => Promise; export interface AuthMiddlewareOptions extends CreateAuthMiddlewareOptions { serviceAccount?: ServiceAccount; apiKey: string; debug?: boolean; headers?: Headers; checkRevoked?: boolean; handleInvalidToken?: HandleInvalidToken; handleValidToken?: HandleValidToken; handleError?: HandleError; enableTokenRefreshOnExpiredKidHeader?: boolean; } const defaultInvalidTokenHandler = async () => NextResponse.next(); const defaultValidTokenHandler = async ( _tokens: Tokens, headers: Headers ) => NextResponse.next({ request: { headers } }); export async function authMiddleware( request: NextRequest, middlewareOptions: AuthMiddlewareOptions ): Promise { const options: AuthMiddlewareOptions = { enableTokenRefreshOnExpiredKidHeader: true, ...middlewareOptions }; if (options.debug) { enableDebugMode(); } validateOptions(options); const referer = getReferer(request.headers) ?? ''; const handleValidToken = options.handleValidToken ?? defaultValidTokenHandler; const handleError = options.handleError ?? defaultInvalidTokenHandler; const handleInvalidToken = options.handleInvalidToken ?? defaultInvalidTokenHandler; debug('Handle request', { path: getUrlWithoutTrailingSlash(request.nextUrl.pathname) }); const authMiddlewareResponseRoutes = [ options.loginPath, options.logoutPath, options.refreshTokenPath ] .filter(Boolean) .map((url) => getUrlWithoutTrailingSlash(url as string)); if ( authMiddlewareResponseRoutes.includes( getUrlWithoutTrailingSlash(request.nextUrl.pathname) ) ) { debug('Handle authentication API route'); return createAuthMiddlewareResponse(request, options); } const {verifyIdToken, handleTokenRefresh, createAnonymousUser} = getFirebaseAuth({ serviceAccount: options.serviceAccount, apiKey: options.apiKey, tenantId: options.tenantId }); try { debug('Attempt to fetch request cookies tokens'); const tokens = await getRequestCookiesTokens( request.cookies, options ); return await handleExpiredToken( async () => { debug('Verifying user credentials...'); const decodedToken = await verifyIdToken(tokens.idToken, { checkRevoked: options.checkRevoked, referer }); debug('Credentials verified successfully'); const response = await handleValidToken( { token: tokens.idToken, decodedToken, customToken: tokens.customToken, metadata: tokens.metadata }, request.headers ); debug('Successfully handled authenticated response'); return response; }, async () => { debug('Token has expired. Refreshing token...'); const {idToken, decodedIdToken, refreshToken, customToken} = await handleTokenRefresh(tokens.refreshToken, { referer, enableCustomToken: options.enableCustomToken }); debug( 'Token refreshed successfully. Updating response cookie headers...' ); const metadata = await getMetadataInternal( { idToken, decodedIdToken, refreshToken, customToken }, options ); const valueToSign = { idToken, refreshToken, customToken, metadata }; const cookies = new AuthCookies( RequestCookiesProvider.fromHeaders(request.headers), options ); await cookies.setAuthCookies(valueToSign, request.cookies); const response = await handleValidToken( {token: idToken, decodedToken: decodedIdToken, customToken, metadata}, request.headers ); debug('Successfully handled authenticated response'); await cookies.setAuthHeaders(valueToSign, response.headers); return response; }, async (e) => { if ( e instanceof AuthError && e.code === AuthErrorCode.NO_MATCHING_KID ) { throw InvalidTokenError.fromError(e, InvalidTokenReason.INVALID_KID); } debug('Authentication failed with error', {error: e}); return handleError(e); }, options.enableTokenRefreshOnExpiredKidHeader ?? false ); } catch (error: unknown) { if (isInvalidTokenError(error)) { debug( `Token is missing or has incorrect formatting. This is expected and usually means that user has not yet logged in`, { reason: error.reason } ); if (options.experimental_createAnonymousUserIfUserNotFound) { const {idToken, refreshToken} = await createAnonymousUser( options.apiKey ); const decodedIdToken = mapJwtPayloadToDecodedIdToken( decodeJwt(idToken) ); const metadata = await getMetadataInternal( { idToken, decodedIdToken, refreshToken }, options ); const valueToSign = { idToken, refreshToken, metadata }; const cookies = new AuthCookies( RequestCookiesProvider.fromHeaders(request.headers), options ); await cookies.setAuthCookies(valueToSign, request.cookies); const decodedToken = await verifyIdToken(idToken, { checkRevoked: options.checkRevoked, referer }); const response = await handleValidToken( {token: idToken, decodedToken, metadata}, request.headers ); await cookies.setAuthHeaders(valueToSign, response.headers); return response; } return handleInvalidToken(error.reason); } throw error; } } ================================================ FILE: src/next/refresh-token.ts ================================================ import {NextResponse} from 'next/server'; import type {NextRequest} from 'next/server'; import { SetAuthCookiesOptions, appendAuthCookies, verifyNextCookies } from './cookies/index.js'; import {HttpError, isInvalidTokenError} from '../auth/index.js'; export async function refreshToken( request: NextRequest, options: SetAuthCookiesOptions ) { try { const result = await verifyNextCookies( request.cookies, request.headers, options ); const headers: Record = { 'Content-Type': 'application/json' }; if (!result) { return new NextResponse(JSON.stringify({idToken: null}), { status: 200, headers }); } const response = new NextResponse( JSON.stringify({ idToken: result.idToken, customToken: result.customToken }), { status: 200, headers } ); await appendAuthCookies(request.headers, response, result, options); return response; } catch (error: unknown) { if (isInvalidTokenError(error)) { return new NextResponse( JSON.stringify({ reason: error.reason, message: error.message } as HttpError), { status: 401 } ); } throw error; } } ================================================ FILE: src/next/tokens.ts ================================================ import type {CookieSerializeOptions} from 'cookie'; import {decodeJwt} from 'jose'; import {NextApiRequest} from 'next'; import type {ReadonlyRequestCookies} from 'next/dist/server/web/spec-extension/adapters/request-cookies'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import {ServiceAccount} from '../auth/credential.js'; import {ParsedCookies} from '../auth/custom-token/index.js'; import {isInvalidTokenError} from '../auth/error.js'; import {Tokens} from '../auth/index.js'; import {mapJwtPayloadToDecodedIdToken} from '../auth/utils.js'; import {debug, enableDebugMode} from '../debug/index.js'; import {CookieParserFactory} from './cookies/parser/CookieParserFactory.js'; import {CookiesObject, GetCookiesTokensOptions} from './cookies/types.js'; export interface GetTokensOptions extends GetCookiesTokensOptions { cookieSerializeOptions?: CookieSerializeOptions; serviceAccount?: ServiceAccount; apiKey: string; debug?: boolean; enableTokenRefreshOnExpiredKidHeader?: boolean; tenantId?: string; } export function validateOptions(options: GetTokensOptions) { if (!options.cookieSignatureKeys.length || !options.cookieSignatureKeys[0]) { throw new Error( `Expected cookieSignatureKeys to contain at least one signature key. Received: ${JSON.stringify( options.cookieSignatureKeys )}` ); } } export function getRequestCookiesTokens( cookies: RequestCookies | ReadonlyRequestCookies, options: GetCookiesTokensOptions ): Promise> { const parser = CookieParserFactory.fromRequestCookies( cookies, options ); return parser.parseCookies(); } export async function getTokens( cookies: RequestCookies | ReadonlyRequestCookies, options: GetTokensOptions ): Promise | null> { const now = Date.now(); if (options.debug) { enableDebugMode(); } validateOptions(options); try { const tokens = await getRequestCookiesTokens(cookies, options); debug('getTokens: Tokens successfully extracted from cookies'); const payload = decodeJwt(tokens.idToken); return { token: tokens.idToken, decodedToken: mapJwtPayloadToDecodedIdToken(payload), customToken: tokens.customToken, metadata: tokens.metadata }; } catch (error: unknown) { if (isInvalidTokenError(error)) { debug( `Token is missing or has incorrect formatting. This is expected and usually means that user has not yet logged in`, { reason: error.reason } ); return null; } throw error; } finally { debug(`getTokens: took ${(Date.now() - now) / 1000}ms`); } } export function getCookiesTokens( cookies: CookiesObject, options: GetCookiesTokensOptions ): Promise> { const parser = CookieParserFactory.fromObject(cookies, options); return parser.parseCookies(); } export async function getApiRequestTokens( request: NextApiRequest, options: GetTokensOptions ): Promise | null> { try { const tokens = await getCookiesTokens(request.cookies, options); const payload = decodeJwt(tokens.idToken); return { token: tokens.idToken, decodedToken: mapJwtPayloadToDecodedIdToken(payload), customToken: tokens.customToken, metadata: tokens.metadata }; } catch (error: unknown) { if (isInvalidTokenError(error)) { return null; } throw error; } } /** * @deprecated * Use `getApiRequestTokens` instead */ export async function getTokensFromObject( cookies: CookiesObject, options: GetTokensOptions ): Promise | null> { try { const tokens = await getCookiesTokens(cookies, options); const payload = decodeJwt(tokens.idToken); return { token: tokens.idToken, decodedToken: mapJwtPayloadToDecodedIdToken(payload), customToken: tokens.customToken, metadata: tokens.metadata }; } catch (error: unknown) { if (isInvalidTokenError(error)) { return null; } throw error; } } ================================================ FILE: src/next/utils.ts ================================================ export function getReferer(headers: Headers) { const host = headers.get('X-Forwarded-Host') ?? headers.get('Host') ?? undefined; const protocol = headers.get('X-Forwarded-Proto'); const fallback = protocol && host ? `${protocol}://${host}/` : undefined; return fallback ?? host; } ================================================ FILE: tsconfig.base.json ================================================ { "compilerOptions": { "lib": ["ES2020", "DOM", "esnext", "DOM.iterable"], "types": [], "strict": true, "noUnusedParameters": true, "removeComments": true, "verbatimModuleSyntax": false, "sourceMap": false, "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, "noImplicitAny": true, "strictNullChecks": true, "noUnusedLocals": true, "skipLibCheck": true }, "include": ["./src/**/*.ts", "./src/**/*.tsx", "./src/*.tsx"], "exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"] } ================================================ FILE: tsconfig.browser.json ================================================ { "extends": "./tsconfig.base.json", "compilerOptions": { "target": "ES2020", "module": "ES2020", "outDir": "browser", "paths": { "next": ["./node_modules/next/index.d.ts"], "next/server": ["./node_modules/next/server.d.ts"], "next/dist/server/web/spec-extension/cookies": ["./node_modules/next/dist/server/web/spec-extension/cookies.d.ts"], "jose": ["./node_modules/jose/dist/types/index.d.ts"], "jose/dist/types/util/errors": ["./node_modules/jose/dist/types/util/errors.d.ts"], "jose/dist/types/jwks/remote": ["./node_modules/jose/dist/types/jwks/remote.d.ts"], "next/dist/server/web/spec-extension/adapters/request-cookies": ["./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts"] } } } ================================================ FILE: tsconfig.esm.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "declaration": false, "module": "ES2022", "outDir": "esm", "paths": { "next": ["./node_modules/next/index.d.ts"], "next/server": ["./node_modules/next/server.d.ts"], "next/dist/server/web/spec-extension/cookies": ["./node_modules/next/dist/server/web/spec-extension/cookies.d.ts"], "jose": ["./node_modules/jose/dist/types/index.d.ts"], "jose/dist/types/util/errors": ["./node_modules/jose/dist/types/util/errors.d.ts"], "jose/dist/types/jwks/remote": ["./node_modules/jose/dist/types/jwks/remote.d.ts"], "next/dist/server/web/spec-extension/adapters/request-cookies": ["./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts"] } } } ================================================ FILE: tsconfig.json ================================================ { "extends": "./tsconfig.base.json", "compilerOptions": { "types": ["node"], "target": "ES2022", "module": "CommonJS", "outDir": "lib", "declaration": true } } ================================================ FILE: tsconfig.test.json ================================================ { "extends": "./tsconfig.base.json", "compilerOptions": { "types": ["node", "jest"], "target": "ES2022", "module": "CommonJS", "outDir": "./lib", "rootDir": "./src" }, "include": ["./src/**/*.ts", "./src/**/*.tsx"], "exclude": ["node_modules"] }