├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── eslint.config.mjs ├── example ├── index.ts ├── tsconfig.json └── wrangler.toml ├── firebase.json ├── package.json ├── pnpm-lock.yaml ├── scripts └── version.ts ├── src ├── api-requests.ts ├── auth-api-requests.ts ├── auth.ts ├── base64.ts ├── client.ts ├── credential.ts ├── emulator.ts ├── errors.ts ├── index.ts ├── jwk-fetcher.ts ├── jws-verifier.ts ├── jwt-decoder.ts ├── key-store.ts ├── token-verifier.ts ├── user-record.ts ├── utf8.ts ├── validator.ts ├── version.ts └── x509.ts ├── tests ├── auth-api-request.test.ts ├── auth.test.ts ├── base64.test.ts ├── client.test.ts ├── fetch.ts ├── firebase-utils.ts ├── jwk-fetcher.test.ts ├── jwk-utils.ts ├── jws-verifier.test.ts ├── jwt-decoder.test.ts ├── setup.ts ├── token-verifier.test.ts ├── validator.test.ts └── x509.test.ts ├── tsconfig.json ├── tsconfig.main.json ├── tsconfig.module.json └── vitest.config.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v4 13 | with: 14 | version: 9 15 | - name: Setup Node 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 18 19 | cache: 'pnpm' 20 | - run: pnpm install --frozen-lockfile 21 | - run: pnpm test-with-emulator 22 | env: 23 | CI: true 24 | eslint: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: pnpm/action-setup@v4 29 | with: 30 | version: 9 31 | - name: Setup Node 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: 18 35 | cache: 'pnpm' 36 | - run: pnpm install --frozen-lockfile 37 | - run: pnpm lint-fix 38 | - run: pnpm prettier 39 | - name: Auto commit fixed code 40 | id: auto-commit-action 41 | uses: stefanzweifel/git-auto-commit-action@v4 42 | with: 43 | commit_message: Apply auto lint-fix changes 44 | branch: ${{ github.head_ref }} 45 | - name: eslint 46 | if: steps.auto-commit-action.outputs.changes_detected == 'false' 47 | uses: reviewdog/action-eslint@v1 48 | with: 49 | level: warning 50 | reporter: github-pr-review -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | sandbox 3 | 4 | # Cloudflare Workers 5 | worker 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # TypeScript cache 17 | *.tsbuildinfo 18 | 19 | # Dependency directories 20 | node_modules/ 21 | .wrangler/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .npmignore 4 | package-lock.json 5 | .DS_Store 6 | tsconfig.tsbuildinfo 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "printWidth": 120, 4 | "singleQuote": true, 5 | "arrowParens": "avoid", 6 | "semi": true 7 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.codeLens.references": true, 3 | "deno.enablePaths": [ 4 | "./scripts" 5 | ], 6 | "deno.enable": true 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 codehex 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # firebase-auth-cloudflare-workers 2 | 3 | **Zero-dependencies** firebase auth library for Cloudflare Workers. 4 | 5 | - Implemented by only Web Standard API. 6 | - Supported UTF-8. 7 | - Supported Firebase Auth Emulator. 8 | 9 | ## Synopsis 10 | 11 | ```ts 12 | import type { EmulatorEnv } from "firebase-auth-cloudflare-workers"; 13 | import { Auth, WorkersKVStoreSingle } from "firebase-auth-cloudflare-workers"; 14 | 15 | interface Bindings extends EmulatorEnv { 16 | PROJECT_ID: string 17 | PUBLIC_JWK_CACHE_KEY: string 18 | PUBLIC_JWK_CACHE_KV: KVNamespace 19 | FIREBASE_AUTH_EMULATOR_HOST: string 20 | } 21 | 22 | const verifyJWT = async (req: Request, env: Bindings): Promise => { 23 | const authorization = req.headers.get('Authorization') 24 | if (authorization === null) { 25 | return new Response(null, { 26 | status: 400, 27 | }) 28 | } 29 | const jwt = authorization.replace(/Bearer\s+/i, "") 30 | const auth = Auth.getOrInitialize( 31 | env.PROJECT_ID, 32 | WorkersKVStoreSingle.getOrInitialize(env.PUBLIC_JWK_CACHE_KEY, env.PUBLIC_JWK_CACHE_KV) 33 | ) 34 | const firebaseToken = await auth.verifyIdToken(jwt, env) 35 | 36 | return new Response(JSON.stringify(firebaseToken), { 37 | headers: { 38 | "Content-Type": "application/json" 39 | } 40 | }) 41 | } 42 | ``` 43 | 44 | ### wrangler.toml 45 | 46 | ```toml 47 | name = "firebase-auth-example" 48 | compatibility_date = "2022-07-05" 49 | workers_dev = true 50 | 51 | [vars] 52 | FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099" 53 | PROJECT_ID = "example-project12345" 54 | 55 | # Specify cache key to store and get public jwk. 56 | PUBLIC_JWK_CACHE_KEY = "public-jwk-cache-key" 57 | 58 | [[kv_namespaces]] 59 | binding = "PUBLIC_JWK_CACHE_KV" 60 | id = "" 61 | preview_id = "testingId" 62 | ``` 63 | 64 | ### Module Worker syntax 65 | 66 | ```ts 67 | export async function fetch(req: Request, env: Bindings) { 68 | return await verifyJWT(req, env) 69 | } 70 | 71 | export default { fetch }; 72 | ``` 73 | 74 | ### Service Worker syntax 75 | 76 | ```ts 77 | declare global { 78 | const PROJECT_ID: string 79 | const PUBLIC_JWK_CACHE_KEY: string 80 | const PUBLIC_JWK_CACHE_KV: KVNamespace 81 | const FIREBASE_AUTH_EMULATOR_HOST: string 82 | } 83 | 84 | addEventListener('fetch', (event: FetchEvent) => { 85 | // Create env object for verifyIdToken API. 86 | const bindings: EmulatorEnv = { 87 | PROJECT_ID, 88 | PUBLIC_JWK_CACHE_KEY, 89 | PUBLIC_JWK_CACHE_KV, 90 | FIREBASE_AUTH_EMULATOR_HOST, 91 | } 92 | event.respondWith(verifyJWT(event.request, bindings)) 93 | }) 94 | ``` 95 | 96 | ## Install 97 | 98 | You can install from npm registry. 99 | 100 | ``` 101 | $ npm i firebase-auth-cloudflare-workers 102 | ``` 103 | 104 | ## Docs 105 | 106 | - [API](#api) 107 | - [Type](#type) 108 | - [Run example code](#run-example-code) 109 | - [Todo](#todo) 110 | 111 | ## API 112 | 113 | ### `Auth.getOrInitialize(projectId: string, keyStore: KeyStorer, credential?: Credential): Auth` 114 | 115 | Auth is created as a singleton object. This is because the Module Worker syntax only use environment variables at the time of request. 116 | 117 | - `projectId` specifies the ID of the project for which firebase auth is used. 118 | - `keyStore` is used to cache the public key used to validate the Firebase ID token (JWT). 119 | - `credential` is an optional. This is used to utilize Admin APIs such as `createSessionCookie`. Currently, you can specify `ServiceAccountCredential` class, which allows you to use a service account. 120 | 121 | See official document for project ID: https://firebase.google.com/docs/projects/learn-more#project-identifiers 122 | 123 | ### `authObj.verifyIdToken(idToken: string, checkRevoked?: boolean, env?: EmulatorEnv): Promise` 124 | 125 | Verifies a Firebase ID token (JWT). If the token is valid, the promise is fulfilled with the token's decoded claims; otherwise, the promise is rejected. 126 | 127 | See the [ID Token section of the OpenID Connect spec](http://openid.net/specs/openid-connect-core-1_0.html#IDToken) for more information about the specific properties below. 128 | 129 | - `idToken` The ID token to verify. 130 | - `checkRevoked` - Whether to check if the session cookie was revoked. This requires an extra request to the Firebase Auth backend to check the `tokensValidAfterTime` time for the corresponding user. When not specified, this additional check is not performed. 131 | - `env` is an optional parameter. but this is using to detect should use emulator or not. 132 | 133 | ### `authObj.verifySessionCookie(sessionCookie: string, checkRevoked?: boolean, env?: EmulatorEnv): Promise` 134 | 135 | Verifies a Firebase session cookie. Returns a Promise with the cookie claims. Rejects the promise if the cookie could not be verified. 136 | 137 | See [Verify Session Cookies](https://firebase.google.com/docs/auth/admin/manage-cookies#verify_session_cookie_and_check_permissions) for code samples and detailed documentation. 138 | 139 | - `sessionCookie` The session cookie to verify. 140 | - `checkRevoked` - Whether to check if the session cookie was revoked. This requires an extra request to the Firebase Auth backend to check the `tokensValidAfterTime` time for the corresponding user. When not specified, this additional check is not performed. 141 | - `env` is an optional parameter. but this is using to detect should use emulator or not. 142 | 143 | ### `authObj.createSessionCookie(idToken: string, sessionCookieOptions: SessionCookieOptions, env?: EmulatorEnv): Promise` 144 | 145 | Creates a new Firebase session cookie with the specified options. The created JWT string can be set as a server-side session cookie with a custom cookie policy, and be used for session management. The session cookie JWT will have the same payload claims as the provided ID token. See [Manage Session Cookies](https://firebase.google.com/docs/auth/admin/manage-cookies) for code samples and detailed documentation. 146 | 147 | - `idToken` The Firebase ID token to exchange for a session cookie. 148 | - `sessionCookieOptions` The session cookie options which includes custom session duration. 149 | - `env` is an optional parameter. but this is using to detect should use emulator or not. 150 | 151 | **Required** service acccount credential to use this API. You need to set the credentials with `Auth.getOrInitialize`. 152 | 153 | ### `authObj.getUser(uid: string, env?: EmulatorEnv): Promise` 154 | 155 | Gets the user data for the user corresponding to a given `uid`. 156 | 157 | - `uid` corresponding to the user whose data to fetch. 158 | - `env` is an optional parameter. but this is using to detect should use emulator or not. 159 | 160 | ### `authObj.revokeRefreshTokens(uid: string, env?: EmulatorEnv): Promise` 161 | 162 | Revokes all refresh tokens for an existing user. 163 | 164 | - `uid` corresponding to the user whose refresh tokens are to be revoked. 165 | - `env` is an optional parameter. but this is using to detect should use emulator or not. 166 | 167 | ### `authObj.setCustomUserClaims(uid: string, customUserClaims: object | null, env?: EmulatorEnv): Promise` 168 | 169 | Sets additional developer claims on an existing user identified by the provided `uid`, typically used to define user roles and levels of access. These claims should propagate to all devices where the user is already signed in (after token expiration or when token refresh is forced) and the next time the user signs in. If a reserved OIDC claim name is used (sub, iat, iss, etc), an error is thrown. They are set on the authenticated user's ID token JWT. 170 | 171 | - `uid` - The `uid` of the user to edit. 172 | - `customUserClaims` The developer claims to set. If null is passed, existing custom claims are deleted. Passing a custom claims payload larger than 1000 bytes will throw an error. Custom claims are added to the user's ID token which is transmitted on every authenticated request. For profile non-access related user attributes, use database or other separate storage systems. 173 | - `env` is an optional parameter. but this is using to detect should use emulator or not. 174 | 175 | ### `WorkersKVStoreSingle.getOrInitialize(cacheKey: string, cfKVNamespace: KVNamespace): WorkersKVStoreSingle` 176 | 177 | WorkersKVStoreSingle is created as a singleton object. This is because the Module Worker syntax only use environment variables at the time of request. 178 | 179 | This caches the public key used to verify the Firebase ID token in the [Workers KV](https://developers.cloudflare.com/workers/runtime-apis/kv/). 180 | 181 | This is implemented `KeyStorer` interface. 182 | 183 | - `cacheKey` specifies the key of the public key cache. 184 | - `cfKVNamespace` specifies the KV namespace which is bound your workers. 185 | 186 | ### `AdminAuthApiClient.getOrInitialize(projectId: string, credential: Credential, retryConfig?: RetryConfig): AdminAuthApiClient` 187 | 188 | AdminAuthApiClient is created as a singleton object. This is because the Module Worker syntax only use environment variables at the time of request. 189 | 190 | You can send request with the [Admin Auth API](https://cloud.google.com/identity-platform/docs/reference/rest). To generate an access token, you will use the `Credential` class. For instance, if you want to generate an access token from a Service Account JSON, you need to specify `ServiceAccountCredential` as a parameter during initialization. 191 | 192 | By specifying the [`roles/firebaseauth.admin`](https://firebase.google.com/docs/projects/iam/roles-predefined-product#app-distro) role to the Service Account, it becomes available for use. If you want finer control over permissions, create a Custom Role based on the [Access Control](https://cloud.google.com/identity-platform/docs/access-control) guide and assign it to the Service Account. 193 | 194 | ### `emulatorHost(env?: EmulatorEnv): string | undefined` 195 | 196 | Returns the host of your Firebase Auth Emulator. For example, this case returns `"127.0.0.1:9099"` if you configured like below. 197 | 198 | `wrangler.toml` 199 | 200 | ```toml 201 | [vars] 202 | FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099" 203 | ``` 204 | 205 | ### `useEmulator(env?: EmulatorEnv): boolean` 206 | 207 | This is a wrapper `emulatorHost` function. 208 | 209 | When true the SDK should communicate with the Auth Emulator for all API calls and also produce unsigned tokens. 210 | 211 | ## Type 212 | 213 | ### `KeyStorer` 214 | 215 | This is an interface to cache the public key used to verify the Firebase ID token. By creating a class that implemented this interface, you can cache it in any storage of your choice. 216 | 217 | ```ts 218 | interface KeyStorer { 219 | get(): Promise; 220 | put(value: string, expirationTtl: number): Promise; 221 | } 222 | ``` 223 | 224 | ### `EmulatorEnv` 225 | 226 | ```ts 227 | interface EmulatorEnv { 228 | FIREBASE_AUTH_EMULATOR_HOST: string | undefined 229 | } 230 | ``` 231 | 232 | ### `FirebaseIdToken` 233 | 234 | Interface representing a decoded Firebase ID token, returned from the `authObj.verifyIdToken` method. 235 | 236 | ## Run example code 237 | 238 | I put an [example](https://github.com/Code-Hex/firebase-auth-cloudflare-workers/tree/master/example) directory as Module Worker Syntax. this is explanation how to run the code. 239 | 240 | 1. Clone this repository and change your directory to it. 241 | 2. Install dev dependencies as `pnpm` command. 242 | 3. Run firebase auth emulator by `$ pnpm start-firebase-emulator` 243 | 4. Access to Emulator UI in your favorite browser. 244 | 5. Create a new user on Emulator UI. (email: `test@example.com` password: `test1234`) 245 | 6. Run example code on local (may serve as `localhost:8787`) by `$ pnpm start-example` 246 | 7. Get jwt for created user by `$ curl -s http://localhost:8787/get-jwt | jq .idToken -r` 247 | 8. Try authorization with user jwt `$ curl http://localhost:8787/ -H 'Authorization: Bearer PASTE-JWT-HERE'` 248 | 249 | ### for Session Cookie 250 | 251 | You can try session cookie with your browser. 252 | 253 | Access to `/admin/login` after started up Emulator and created an account (email: `test@example.com` password: `test1234`). 254 | 255 | ## Todo 256 | 257 | ### Non-required service account key. 258 | 259 | - [x] IDToken verification 260 | 261 | ### Required service account key. 262 | 263 | - [x] Check authorized user is deleted (revoked) 264 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tseslint from 'typescript-eslint'; 2 | 3 | export default tseslint.config({ 4 | ignores: ['dist', 'vitest.config.ts', 'tests'], 5 | languageOptions: { 6 | parserOptions: { 7 | project: './tsconfig.json', 8 | }, 9 | }, 10 | rules: { 11 | quotes: ['error', 'single'], 12 | semi: 'off', 13 | 'no-debugger': 'error', 14 | 'no-empty': ['warn', { allowEmptyCatch: true }], 15 | 'no-process-exit': 'off', 16 | 'no-useless-escape': 'off', 17 | 'prefer-const': [ 18 | 'warn', 19 | { 20 | destructuring: 'all', 21 | }, 22 | ], 23 | 'sort-imports': 'off', 24 | 'node/no-missing-import': 'off', 25 | 'node/no-missing-require': 'off', 26 | 'node/no-deprecated-api': 'off', 27 | 'node/no-unpublished-import': 'off', 28 | 'node/no-unpublished-require': 'off', 29 | 'node/no-unsupported-features/es-syntax': 'off', 30 | '@typescript-eslint/no-empty-interface': 'off', 31 | '@typescript-eslint/no-inferrable-types': 'off', 32 | '@typescript-eslint/no-var-requires': 'off', 33 | '@typescript-eslint/no-explicit-any': 'off', 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /example/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | import { getCookie, setCookie } from 'hono/cookie'; 3 | import { csrf } from 'hono/csrf'; 4 | import { html } from 'hono/html'; 5 | import { Auth, ServiceAccountCredential, emulatorHost, WorkersKVStoreSingle, AdminAuthApiClient } from '../src'; 6 | 7 | type Env = { 8 | EMAIL_ADDRESS: string; 9 | PASSWORD: string; 10 | PUBLIC_JWK_CACHE_KV: KVNamespace; 11 | PROJECT_ID: string; 12 | PUBLIC_JWK_CACHE_KEY: string; 13 | 14 | FIREBASE_AUTH_EMULATOR_HOST: string; // satisfied EmulatorEnv 15 | // Set JSON as string. 16 | // See: https://cloud.google.com/iam/docs/keys-create-delete 17 | SERVICE_ACCOUNT_JSON: string; 18 | }; 19 | 20 | const app = new Hono<{ Bindings: Env }>(); 21 | 22 | const signInPath = '/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=test1234'; 23 | 24 | app.get('/get-jwt', async c => { 25 | const firebaseEmuHost = emulatorHost(c.env); 26 | const firebaseEmulatorSignInUrl = 'http://' + firebaseEmuHost + signInPath; 27 | return await fetch(firebaseEmulatorSignInUrl, { 28 | method: 'POST', 29 | body: JSON.stringify({ 30 | email: c.env.EMAIL_ADDRESS, 31 | password: c.env.PASSWORD, 32 | returnSecureToken: true, 33 | }), 34 | headers: { 35 | 'Content-Type': 'application/json', 36 | }, 37 | }); 38 | }); 39 | 40 | app.post('/verify-header', async c => { 41 | const authorization = c.req.raw.headers.get('Authorization'); 42 | if (authorization === null) { 43 | return new Response(null, { 44 | status: 400, 45 | }); 46 | } 47 | const jwt = authorization.replace(/Bearer\s+/i, ''); 48 | const auth = Auth.getOrInitialize( 49 | c.env.PROJECT_ID, 50 | WorkersKVStoreSingle.getOrInitialize(c.env.PUBLIC_JWK_CACHE_KEY, c.env.PUBLIC_JWK_CACHE_KV) 51 | ); 52 | const firebaseToken = await auth.verifyIdToken(jwt, false, c.env); 53 | 54 | return new Response(JSON.stringify(firebaseToken), { 55 | headers: { 56 | 'Content-Type': 'application/json', 57 | }, 58 | }); 59 | }); 60 | 61 | app.use('/admin/*', csrf()); 62 | 63 | app.get('/admin/login', async c => { 64 | const content = await html` 65 | 66 | 67 | Login 68 | 69 | 70 |

Login Page

71 | 72 | 142 | 143 | `; 144 | return c.html(content); 145 | }); 146 | 147 | app.post('/admin/login_session', async c => { 148 | const json = await c.req.json(); 149 | const idToken = json.idToken; 150 | if (!idToken || typeof idToken !== 'string') { 151 | return c.json({ message: 'invalid idToken' }, 400); 152 | } 153 | // Set session expiration to 5 days. 154 | const expiresIn = 60 * 60 * 24 * 5; 155 | // Create the session cookie. This will also verify the ID token in the process. 156 | // The session cookie will have the same claims as the ID token. 157 | // To only allow session cookie setting on recent sign-in, auth_time in ID token 158 | // can be checked to ensure user was recently signed in before creating a session cookie. 159 | const auth = AdminAuthApiClient.getOrInitialize( 160 | c.env.PROJECT_ID, 161 | new ServiceAccountCredential(c.env.SERVICE_ACCOUNT_JSON) 162 | ); 163 | const sessionCookie = await auth.createSessionCookie( 164 | idToken, 165 | expiresIn, 166 | c.env // This valus must be removed in real world 167 | ); 168 | setCookie(c, 'session', sessionCookie, { 169 | maxAge: expiresIn, 170 | httpOnly: true, 171 | // secure: true // set this in real world 172 | }); 173 | return c.json({ message: 'success' }); 174 | }); 175 | 176 | app.get('/admin/profile', async c => { 177 | const session = getCookie(c, 'session') ?? ''; 178 | 179 | const auth = Auth.getOrInitialize( 180 | c.env.PROJECT_ID, 181 | WorkersKVStoreSingle.getOrInitialize(c.env.PUBLIC_JWK_CACHE_KEY, c.env.PUBLIC_JWK_CACHE_KV) 182 | ); 183 | 184 | try { 185 | const decodedToken = await auth.verifySessionCookie( 186 | session, 187 | false, 188 | c.env // This valus must be removed in real world 189 | ); 190 | return c.json(decodedToken); 191 | } catch (err) { 192 | return c.redirect('/admin/login'); 193 | } 194 | }); 195 | 196 | export default app; 197 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["@cloudflare/workers-types"] 5 | }, 6 | "include": ["../src/**/*", "**/*"] 7 | } -------------------------------------------------------------------------------- /example/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "firebase-auth-example" 2 | compatibility_date = "2024-12-22" 3 | workers_dev = true 4 | main = "index.ts" 5 | 6 | tsconfig = "./tsconfig.json" 7 | 8 | [vars] 9 | # Please set FIREBASE_AUTH_EMULATOR_HOST environment variable in your wrangler.toml. 10 | # see: https://developers.cloudflare.com/workers/platform/environment-variables/#environment-variables-via-wrangler 11 | # 12 | # Example for wrangler.toml 13 | # [vars] 14 | # FIREBASE_AUTH_EMULATOR_HOST = "localhost:8080" 15 | # 16 | # Override values for `--env production` usage 17 | # [env.production.vars] 18 | # FIREBASE_AUTH_EMULATOR_HOST = "" 19 | FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099" 20 | 21 | # See: https://cloud.google.com/iam/docs/keys-create-delete 22 | SERVICE_ACCOUNT_JSON = '{"type":"service_account","project_id":"project12345","private_key_id":"xxxxxxxxxxxxxxxxx","private_key":"-----BEGIN PRIVATE KEY-----XXXXXX-----END PRIVATE KEY-----\n","client_email":"xxxxx@xxxxxx.iam.gserviceaccount.com","client_id":"xxxxxx","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/xxxxx@xxxxxx.iam.gserviceaccount.com"}' 23 | 24 | # Setup user account in Emulator UI 25 | EMAIL_ADDRESS = "test@example.com" 26 | PASSWORD = "test1234" 27 | 28 | PROJECT_ID = "project12345" # see package.json (for emulator) 29 | 30 | # Specify cache key to store and get public jwk. 31 | PUBLIC_JWK_CACHE_KEY = "public-jwk-cache-key" 32 | 33 | [[kv_namespaces]] 34 | binding = "PUBLIC_JWK_CACHE_KV" 35 | id = "testingId" 36 | preview_id = "testingId" -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "emulators": { 3 | "auth": { 4 | "port": 9099 5 | }, 6 | "ui": { 7 | "enabled": true 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firebase-auth-cloudflare-workers", 3 | "version": "2.0.6", 4 | "description": "Zero-dependencies firebase auth library for Cloudflare Workers.", 5 | "author": "codehex", 6 | "license": "MIT", 7 | "main": "dist/main/index.js", 8 | "typings": "dist/main/index.d.ts", 9 | "module": "dist/module/index.js", 10 | "files": [ 11 | "dist/**/*.{js,ts}", 12 | "LICENSE", 13 | "README.md" 14 | ], 15 | "scripts": { 16 | "test": "vitest run", 17 | "test-with-emulator": "firebase emulators:exec --project project12345 'vitest run'", 18 | "build": "deno run --allow-read --allow-write scripts/version.ts && run-p build:*", 19 | "build:main": "tsc -p tsconfig.main.json", 20 | "build:module": "tsc -p tsconfig.module.json", 21 | "start-firebase-emulator": "firebase emulators:start --project project12345", 22 | "start-example": "wrangler dev example/index.ts --config=example/wrangler.toml --local=true", 23 | "prettier": "prettier --write --list-different \"**/*.ts\"", 24 | "prettier:check": "prettier --check \"**/*.ts\"", 25 | "lint": "eslint .", 26 | "lint-fix": "eslint --fix .", 27 | "prepublish": "run-p build:*", 28 | "wrangler": "wrangler", 29 | "version": "pnpm run build && git add -A src/version.ts" 30 | }, 31 | "devDependencies": { 32 | "@cloudflare/workers-types": "^4.20241224.0", 33 | "@eslint/js": "^9.17.0", 34 | "eslint": "^9.17.0", 35 | "firebase-tools": "^13.29.1", 36 | "hono": "^4.6.15", 37 | "miniflare": "^3.20241218.0", 38 | "npm-run-all": "^4.1.5", 39 | "prettier": "^3.4.2", 40 | "typescript": "^5.7.2", 41 | "typescript-eslint": "^8.18.2", 42 | "undici": "^6.6.2", 43 | "vitest": "^2.1.8", 44 | "wrangler": "^3.99.0" 45 | }, 46 | "keywords": [ 47 | "web", 48 | "app", 49 | "jwt", 50 | "firebase", 51 | "cloudflare", 52 | "workers" 53 | ], 54 | "bugs": { 55 | "url": "https://github.com/Code-Hex/firebase-auth-cloudflare-workers/issues" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/version.ts: -------------------------------------------------------------------------------- 1 | const decoder = new TextDecoder('utf-8'); 2 | const encoder = new TextEncoder(); 3 | 4 | async function updateVersion() { 5 | const packageJsonText = decoder.decode(await Deno.readFile('./package.json')); 6 | const packageJson = JSON.parse(packageJsonText); 7 | const version = packageJson.version; 8 | 9 | const versionTsContent = `export const version = '${version}';\n`; 10 | await Deno.writeFile('src/version.ts', encoder.encode(versionTsContent)); 11 | } 12 | 13 | updateVersion().catch(error => { 14 | console.error('failed to update version.ts:', error); 15 | Deno.exit(1); 16 | }); 17 | -------------------------------------------------------------------------------- /src/api-requests.ts: -------------------------------------------------------------------------------- 1 | /** Http method type definition. */ 2 | export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'; 3 | /** API callback function type definition. */ 4 | export type ApiCallbackFunction = (data: object) => void; 5 | 6 | /** 7 | * Class that defines all the settings for the backend API endpoint. 8 | * 9 | * @param endpoint - The Firebase Auth backend endpoint. 10 | * @param httpMethod - The http method for that endpoint. 11 | * @constructor 12 | */ 13 | export class ApiSettings { 14 | private requestValidator: ApiCallbackFunction; 15 | private responseValidator: ApiCallbackFunction; 16 | 17 | constructor( 18 | private version: 'v1' | 'v2', 19 | private endpoint: string, 20 | private httpMethod: HttpMethod = 'POST' 21 | ) { 22 | this.setRequestValidator(null).setResponseValidator(null); 23 | } 24 | 25 | /** @returns The backend API resource version. */ 26 | public getVersion(): 'v1' | 'v2' { 27 | return this.version; 28 | } 29 | 30 | /** @returns The backend API endpoint. */ 31 | public getEndpoint(): string { 32 | return this.endpoint; 33 | } 34 | 35 | /** @returns The request HTTP method. */ 36 | public getHttpMethod(): HttpMethod { 37 | return this.httpMethod; 38 | } 39 | 40 | /** 41 | * @param requestValidator - The request validator. 42 | * @returns The current API settings instance. 43 | */ 44 | public setRequestValidator(requestValidator: ApiCallbackFunction | null): ApiSettings { 45 | const nullFunction: ApiCallbackFunction = () => undefined; 46 | this.requestValidator = requestValidator || nullFunction; 47 | return this; 48 | } 49 | 50 | /** @returns The request validator. */ 51 | public getRequestValidator(): ApiCallbackFunction { 52 | return this.requestValidator; 53 | } 54 | 55 | /** 56 | * @param responseValidator - The response validator. 57 | * @returns The current API settings instance. 58 | */ 59 | public setResponseValidator(responseValidator: ApiCallbackFunction | null): ApiSettings { 60 | const nullFunction: ApiCallbackFunction = () => undefined; 61 | this.responseValidator = responseValidator || nullFunction; 62 | return this; 63 | } 64 | 65 | /** @returns The response validator. */ 66 | public getResponseValidator(): ApiCallbackFunction { 67 | return this.responseValidator; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/auth-api-requests.ts: -------------------------------------------------------------------------------- 1 | import { ApiSettings } from './api-requests'; 2 | import { BaseClient } from './client'; 3 | import type { EmulatorEnv } from './emulator'; 4 | import { AuthClientErrorCode, FirebaseAuthError } from './errors'; 5 | import { UserRecord } from './user-record'; 6 | import { isNonEmptyString, isNumber, isObject, isUid } from './validator'; 7 | 8 | /** Minimum allowed session cookie duration in seconds (5 minutes). */ 9 | const MIN_SESSION_COOKIE_DURATION_SECS = 5 * 60; 10 | 11 | /** Maximum allowed session cookie duration in seconds (2 weeks). */ 12 | const MAX_SESSION_COOKIE_DURATION_SECS = 14 * 24 * 60 * 60; 13 | 14 | /** List of reserved claims which cannot be provided when creating a custom token. */ 15 | const RESERVED_CLAIMS = [ 16 | 'acr', 17 | 'amr', 18 | 'at_hash', 19 | 'aud', 20 | 'auth_time', 21 | 'azp', 22 | 'cnf', 23 | 'c_hash', 24 | 'exp', 25 | 'iat', 26 | 'iss', 27 | 'jti', 28 | 'nbf', 29 | 'nonce', 30 | 'sub', 31 | 'firebase', 32 | ]; 33 | 34 | /** Maximum allowed number of characters in the custom claims payload. */ 35 | const MAX_CLAIMS_PAYLOAD_SIZE = 1000; 36 | 37 | /** 38 | * Instantiates the createSessionCookie endpoint settings. 39 | * 40 | * @internal 41 | */ 42 | export const FIREBASE_AUTH_CREATE_SESSION_COOKIE = new ApiSettings('v1', ':createSessionCookie', 'POST') 43 | // Set request validator. 44 | .setRequestValidator((request: any) => { 45 | // Validate the ID token is a non-empty string. 46 | if (!isNonEmptyString(request.idToken)) { 47 | throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN); 48 | } 49 | // Validate the custom session cookie duration. 50 | if ( 51 | !isNumber(request.validDuration) || 52 | request.validDuration < MIN_SESSION_COOKIE_DURATION_SECS || 53 | request.validDuration > MAX_SESSION_COOKIE_DURATION_SECS 54 | ) { 55 | throw new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION); 56 | } 57 | }) 58 | // Set response validator. 59 | .setResponseValidator((response: any) => { 60 | // Response should always contain the session cookie. 61 | if (!isNonEmptyString(response.sessionCookie)) { 62 | throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); 63 | } 64 | }); 65 | 66 | interface GetAccountInfoRequest { 67 | localId?: string[]; 68 | email?: string[]; 69 | phoneNumber?: string[]; 70 | federatedUserId?: Array<{ 71 | providerId: string; 72 | rawId: string; 73 | }>; 74 | } 75 | 76 | /** 77 | * Instantiates the getAccountInfo endpoint settings. 78 | * 79 | * @internal 80 | */ 81 | export const FIREBASE_AUTH_GET_ACCOUNT_INFO = new ApiSettings('v1', '/accounts:lookup', 'POST') 82 | // Set request validator. 83 | .setRequestValidator((request: GetAccountInfoRequest) => { 84 | if (!request.localId && !request.email && !request.phoneNumber && !request.federatedUserId) { 85 | throw new FirebaseAuthError( 86 | AuthClientErrorCode.INTERNAL_ERROR, 87 | 'INTERNAL ASSERT FAILED: Server request is missing user identifier' 88 | ); 89 | } 90 | }) 91 | // Set response validator. 92 | .setResponseValidator((response: any) => { 93 | if (!response.users || !response.users.length) { 94 | throw new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); 95 | } 96 | }); 97 | 98 | /** 99 | * Instantiates the revokeRefreshTokens endpoint settings for updating existing accounts. 100 | * 101 | * @internal 102 | * @link https://github.com/firebase/firebase-admin-node/blob/9955bca47249301aa970679ae99fe01d54adf6a8/src/auth/auth-api-request.ts#L746 103 | */ 104 | export const FIREBASE_AUTH_REVOKE_REFRESH_TOKENS = new ApiSettings('v1', '/accounts:update', 'POST') 105 | // Set request validator. 106 | .setRequestValidator((request: any) => { 107 | // localId is a required parameter. 108 | if (typeof request.localId === 'undefined') { 109 | throw new FirebaseAuthError( 110 | AuthClientErrorCode.INTERNAL_ERROR, 111 | 'INTERNAL ASSERT FAILED: Server request is missing user identifier' 112 | ); 113 | } 114 | // validSince should be a number. 115 | if (typeof request.validSince !== 'undefined' && !isNumber(request.validSince)) { 116 | throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TOKENS_VALID_AFTER_TIME); 117 | } 118 | }) 119 | // Set response validator. 120 | .setResponseValidator((response: any) => { 121 | // If the localId is not returned, then the request failed. 122 | if (!response.localId) { 123 | throw new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); 124 | } 125 | }); 126 | 127 | /** 128 | * Instantiates the setCustomUserClaims endpoint settings for updating existing accounts. 129 | * 130 | * @internal 131 | * @link https://github.com/firebase/firebase-admin-node/blob/9955bca47249301aa970679ae99fe01d54adf6a8/src/auth/auth-api-request.ts#L746 132 | */ 133 | export const FIREBASE_AUTH_SET_CUSTOM_USER_CLAIMS = new ApiSettings('v1', '/accounts:update', 'POST') 134 | // Set request validator. 135 | .setRequestValidator((request: any) => { 136 | // localId is a required parameter. 137 | if (typeof request.localId === 'undefined') { 138 | throw new FirebaseAuthError( 139 | AuthClientErrorCode.INTERNAL_ERROR, 140 | 'INTERNAL ASSERT FAILED: Server request is missing user identifier' 141 | ); 142 | } 143 | // customAttributes should be stringified JSON with no blacklisted claims. 144 | // The payload should not exceed 1KB. 145 | if (typeof request.customAttributes !== 'undefined') { 146 | let developerClaims: object; 147 | try { 148 | developerClaims = JSON.parse(request.customAttributes); 149 | } catch (error) { 150 | if (error instanceof Error) { 151 | // JSON parsing error. This should never happen as we stringify the claims internally. 152 | // However, we still need to check since setAccountInfo via edit requests could pass 153 | // this field. 154 | throw new FirebaseAuthError(AuthClientErrorCode.INVALID_CLAIMS, error.message); 155 | } 156 | throw error; 157 | } 158 | const invalidClaims: string[] = []; 159 | // Check for any invalid claims. 160 | RESERVED_CLAIMS.forEach(blacklistedClaim => { 161 | if (Object.prototype.hasOwnProperty.call(developerClaims, blacklistedClaim)) { 162 | invalidClaims.push(blacklistedClaim); 163 | } 164 | }); 165 | // Throw an error if an invalid claim is detected. 166 | if (invalidClaims.length > 0) { 167 | throw new FirebaseAuthError( 168 | AuthClientErrorCode.FORBIDDEN_CLAIM, 169 | invalidClaims.length > 1 170 | ? `Developer claims "${invalidClaims.join('", "')}" are reserved and cannot be specified.` 171 | : `Developer claim "${invalidClaims[0]}" is reserved and cannot be specified.` 172 | ); 173 | } 174 | // Check claims payload does not exceed maxmimum size. 175 | if (request.customAttributes.length > MAX_CLAIMS_PAYLOAD_SIZE) { 176 | throw new FirebaseAuthError( 177 | AuthClientErrorCode.CLAIMS_TOO_LARGE, 178 | `Developer claims payload should not exceed ${MAX_CLAIMS_PAYLOAD_SIZE} characters.` 179 | ); 180 | } 181 | } 182 | }) 183 | // Set response validator. 184 | .setResponseValidator((response: any) => { 185 | // If the localId is not returned, then the request failed. 186 | if (!response.localId) { 187 | throw new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); 188 | } 189 | }); 190 | 191 | export class AuthApiClient extends BaseClient { 192 | /** 193 | * Creates a new Firebase session cookie with the specified duration that can be used for 194 | * session management (set as a server side session cookie with custom cookie policy). 195 | * The session cookie JWT will have the same payload claims as the provided ID token. 196 | * 197 | * @param idToken - The Firebase ID token to exchange for a session cookie. 198 | * @param expiresIn - The session cookie duration in milliseconds. 199 | * @param env - An optional parameter specifying the environment in which the function is running. 200 | * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. 201 | * If not specified, the function will assume it is running in a production environment. 202 | * 203 | * @returns A promise that resolves on success with the created session cookie. 204 | */ 205 | public async createSessionCookie(idToken: string, expiresIn: number, env?: EmulatorEnv): Promise { 206 | const request = { 207 | idToken, 208 | // Convert to seconds. 209 | validDuration: expiresIn / 1000, 210 | }; 211 | const res = await this.fetch<{ sessionCookie: string }>(FIREBASE_AUTH_CREATE_SESSION_COOKIE, request, env); 212 | return res.sessionCookie; 213 | } 214 | 215 | /** 216 | * Looks up a user by uid. 217 | * 218 | * @param uid - The uid of the user to lookup. 219 | * @param env - An optional parameter specifying the environment in which the function is running. 220 | * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. 221 | * If not specified, the function will assume it is running in a production environment. 222 | * @returns A promise that resolves with the user information. 223 | */ 224 | public async getAccountInfoByUid(uid: string, env?: EmulatorEnv): Promise { 225 | if (!isUid(uid)) { 226 | throw new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); 227 | } 228 | 229 | const request = { 230 | localId: [uid], 231 | }; 232 | const res = await this.fetch(FIREBASE_AUTH_GET_ACCOUNT_INFO, request, env); 233 | // Returns the user record populated with server response. 234 | return new UserRecord((res as any).users[0]); 235 | } 236 | 237 | /** 238 | * Revokes all refresh tokens for the specified user identified by the uid provided. 239 | * In addition to revoking all refresh tokens for a user, all ID tokens issued 240 | * before revocation will also be revoked on the Auth backend. Any request with an 241 | * ID token generated before revocation will be rejected with a token expired error. 242 | * Note that due to the fact that the timestamp is stored in seconds, any tokens minted in 243 | * the same second as the revocation will still be valid. If there is a chance that a token 244 | * was minted in the last second, delay for 1 second before revoking. 245 | * 246 | * @param uid - The user whose tokens are to be revoked. 247 | * @param env - An optional parameter specifying the environment in which the function is running. 248 | * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. 249 | * If not specified, the function will assume it is running in a production environment. 250 | * @returns A promise that resolves when the operation completes 251 | * successfully with the user id of the corresponding user. 252 | */ 253 | public async revokeRefreshTokens(uid: string, env?: EmulatorEnv): Promise { 254 | // Validate user UID. 255 | if (!isUid(uid)) { 256 | throw new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); 257 | } 258 | const request: any = { 259 | localId: uid, 260 | // validSince is in UTC seconds. 261 | validSince: Math.floor(new Date().getTime() / 1000), 262 | }; 263 | const res = await this.fetch<{ localId: string }>(FIREBASE_AUTH_REVOKE_REFRESH_TOKENS, request, env); 264 | return res.localId; 265 | } 266 | 267 | /** 268 | * Sets additional developer claims on an existing user identified by provided UID. 269 | * 270 | * @param uid - The user to edit. 271 | * @param customUserClaims - The developer claims to set. 272 | * @param env - An optional parameter specifying the environment in which the function is running. 273 | * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. 274 | * If not specified, the function will assume it is running in a production environment. 275 | * @returns A promise that resolves when the operation completes 276 | * with the user id that was edited. 277 | */ 278 | public async setCustomUserClaims(uid: string, customUserClaims: object | null, env?: EmulatorEnv): Promise { 279 | // Validate user UID. 280 | if (!isUid(uid)) { 281 | throw new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); 282 | } else if (!isObject(customUserClaims)) { 283 | throw new FirebaseAuthError( 284 | AuthClientErrorCode.INVALID_ARGUMENT, 285 | 'CustomUserClaims argument must be an object or null.' 286 | ); 287 | } 288 | // Delete operation. Replace null with an empty object. 289 | if (customUserClaims === null) { 290 | customUserClaims = {}; 291 | } 292 | // Construct custom user attribute editting request. 293 | const request: any = { 294 | localId: uid, 295 | customAttributes: JSON.stringify(customUserClaims), 296 | }; 297 | const res = await this.fetch<{ localId: string }>(FIREBASE_AUTH_SET_CUSTOM_USER_CLAIMS, request, env); 298 | return res.localId; 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { AuthApiClient } from './auth-api-requests'; 2 | import type { Credential } from './credential'; 3 | import type { EmulatorEnv } from './emulator'; 4 | import { useEmulator } from './emulator'; 5 | import type { ErrorInfo } from './errors'; 6 | import { AppErrorCodes, AuthClientErrorCode, FirebaseAppError, FirebaseAuthError } from './errors'; 7 | import type { KeyStorer } from './key-store'; 8 | import type { FirebaseIdToken, FirebaseTokenVerifier } from './token-verifier'; 9 | import { createIdTokenVerifier, createSessionCookieVerifier } from './token-verifier'; 10 | import type { UserRecord } from './user-record'; 11 | import { isNonNullObject, isNumber } from './validator'; 12 | 13 | export class BaseAuth { 14 | /** @internal */ 15 | protected readonly idTokenVerifier: FirebaseTokenVerifier; 16 | protected readonly sessionCookieVerifier: FirebaseTokenVerifier; 17 | private readonly _authApiClient?: AuthApiClient; 18 | 19 | constructor(projectId: string, keyStore: KeyStorer, credential?: Credential) { 20 | this.idTokenVerifier = createIdTokenVerifier(projectId, keyStore); 21 | this.sessionCookieVerifier = createSessionCookieVerifier(projectId, keyStore); 22 | 23 | if (credential) { 24 | this._authApiClient = new AuthApiClient(projectId, credential); 25 | } 26 | } 27 | 28 | private get authApiClient(): AuthApiClient { 29 | if (this._authApiClient) { 30 | return this._authApiClient; 31 | } 32 | throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, 'Service account must be required in initialization.'); 33 | } 34 | 35 | /** 36 | * Verifies a Firebase ID token (JWT). If the token is valid, the promise is 37 | * fulfilled with the token's decoded claims; otherwise, the promise is 38 | * rejected. 39 | * 40 | * If `checkRevoked` is set to true, first verifies whether the corresponding 41 | * user is disabled. If yes, an `auth/user-disabled` error is thrown. If no, 42 | * verifies if the session corresponding to the ID token was revoked. If the 43 | * corresponding user's session was invalidated, an `auth/id-token-revoked` 44 | * error is thrown. If not specified the check is not applied. 45 | * 46 | * See {@link https://firebase.google.com/docs/auth/admin/verify-id-tokens | Verify ID Tokens} 47 | * for code samples and detailed documentation. 48 | * 49 | * @param idToken - The ID token to verify. 50 | * @param checkRevoked - Whether to check if the ID token was revoked. 51 | * This requires an extra request to the Firebase Auth backend to check 52 | * the `tokensValidAfterTime` time for the corresponding user. 53 | * When not specified, this additional check is not applied. 54 | * @param env - An optional parameter specifying the environment in which the function is running. 55 | * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. 56 | * If not specified, the function will assume it is running in a production environment. 57 | * @param clockSkewSeconds - The number of seconds to tolerate when checking the `iat`. 58 | * This is to deal with small clock differences among different servers. 59 | * @returns A promise fulfilled with the 60 | * token's decoded claims if the ID token is valid; otherwise, a rejected 61 | * promise. 62 | */ 63 | public async verifyIdToken( 64 | idToken: string, 65 | checkRevoked = false, 66 | env?: EmulatorEnv, 67 | clockSkewSeconds?: number 68 | ): Promise { 69 | const isEmulator = useEmulator(env); 70 | const decodedIdToken = await this.idTokenVerifier.verifyJWT(idToken, isEmulator, clockSkewSeconds); 71 | // Whether to check if the token was revoked. 72 | if (checkRevoked) { 73 | return await this.verifyDecodedJWTNotRevokedOrDisabled(decodedIdToken, AuthClientErrorCode.ID_TOKEN_REVOKED, env); 74 | } 75 | return decodedIdToken; 76 | } 77 | 78 | /** 79 | * Creates a new Firebase session cookie with the specified options. The created 80 | * JWT string can be set as a server-side session cookie with a custom cookie 81 | * policy, and be used for session management. The session cookie JWT will have 82 | * the same payload claims as the provided ID token. 83 | * 84 | * See {@link https://firebase.google.com/docs/auth/admin/manage-cookies | Manage Session Cookies} 85 | * for code samples and detailed documentation. 86 | * 87 | * @param idToken - The Firebase ID token to exchange for a session 88 | * cookie. 89 | * @param sessionCookieOptions - The session 90 | * cookie options which includes custom session duration. 91 | * @param env - An optional parameter specifying the environment in which the function is running. 92 | * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. 93 | * If not specified, the function will assume it is running in a production environment. 94 | * 95 | * @returns A promise that resolves on success with the 96 | * created session cookie. 97 | */ 98 | public async createSessionCookie( 99 | idToken: string, 100 | sessionCookieOptions: SessionCookieOptions, 101 | env?: EmulatorEnv 102 | ): Promise { 103 | // Return rejected promise if expiresIn is not available. 104 | if (!isNonNullObject(sessionCookieOptions) || !isNumber(sessionCookieOptions.expiresIn)) { 105 | throw new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION); 106 | } 107 | return await this.authApiClient.createSessionCookie(idToken, sessionCookieOptions.expiresIn, env); 108 | } 109 | 110 | /** 111 | * Verifies a Firebase session cookie. Returns a Promise with the cookie claims. 112 | * Rejects the promise if the cookie could not be verified. 113 | * 114 | * If `checkRevoked` is set to true, first verifies whether the corresponding 115 | * user is disabled: If yes, an `auth/user-disabled` error is thrown. If no, 116 | * verifies if the session corresponding to the session cookie was revoked. 117 | * If the corresponding user's session was invalidated, an 118 | * `auth/session-cookie-revoked` error is thrown. If not specified the check 119 | * is not performed. 120 | * 121 | * See {@link https://firebase.google.com/docs/auth/admin/manage-cookies#verify_session_cookie_and_check_permissions | 122 | * Verify Session Cookies} 123 | * for code samples and detailed documentation 124 | * 125 | * @param sessionCookie - The session cookie to verify. 126 | * @param checkRevoked - Whether to check if the session cookie was 127 | * revoked. This requires an extra request to the Firebase Auth backend to 128 | * check the `tokensValidAfterTime` time for the corresponding user. 129 | * When not specified, this additional check is not performed. 130 | * @param env - An optional parameter specifying the environment in which the function is running. 131 | * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. 132 | * If not specified, the function will assume it is running in a production environment. 133 | * 134 | * @returns A promise fulfilled with the 135 | * session cookie's decoded claims if the session cookie is valid; otherwise, 136 | * a rejected promise. 137 | */ 138 | public async verifySessionCookie( 139 | sessionCookie: string, 140 | checkRevoked = false, 141 | env?: EmulatorEnv 142 | ): Promise { 143 | const isEmulator = useEmulator(env); 144 | const decodedIdToken = await this.sessionCookieVerifier.verifyJWT(sessionCookie, isEmulator); 145 | // Whether to check if the token was revoked. 146 | if (checkRevoked) { 147 | return await this.verifyDecodedJWTNotRevokedOrDisabled( 148 | decodedIdToken, 149 | AuthClientErrorCode.SESSION_COOKIE_REVOKED, 150 | env 151 | ); 152 | } 153 | return decodedIdToken; 154 | } 155 | 156 | /** 157 | * Gets the user data for the user corresponding to a given `uid`. 158 | * 159 | * See {@link https://firebase.google.com/docs/auth/admin/manage-users#retrieve_user_data | Retrieve user data} 160 | * for code samples and detailed documentation. 161 | * 162 | * @param uid - The `uid` corresponding to the user whose data to fetch. 163 | * @param env - An optional parameter specifying the environment in which the function is running. 164 | * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. 165 | * If not specified, the function will assume it is running in a production environment. 166 | * 167 | * @returns A promise fulfilled with the user 168 | * data corresponding to the provided `uid`. 169 | */ 170 | public async getUser(uid: string, env?: EmulatorEnv): Promise { 171 | return await this.authApiClient.getAccountInfoByUid(uid, env); 172 | } 173 | 174 | /** 175 | * Revokes all refresh tokens for an existing user. 176 | * 177 | * This API will update the user's {@link UserRecord.tokensValidAfterTime} to 178 | * the current UTC. It is important that the server on which this is called has 179 | * its clock set correctly and synchronized. 180 | * 181 | * While this will revoke all sessions for a specified user and disable any 182 | * new ID tokens for existing sessions from getting minted, existing ID tokens 183 | * may remain active until their natural expiration (one hour). To verify that 184 | * ID tokens are revoked, use {@link BaseAuth.verifyIdToken} 185 | * where `checkRevoked` is set to true. 186 | * 187 | * @param uid - The `uid` corresponding to the user whose refresh tokens 188 | * are to be revoked. 189 | * @param env - An optional parameter specifying the environment in which the function is running. 190 | * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. 191 | * If not specified, the function will assume it is running in a production environment. 192 | * 193 | * @returns An empty promise fulfilled once the user's refresh 194 | * tokens have been revoked. 195 | */ 196 | public async revokeRefreshTokens(uid: string, env?: EmulatorEnv): Promise { 197 | await this.authApiClient.revokeRefreshTokens(uid, env); 198 | } 199 | 200 | /** 201 | * Sets additional developer claims on an existing user identified by the 202 | * provided `uid`, typically used to define user roles and levels of 203 | * access. These claims should propagate to all devices where the user is 204 | * already signed in (after token expiration or when token refresh is forced) 205 | * and the next time the user signs in. If a reserved OIDC claim name 206 | * is used (sub, iat, iss, etc), an error is thrown. They are set on the 207 | * authenticated user's ID token JWT. 208 | * 209 | * See {@link https://firebase.google.com/docs/auth/admin/custom-claims | 210 | * Defining user roles and access levels} 211 | * for code samples and detailed documentation. 212 | * 213 | * @param uid - The `uid` of the user to edit. 214 | * @param customUserClaims - The developer claims to set. If null is 215 | * passed, existing custom claims are deleted. Passing a custom claims payload 216 | * larger than 1000 bytes will throw an error. Custom claims are added to the 217 | * user's ID token which is transmitted on every authenticated request. 218 | * For profile non-access related user attributes, use database or other 219 | * separate storage systems. 220 | * @param env - An optional parameter specifying the environment in which the function is running. 221 | * If the function is running in an emulator environment, this should be set to `EmulatorEnv`. 222 | * If not specified, the function will assume it is running in a production environment. 223 | * @returns A promise that resolves when the operation completes 224 | * successfully. 225 | */ 226 | public async setCustomUserClaims(uid: string, customUserClaims: object | null, env?: EmulatorEnv): Promise { 227 | await this.authApiClient.setCustomUserClaims(uid, customUserClaims, env); 228 | } 229 | 230 | /** 231 | * Verifies the decoded Firebase issued JWT is not revoked or disabled. Returns a promise that 232 | * resolves with the decoded claims on success. Rejects the promise with revocation error if revoked 233 | * or user disabled. 234 | * 235 | * @param decodedIdToken - The JWT's decoded claims. 236 | * @param revocationErrorInfo - The revocation error info to throw on revocation 237 | * detection. 238 | * @returns A promise that will be fulfilled after a successful verification. 239 | */ 240 | private async verifyDecodedJWTNotRevokedOrDisabled( 241 | decodedIdToken: FirebaseIdToken, 242 | revocationErrorInfo: ErrorInfo, 243 | env?: EmulatorEnv 244 | ): Promise { 245 | // Get tokens valid after time for the corresponding user. 246 | const user = await this.getUser(decodedIdToken.sub, env); 247 | if (user.disabled) { 248 | throw new FirebaseAuthError(AuthClientErrorCode.USER_DISABLED, 'The user record is disabled.'); 249 | } 250 | // If no tokens valid after time available, token is not revoked. 251 | if (user.tokensValidAfterTime) { 252 | // Get the ID token authentication time and convert to milliseconds UTC. 253 | const authTimeUtc = decodedIdToken.auth_time * 1000; 254 | // Get user tokens valid after time in milliseconds UTC. 255 | const validSinceUtc = new Date(user.tokensValidAfterTime).getTime(); 256 | // Check if authentication time is older than valid since time. 257 | if (authTimeUtc < validSinceUtc) { 258 | throw new FirebaseAuthError(revocationErrorInfo); 259 | } 260 | } 261 | // All checks above passed. Return the decoded token. 262 | return decodedIdToken; 263 | } 264 | } 265 | 266 | /** 267 | * Interface representing the session cookie options needed for the 268 | * {@link BaseAuth.createSessionCookie} method. 269 | */ 270 | export interface SessionCookieOptions { 271 | /** 272 | * The session cookie custom expiration in milliseconds. The minimum allowed is 273 | * 5 minutes and the maxium allowed is 2 weeks. 274 | */ 275 | expiresIn: number; 276 | } 277 | -------------------------------------------------------------------------------- /src/base64.ts: -------------------------------------------------------------------------------- 1 | import { utf8Encoder } from './utf8'; 2 | 3 | export const decodeBase64Url = (str: string): Uint8Array => { 4 | return decodeBase64(str.replace(/_|-/g, m => ({ _: '/', '-': '+' })[m] ?? m)); 5 | }; 6 | 7 | export const encodeBase64Url = (buf: ArrayBufferLike): string => 8 | encodeBase64(buf).replace(/\/|\+/g, m => ({ '/': '_', '+': '-' })[m] ?? m); 9 | 10 | // This approach is written in MDN. 11 | // btoa does not support utf-8 characters. So we need a little bit hack. 12 | export const encodeBase64 = (buf: ArrayBufferLike): string => { 13 | const binary = String.fromCharCode(...new Uint8Array(buf)); 14 | return btoa(binary); 15 | }; 16 | 17 | // atob does not support utf-8 characters. So we need a little bit hack. 18 | export const decodeBase64 = (str: string): Uint8Array => { 19 | const binary = atob(str); 20 | const bytes = new Uint8Array(new ArrayBuffer(binary.length)); 21 | const half = binary.length / 2; 22 | for (let i = 0, j = binary.length - 1; i <= half; i++, j--) { 23 | bytes[i] = binary.charCodeAt(i); 24 | bytes[j] = binary.charCodeAt(j); 25 | } 26 | return bytes; 27 | }; 28 | 29 | const jsonUTF8Stringify = (obj: any): Uint8Array => utf8Encoder.encode(JSON.stringify(obj)); 30 | export const encodeObjectBase64Url = (obj: any): string => encodeBase64Url(jsonUTF8Stringify(obj).buffer); 31 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import type { ApiSettings } from './api-requests'; 2 | import type { Credential } from './credential'; 3 | import { useEmulator, type EmulatorEnv } from './emulator'; 4 | import { AppErrorCodes, FirebaseAppError, FirebaseAuthError } from './errors'; 5 | import { isNonNullObject } from './validator'; 6 | import { version } from './version'; 7 | 8 | /** 9 | * Specifies how failing HTTP requests should be retried. 10 | */ 11 | export interface RetryConfig { 12 | /** Maximum number of times to retry a given request. */ 13 | maxRetries: number; 14 | 15 | /** HTTP status codes that should be retried. */ 16 | statusCodes?: number[]; 17 | 18 | /** Low-level I/O error codes that should be retried. */ 19 | ioErrorCodes?: string[]; 20 | 21 | /** 22 | * The multiplier for exponential back off. The retry delay is calculated in seconds using the formula 23 | * `(2^n) * backOffFactor`, where n is the number of retries performed so far. When the backOffFactor is set 24 | * to 0, retries are not delayed. When the backOffFactor is 1, retry duration is doubled each iteration. 25 | */ 26 | backOffFactor?: number; 27 | 28 | /** Maximum duration to wait before initiating a retry. */ 29 | maxDelayInMillis: number; 30 | } 31 | 32 | /** 33 | * Default retry configuration for HTTP requests. Retries up to 4 times on connection reset and timeout errors 34 | * as well as HTTP 503 errors. Exposed as a function to ensure that every HttpClient gets its own RetryConfig 35 | * instance. 36 | */ 37 | export function defaultRetryConfig(): RetryConfig { 38 | return { 39 | maxRetries: 4, 40 | statusCodes: [503], 41 | ioErrorCodes: ['ECONNRESET', 'ETIMEDOUT'], 42 | backOffFactor: 0.5, 43 | maxDelayInMillis: 60 * 1000, 44 | }; 45 | } 46 | 47 | export function buildApiUrl(projectId: string, apiSettings: ApiSettings, env?: EmulatorEnv): string { 48 | const defaultAuthURL = 'https://identitytoolkit.googleapis.com'; 49 | 50 | const baseUrl = env?.FIREBASE_AUTH_EMULATOR_HOST 51 | ? `http://${env.FIREBASE_AUTH_EMULATOR_HOST}/identitytoolkit.googleapis.com` 52 | : defaultAuthURL; 53 | const endpoint = apiSettings.getEndpoint(); 54 | return `${baseUrl}/${apiSettings.getVersion()}/projects/${projectId}${endpoint}`; 55 | } 56 | 57 | export class BaseClient { 58 | constructor( 59 | private projectId: string, 60 | private credential: Credential, 61 | private retryConfig: RetryConfig = defaultRetryConfig() 62 | ) {} 63 | 64 | private async getToken(env?: EmulatorEnv): Promise { 65 | if (useEmulator(env)) { 66 | return 'owner'; 67 | } 68 | const result = await this.credential.getAccessToken(); 69 | return result.access_token; 70 | } 71 | 72 | protected async fetch(apiSettings: ApiSettings, requestData?: object, env?: EmulatorEnv): Promise { 73 | const fullUrl = buildApiUrl(this.projectId, apiSettings, env); 74 | if (requestData) { 75 | const requestValidator = apiSettings.getRequestValidator(); 76 | requestValidator(requestData); 77 | } 78 | const token = await this.getToken(env); 79 | const method = apiSettings.getHttpMethod(); 80 | const signal = AbortSignal.timeout(25000); // 25s 81 | return await this.fetchWithRetry(fullUrl, { 82 | method, 83 | headers: { 84 | Authorization: `Bearer ${token}`, 85 | 'User-Agent': `Code-Hex/firebase-auth-cloudflare-workers/${version}`, 86 | 'X-Client-Version': `Code-Hex/firebase-auth-cloudflare-workers/${version}`, 87 | 'Content-Type': 'application/json;charset=utf-8', 88 | }, 89 | body: requestData ? JSON.stringify(requestData) : undefined, 90 | signal, 91 | }); 92 | } 93 | 94 | private async fetchWithRetry(url: string, init: RequestInit, retryAttempts: number = 0): Promise { 95 | try { 96 | const res = await fetch(url, init); 97 | const text = await res.text(); 98 | if (!res.ok) { 99 | throw new HttpError(res.status, text); 100 | } 101 | try { 102 | return JSON.parse(text) as T; 103 | } catch (err) { 104 | throw new HttpError(res.status, text, { 105 | cause: new FirebaseAppError( 106 | AppErrorCodes.UNABLE_TO_PARSE_RESPONSE, 107 | `Error while parsing response data: "${String(err)}". Raw server ` + 108 | `response: "${text}". Status code: "${res.status}". Outgoing ` + 109 | `request: "${init.method} ${url}."` 110 | ), 111 | }); 112 | } 113 | } catch (err) { 114 | const canRetry = this.isRetryEligible(retryAttempts, err); 115 | const delayMillis = this.backOffDelayMillis(retryAttempts); 116 | if (canRetry && delayMillis <= this.retryConfig.maxDelayInMillis) { 117 | await this.waitForRetry(delayMillis); 118 | return await this.fetchWithRetry(url, init, retryAttempts + 1); 119 | } 120 | if (err instanceof HttpError) { 121 | if (err.cause) { 122 | throw err.cause; 123 | } 124 | 125 | try { 126 | const json = JSON.parse(err.message); 127 | const errorCode = this.getErrorCode(json); 128 | if (errorCode) { 129 | throw FirebaseAuthError.fromServerError(errorCode, json); 130 | } 131 | } catch (err) { 132 | if (err instanceof FirebaseAuthError) { 133 | throw err; 134 | } 135 | } 136 | 137 | throw new FirebaseAppError( 138 | AppErrorCodes.INTERNAL_ERROR, 139 | `Error while sending request or reading response: "${err}". Raw server ` + 140 | `response: Status code: "${err.status}". Outgoing ` + 141 | `request: "${init.method} ${url}."` 142 | ); 143 | } 144 | throw new FirebaseAppError(AppErrorCodes.NETWORK_ERROR, `Error while making request: ${String(err)}`); 145 | } 146 | } 147 | 148 | /** 149 | * @param response - The response to check for errors. 150 | * @returns The error code if present; null otherwise. 151 | */ 152 | private getErrorCode(response: any): string | null { 153 | return (isNonNullObject(response) && response.error && response.error.message) || null; 154 | } 155 | 156 | private waitForRetry(delayMillis: number): Promise { 157 | if (delayMillis > 0) { 158 | return new Promise(resolve => { 159 | setTimeout(resolve, delayMillis); 160 | }); 161 | } 162 | return Promise.resolve(); 163 | } 164 | 165 | private isRetryEligible(retryAttempts: number, err: unknown): boolean { 166 | if (retryAttempts >= this.retryConfig.maxRetries) { 167 | return false; 168 | } 169 | if (err instanceof HttpError) { 170 | const statusCodes = this.retryConfig.statusCodes || []; 171 | return statusCodes.includes(err.status); 172 | } 173 | if (err instanceof Error && err.name === 'AbortError') { 174 | return false; 175 | } 176 | return true; 177 | } 178 | 179 | private backOffDelayMillis(retryAttempts: number): number { 180 | if (retryAttempts === 0) { 181 | return 0; 182 | } 183 | 184 | const backOffFactor = this.retryConfig.backOffFactor || 0; 185 | const delayInSeconds = 2 ** retryAttempts * backOffFactor; 186 | return Math.min(delayInSeconds * 1000, this.retryConfig.maxDelayInMillis); 187 | } 188 | } 189 | 190 | class HttpError extends Error { 191 | constructor( 192 | public status: number, 193 | message: string, 194 | opts?: { cause?: unknown } 195 | ) { 196 | super(message, opts); 197 | this.name = 'HttpError'; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/credential.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64, encodeBase64Url, encodeObjectBase64Url } from './base64'; 2 | import { AppErrorCodes, FirebaseAppError } from './errors'; 3 | import { isNonEmptyString, isNonNullObject } from './validator'; 4 | 5 | /** 6 | * Type representing a Firebase OAuth access token (derived from a Google OAuth2 access token) which 7 | * can be used to authenticate to Firebase services such as the Realtime Database and Auth. 8 | */ 9 | export interface FirebaseAccessToken { 10 | accessToken: string; 11 | expirationTime: number; 12 | } 13 | 14 | /** 15 | * Interface for Google OAuth 2.0 access tokens. 16 | */ 17 | export interface GoogleOAuthAccessToken { 18 | access_token: string; 19 | expires_in: number; 20 | } 21 | 22 | /** 23 | * Interface that provides Google OAuth2 access tokens used to authenticate 24 | * with Firebase services. 25 | * 26 | * In most cases, you will not need to implement this yourself and can instead 27 | * use the default implementations provided by the `firebase-admin/app` module. 28 | */ 29 | export interface Credential { 30 | /** 31 | * Returns a Google OAuth2 access token object used to authenticate with 32 | * Firebase services. 33 | * 34 | * @returns A Google OAuth2 access token object. 35 | */ 36 | getAccessToken(): Promise; 37 | } 38 | 39 | const GOOGLE_TOKEN_AUDIENCE = 'https://accounts.google.com/o/oauth2/token'; 40 | const GOOGLE_AUTH_TOKEN_HOST = 'accounts.google.com'; 41 | const GOOGLE_AUTH_TOKEN_PATH = '/o/oauth2/token'; 42 | 43 | /** 44 | * Implementation of Credential that uses a service account. 45 | */ 46 | export class ServiceAccountCredential implements Credential { 47 | public readonly projectId: string; 48 | public readonly privateKey: string; 49 | public readonly clientEmail: string; 50 | 51 | /** 52 | * Creates a new ServiceAccountCredential from the given parameters. 53 | * 54 | * @param serviceAccountJson - Service account json content. 55 | * 56 | * @constructor 57 | */ 58 | constructor(serviceAccountJson: string) { 59 | const serviceAccount = ServiceAccount.fromJSON(serviceAccountJson); 60 | this.projectId = serviceAccount.projectId; 61 | this.privateKey = serviceAccount.privateKey; 62 | this.clientEmail = serviceAccount.clientEmail; 63 | } 64 | 65 | public async getAccessToken(): Promise { 66 | const header = encodeObjectBase64Url({ 67 | alg: 'RS256', 68 | typ: 'JWT', 69 | }).replace(/=/g, ''); 70 | 71 | const iat = Math.round(Date.now() / 1000); 72 | const exp = iat + 3600; 73 | const claim = encodeObjectBase64Url({ 74 | iss: this.clientEmail, 75 | scope: ['https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/identitytoolkit'].join( 76 | ' ' 77 | ), 78 | aud: GOOGLE_TOKEN_AUDIENCE, 79 | exp, 80 | iat, 81 | }).replace(/=/g, ''); 82 | 83 | const unsignedContent = `${header}.${claim}`; 84 | // This method is actually synchronous so we can capture and return the buffer. 85 | const signature = await this.sign(unsignedContent, this.privateKey); 86 | const jwt = `${unsignedContent}.${signature}`; 87 | const body = `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`; 88 | const url = `https://${GOOGLE_AUTH_TOKEN_HOST}${GOOGLE_AUTH_TOKEN_PATH}`; 89 | const res = await fetch(url, { 90 | method: 'POST', 91 | headers: { 92 | 'Content-Type': 'application/x-www-form-urlencoded', 93 | 'Cache-Control': 'no-cache', 94 | Host: 'oauth2.googleapis.com', 95 | }, 96 | body, 97 | }); 98 | const json = (await res.json()) as any; 99 | if (!json.access_token || !json.expires_in) { 100 | throw new FirebaseAppError( 101 | AppErrorCodes.INVALID_CREDENTIAL, 102 | `Unexpected response while fetching access token: ${JSON.stringify(json)}` 103 | ); 104 | } 105 | 106 | return json; 107 | } 108 | 109 | private async sign(content: string, privateKey: string): Promise { 110 | const buf = this.str2ab(content); 111 | const binaryKey = decodeBase64(privateKey); 112 | const signer = await crypto.subtle.importKey( 113 | 'pkcs8', 114 | binaryKey, 115 | { 116 | name: 'RSASSA-PKCS1-V1_5', 117 | hash: { name: 'SHA-256' }, 118 | }, 119 | false, 120 | ['sign'] 121 | ); 122 | const binarySignature = await crypto.subtle.sign({ name: 'RSASSA-PKCS1-V1_5' }, signer, buf); 123 | return encodeBase64Url(binarySignature).replace(/=/g, ''); 124 | } 125 | 126 | private str2ab(str: string): ArrayBuffer { 127 | const buf = new ArrayBuffer(str.length); 128 | const bufView = new Uint8Array(buf); 129 | for (let i = 0, strLen = str.length; i < strLen; i += 1) { 130 | bufView[i] = str.charCodeAt(i); 131 | } 132 | return buf; 133 | } 134 | } 135 | 136 | /** 137 | * A struct containing the properties necessary to use service account JSON credentials. 138 | */ 139 | class ServiceAccount { 140 | public readonly projectId: string; 141 | public readonly privateKey: string; 142 | public readonly clientEmail: string; 143 | 144 | public static fromJSON(text: string): ServiceAccount { 145 | try { 146 | return new ServiceAccount(JSON.parse(text)); 147 | } catch (error) { 148 | // Throw a nicely formed error message if the file contents cannot be parsed 149 | throw new FirebaseAppError( 150 | AppErrorCodes.INVALID_CREDENTIAL, 151 | 'Failed to parse service account json file: ' + error 152 | ); 153 | } 154 | } 155 | 156 | constructor(json: object) { 157 | if (!isNonNullObject(json)) { 158 | throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, 'Service account must be an object.'); 159 | } 160 | 161 | copyAttr(this, json, 'projectId', 'project_id'); 162 | copyAttr(this, json, 'privateKey', 'private_key'); 163 | copyAttr(this, json, 'clientEmail', 'client_email'); 164 | 165 | let errorMessage; 166 | if (!isNonEmptyString(this.projectId)) { 167 | errorMessage = 'Service account object must contain a string "project_id" property.'; 168 | } else if (!isNonEmptyString(this.privateKey)) { 169 | errorMessage = 'Service account object must contain a string "private_key" property.'; 170 | } else if (!isNonEmptyString(this.clientEmail)) { 171 | errorMessage = 'Service account object must contain a string "client_email" property.'; 172 | } 173 | 174 | if (typeof errorMessage !== 'undefined') { 175 | throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, errorMessage); 176 | } 177 | 178 | this.privateKey = this.privateKey.replace(/-+(BEGIN|END).*/g, '').replace(/\s/g, ''); 179 | } 180 | } 181 | 182 | /** 183 | * Copies the specified property from one object to another. 184 | * 185 | * If no property exists by the given "key", looks for a property identified by "alt", and copies it instead. 186 | * This can be used to implement behaviors such as "copy property myKey or my_key". 187 | * 188 | * @param to - Target object to copy the property into. 189 | * @param from - Source object to copy the property from. 190 | * @param key - Name of the property to copy. 191 | * @param alt - Alternative name of the property to copy. 192 | */ 193 | function copyAttr(to: { [key: string]: any }, from: { [key: string]: any }, key: string, alt: string): void { 194 | const tmp = from[key] || from[alt]; 195 | if (typeof tmp !== 'undefined') { 196 | to[key] = tmp; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/emulator.ts: -------------------------------------------------------------------------------- 1 | export interface EmulatorEnv { 2 | FIREBASE_AUTH_EMULATOR_HOST: string | undefined; 3 | } 4 | 5 | export function emulatorHost(env?: EmulatorEnv): string | undefined { 6 | return env?.FIREBASE_AUTH_EMULATOR_HOST; 7 | } 8 | 9 | /** 10 | * When true the SDK should communicate with the Auth Emulator for all API 11 | * calls and also produce unsigned tokens. 12 | */ 13 | export const useEmulator = (env?: EmulatorEnv): boolean => { 14 | return !!emulatorHost(env); 15 | }; 16 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Jwt error code structure. 3 | * 4 | * @param code - The error code. 5 | * @param message - The error message. 6 | * @constructor 7 | */ 8 | export class JwtError extends Error { 9 | constructor( 10 | readonly code: JwtErrorCode, 11 | readonly message: string 12 | ) { 13 | super(message); 14 | (this as any).__proto__ = JwtError.prototype; 15 | } 16 | } 17 | 18 | /** 19 | * JWT error codes. 20 | */ 21 | export enum JwtErrorCode { 22 | INVALID_ARGUMENT = 'invalid-argument', 23 | INVALID_CREDENTIAL = 'invalid-credential', 24 | TOKEN_EXPIRED = 'token-expired', 25 | INVALID_SIGNATURE = 'invalid-token', 26 | NO_MATCHING_KID = 'no-matching-kid-error', 27 | NO_KID_IN_HEADER = 'no-kid-error', 28 | KEY_FETCH_ERROR = 'key-fetch-error', 29 | } 30 | 31 | /** 32 | * App client error codes and their default messages. 33 | */ 34 | export class AppErrorCodes { 35 | public static INVALID_CREDENTIAL = 'invalid-credential'; 36 | public static INTERNAL_ERROR = 'internal-error'; 37 | public static NETWORK_ERROR = 'network-error'; 38 | public static NETWORK_TIMEOUT = 'network-timeout'; 39 | public static UNABLE_TO_PARSE_RESPONSE = 'unable-to-parse-response'; 40 | } 41 | 42 | /** 43 | * Auth client error codes and their default messages. 44 | */ 45 | export class AuthClientErrorCode { 46 | public static INVALID_ARGUMENT = { 47 | code: 'argument-error', 48 | message: 'Invalid argument provided.', 49 | }; 50 | public static INVALID_CREDENTIAL = { 51 | code: 'invalid-credential', 52 | message: 'Invalid credential object provided.', 53 | }; 54 | public static ID_TOKEN_EXPIRED = { 55 | code: 'id-token-expired', 56 | message: 'The provided Firebase ID token is expired.', 57 | }; 58 | public static INVALID_ID_TOKEN = { 59 | code: 'invalid-id-token', 60 | message: 'The provided ID token is not a valid Firebase ID token.', 61 | }; 62 | public static ID_TOKEN_REVOKED = { 63 | code: 'id-token-revoked', 64 | message: 'The Firebase ID token has been revoked.', 65 | }; 66 | public static INTERNAL_ERROR = { 67 | code: 'internal-error', 68 | message: 'An internal error has occurred.', 69 | }; 70 | public static USER_NOT_FOUND = { 71 | code: 'user-not-found', 72 | message: 'There is no user record corresponding to the provided identifier.', 73 | }; 74 | public static USER_DISABLED = { 75 | code: 'user-disabled', 76 | message: 'The user record is disabled.', 77 | }; 78 | public static SESSION_COOKIE_EXPIRED = { 79 | code: 'session-cookie-expired', 80 | message: 'The Firebase session cookie is expired.', 81 | }; 82 | public static SESSION_COOKIE_REVOKED = { 83 | code: 'session-cookie-revoked', 84 | message: 'The Firebase session cookie has been revoked.', 85 | }; 86 | public static INVALID_SESSION_COOKIE_DURATION = { 87 | code: 'invalid-session-cookie-duration', 88 | message: 'The session cookie duration must be a valid number in milliseconds ' + 'between 5 minutes and 2 weeks.', 89 | }; 90 | public static INVALID_UID = { 91 | code: 'invalid-uid', 92 | message: 'The uid must be a non-empty string with at most 128 characters.', 93 | }; 94 | public static INVALID_TOKENS_VALID_AFTER_TIME = { 95 | code: 'invalid-tokens-valid-after-time', 96 | message: 'The tokensValidAfterTime must be a valid UTC number in seconds.', 97 | }; 98 | public static FORBIDDEN_CLAIM = { 99 | code: 'reserved-claim', 100 | message: 'The specified developer claim is reserved and cannot be specified.', 101 | }; 102 | public static INVALID_CLAIMS = { 103 | code: 'invalid-claims', 104 | message: 'The provided custom claim attributes are invalid.', 105 | }; 106 | public static CLAIMS_TOO_LARGE = { 107 | code: 'claims-too-large', 108 | message: 'Developer claims maximum payload size exceeded.', 109 | }; 110 | } 111 | 112 | /** 113 | * `FirebaseErrorInterface` is a subclass of the standard JavaScript `Error` object. In 114 | * addition to a message string and stack trace, it contains a string code. 115 | */ 116 | export interface FirebaseErrorInterface { 117 | /** 118 | * Error codes are strings using the following format: `"service/string-code"`. 119 | * Some examples include `"auth/invalid-uid"` and 120 | * `"messaging/invalid-recipient"`. 121 | * 122 | * While the message for a given error can change, the code will remain the same 123 | * between backward-compatible versions of the Firebase SDK. 124 | */ 125 | code: string; 126 | 127 | /** 128 | * An explanatory message for the error that just occurred. 129 | * 130 | * This message is designed to be helpful to you, the developer. Because 131 | * it generally does not convey meaningful information to end users, 132 | * this message should not be displayed in your application. 133 | */ 134 | message: string; 135 | 136 | /** 137 | * A string value containing the execution backtrace when the error originally 138 | * occurred. 139 | * 140 | * This information can be useful for troubleshooting the cause of the error with 141 | * {@link https://firebase.google.com/support | Firebase Support}. 142 | */ 143 | stack?: string; 144 | 145 | /** 146 | * Returns a JSON-serializable object representation of this error. 147 | * 148 | * @returns A JSON-serializable representation of this object. 149 | */ 150 | toJSON(): object; 151 | } 152 | 153 | /** 154 | * Firebase error code structure. This extends Error. 155 | * 156 | * @param errorInfo - The error information (code and message). 157 | * @constructor 158 | */ 159 | export class FirebaseError extends Error implements FirebaseErrorInterface { 160 | constructor(private errorInfo: ErrorInfo) { 161 | super(errorInfo.message); 162 | 163 | /* tslint:disable:max-line-length */ 164 | // Set the prototype explicitly. See the following link for more details: 165 | // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work 166 | /* tslint:enable:max-line-length */ 167 | (this as any).__proto__ = FirebaseError.prototype; 168 | } 169 | 170 | /** @returns The error code. */ 171 | public get code(): string { 172 | return this.errorInfo.code; 173 | } 174 | 175 | /** @returns The error message. */ 176 | public get message(): string { 177 | return this.errorInfo.message; 178 | } 179 | 180 | /** @returns The object representation of the error. */ 181 | public toJSON(): object { 182 | return { 183 | code: this.code, 184 | message: this.message, 185 | }; 186 | } 187 | } 188 | 189 | /** 190 | * Defines error info type. This includes a code and message string. 191 | */ 192 | export interface ErrorInfo { 193 | code: string; 194 | message: string; 195 | } 196 | 197 | /** 198 | * A FirebaseError with a prefix in front of the error code. 199 | * 200 | * @param codePrefix - The prefix to apply to the error code. 201 | * @param code - The error code. 202 | * @param message - The error message. 203 | * @constructor 204 | */ 205 | export class PrefixedFirebaseError extends FirebaseError { 206 | constructor( 207 | private codePrefix: string, 208 | code: string, 209 | message: string 210 | ) { 211 | super({ 212 | code: `${codePrefix}/${code}`, 213 | message, 214 | }); 215 | 216 | /* tslint:disable:max-line-length */ 217 | // Set the prototype explicitly. See the following link for more details: 218 | // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work 219 | /* tslint:enable:max-line-length */ 220 | (this as any).__proto__ = PrefixedFirebaseError.prototype; 221 | } 222 | 223 | /** 224 | * Allows the error type to be checked without needing to know implementation details 225 | * of the code prefixing. 226 | * 227 | * @param code - The non-prefixed error code to test against. 228 | * @returns True if the code matches, false otherwise. 229 | */ 230 | public hasCode(code: string): boolean { 231 | return `${this.codePrefix}/${code}` === this.code; 232 | } 233 | } 234 | 235 | /** 236 | * Firebase Auth error code structure. This extends PrefixedFirebaseError. 237 | * 238 | * @param info - The error code info. 239 | * @param [message] The error message. This will override the default 240 | * message if provided. 241 | * @constructor 242 | */ 243 | export class FirebaseAuthError extends PrefixedFirebaseError { 244 | constructor(info: ErrorInfo, message?: string) { 245 | // Override default message if custom message provided. 246 | super('auth', info.code, message || info.message); 247 | 248 | /* tslint:disable:max-line-length */ 249 | // Set the prototype explicitly. See the following link for more details: 250 | // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work 251 | /* tslint:enable:max-line-length */ 252 | (this as any).__proto__ = FirebaseAuthError.prototype; 253 | } 254 | 255 | /** 256 | * Creates the developer-facing error corresponding to the backend error code. 257 | * 258 | * @param serverErrorCode - The server error code. 259 | * @param [message] The error message. The default message is used 260 | * if not provided. 261 | * @param [rawServerResponse] The error's raw server response. 262 | * @returns The corresponding developer-facing error. 263 | */ 264 | public static fromServerError(serverErrorCode: string, rawServerResponse?: object): FirebaseAuthError { 265 | // serverErrorCode could contain additional details: 266 | // ERROR_CODE : Detailed message which can also contain colons 267 | const colonSeparator = (serverErrorCode || '').indexOf(':'); 268 | if (colonSeparator !== -1) { 269 | serverErrorCode = serverErrorCode.substring(0, colonSeparator).trim(); 270 | } 271 | // If not found, default to internal error. 272 | const clientCodeKey = AUTH_SERVER_TO_CLIENT_CODE[serverErrorCode] || 'INTERNAL_ERROR'; 273 | const error: ErrorInfo = { 274 | ...AuthClientErrorCode.INTERNAL_ERROR, 275 | ...(AuthClientErrorCode as any)[clientCodeKey], 276 | }; 277 | 278 | if (clientCodeKey === 'INTERNAL_ERROR' && typeof rawServerResponse !== 'undefined') { 279 | try { 280 | error.message += ` Raw server response: "${JSON.stringify(rawServerResponse)}"`; 281 | } catch (e) { 282 | // Ignore JSON parsing error. 283 | } 284 | } 285 | return new FirebaseAuthError(error); 286 | } 287 | } 288 | 289 | /** 290 | * Firebase App error code structure. This extends PrefixedFirebaseError. 291 | * 292 | * @param code - The error code. 293 | * @param message - The error message. 294 | * @constructor 295 | */ 296 | export class FirebaseAppError extends PrefixedFirebaseError { 297 | constructor(code: string, message: string) { 298 | super('app', code, message); 299 | 300 | /* tslint:disable:max-line-length */ 301 | // Set the prototype explicitly. See the following link for more details: 302 | // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work 303 | /* tslint:enable:max-line-length */ 304 | (this as any).__proto__ = FirebaseAppError.prototype; 305 | } 306 | } 307 | 308 | /** 309 | * Defines a type that stores all server to client codes (string enum). 310 | */ 311 | interface ServerToClientCode { 312 | [code: string]: string; 313 | } 314 | 315 | /** @const {ServerToClientCode} Auth server to client enum error codes. */ 316 | const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { 317 | // Feature being configured or used requires a billing account. 318 | BILLING_NOT_ENABLED: 'BILLING_NOT_ENABLED', 319 | // Claims payload is too large. 320 | CLAIMS_TOO_LARGE: 'CLAIMS_TOO_LARGE', 321 | // Configuration being added already exists. 322 | CONFIGURATION_EXISTS: 'CONFIGURATION_EXISTS', 323 | // Configuration not found. 324 | CONFIGURATION_NOT_FOUND: 'CONFIGURATION_NOT_FOUND', 325 | // Provided credential has insufficient permissions. 326 | INSUFFICIENT_PERMISSION: 'INSUFFICIENT_PERMISSION', 327 | // Provided configuration has invalid fields. 328 | INVALID_CONFIG: 'INVALID_CONFIG', 329 | // Provided configuration identifier is invalid. 330 | INVALID_CONFIG_ID: 'INVALID_PROVIDER_ID', 331 | // ActionCodeSettings missing continue URL. 332 | INVALID_CONTINUE_URI: 'INVALID_CONTINUE_URI', 333 | // Dynamic link domain in provided ActionCodeSettings is not authorized. 334 | INVALID_DYNAMIC_LINK_DOMAIN: 'INVALID_DYNAMIC_LINK_DOMAIN', 335 | // uploadAccount provides an email that already exists. 336 | DUPLICATE_EMAIL: 'EMAIL_ALREADY_EXISTS', 337 | // uploadAccount provides a localId that already exists. 338 | DUPLICATE_LOCAL_ID: 'UID_ALREADY_EXISTS', 339 | // Request specified a multi-factor enrollment ID that already exists. 340 | DUPLICATE_MFA_ENROLLMENT_ID: 'SECOND_FACTOR_UID_ALREADY_EXISTS', 341 | // setAccountInfo email already exists. 342 | EMAIL_EXISTS: 'EMAIL_ALREADY_EXISTS', 343 | // /accounts:sendOobCode for password reset when user is not found. 344 | EMAIL_NOT_FOUND: 'EMAIL_NOT_FOUND', 345 | // Reserved claim name. 346 | FORBIDDEN_CLAIM: 'FORBIDDEN_CLAIM', 347 | // Invalid claims provided. 348 | INVALID_CLAIMS: 'INVALID_CLAIMS', 349 | // Invalid session cookie duration. 350 | INVALID_DURATION: 'INVALID_SESSION_COOKIE_DURATION', 351 | // Invalid email provided. 352 | INVALID_EMAIL: 'INVALID_EMAIL', 353 | // Invalid new email provided. 354 | INVALID_NEW_EMAIL: 'INVALID_NEW_EMAIL', 355 | // Invalid tenant display name. This can be thrown on CreateTenant and UpdateTenant. 356 | INVALID_DISPLAY_NAME: 'INVALID_DISPLAY_NAME', 357 | // Invalid ID token provided. 358 | INVALID_ID_TOKEN: 'INVALID_ID_TOKEN', 359 | // Invalid tenant/parent resource name. 360 | INVALID_NAME: 'INVALID_NAME', 361 | // OIDC configuration has an invalid OAuth client ID. 362 | INVALID_OAUTH_CLIENT_ID: 'INVALID_OAUTH_CLIENT_ID', 363 | // Invalid page token. 364 | INVALID_PAGE_SELECTION: 'INVALID_PAGE_TOKEN', 365 | // Invalid phone number. 366 | INVALID_PHONE_NUMBER: 'INVALID_PHONE_NUMBER', 367 | // Invalid agent project. Either agent project doesn't exist or didn't enable multi-tenancy. 368 | INVALID_PROJECT_ID: 'INVALID_PROJECT_ID', 369 | // Invalid provider ID. 370 | INVALID_PROVIDER_ID: 'INVALID_PROVIDER_ID', 371 | // Invalid service account. 372 | INVALID_SERVICE_ACCOUNT: 'INVALID_SERVICE_ACCOUNT', 373 | // Invalid testing phone number. 374 | INVALID_TESTING_PHONE_NUMBER: 'INVALID_TESTING_PHONE_NUMBER', 375 | // Invalid tenant type. 376 | INVALID_TENANT_TYPE: 'INVALID_TENANT_TYPE', 377 | // Missing Android package name. 378 | MISSING_ANDROID_PACKAGE_NAME: 'MISSING_ANDROID_PACKAGE_NAME', 379 | // Missing configuration. 380 | MISSING_CONFIG: 'MISSING_CONFIG', 381 | // Missing configuration identifier. 382 | MISSING_CONFIG_ID: 'MISSING_PROVIDER_ID', 383 | // Missing tenant display name: This can be thrown on CreateTenant and UpdateTenant. 384 | MISSING_DISPLAY_NAME: 'MISSING_DISPLAY_NAME', 385 | // Email is required for the specified action. For example a multi-factor user requires 386 | // a verified email. 387 | MISSING_EMAIL: 'MISSING_EMAIL', 388 | // Missing iOS bundle ID. 389 | MISSING_IOS_BUNDLE_ID: 'MISSING_IOS_BUNDLE_ID', 390 | // Missing OIDC issuer. 391 | MISSING_ISSUER: 'MISSING_ISSUER', 392 | // No localId provided (deleteAccount missing localId). 393 | MISSING_LOCAL_ID: 'MISSING_UID', 394 | // OIDC configuration is missing an OAuth client ID. 395 | MISSING_OAUTH_CLIENT_ID: 'MISSING_OAUTH_CLIENT_ID', 396 | // Missing provider ID. 397 | MISSING_PROVIDER_ID: 'MISSING_PROVIDER_ID', 398 | // Missing SAML RP config. 399 | MISSING_SAML_RELYING_PARTY_CONFIG: 'MISSING_SAML_RELYING_PARTY_CONFIG', 400 | // Empty user list in uploadAccount. 401 | MISSING_USER_ACCOUNT: 'MISSING_UID', 402 | // Password auth disabled in console. 403 | OPERATION_NOT_ALLOWED: 'OPERATION_NOT_ALLOWED', 404 | // Provided credential has insufficient permissions. 405 | PERMISSION_DENIED: 'INSUFFICIENT_PERMISSION', 406 | // Phone number already exists. 407 | PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_ALREADY_EXISTS', 408 | // Project not found. 409 | PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND', 410 | // In multi-tenancy context: project creation quota exceeded. 411 | QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', 412 | // Currently only 5 second factors can be set on the same user. 413 | SECOND_FACTOR_LIMIT_EXCEEDED: 'SECOND_FACTOR_LIMIT_EXCEEDED', 414 | // Tenant not found. 415 | TENANT_NOT_FOUND: 'TENANT_NOT_FOUND', 416 | // Tenant ID mismatch. 417 | TENANT_ID_MISMATCH: 'MISMATCHING_TENANT_ID', 418 | // Token expired error. 419 | TOKEN_EXPIRED: 'ID_TOKEN_EXPIRED', 420 | // Continue URL provided in ActionCodeSettings has a domain that is not whitelisted. 421 | UNAUTHORIZED_DOMAIN: 'UNAUTHORIZED_DOMAIN', 422 | // A multi-factor user requires a supported first factor. 423 | UNSUPPORTED_FIRST_FACTOR: 'UNSUPPORTED_FIRST_FACTOR', 424 | // The request specified an unsupported type of second factor. 425 | UNSUPPORTED_SECOND_FACTOR: 'UNSUPPORTED_SECOND_FACTOR', 426 | // Operation is not supported in a multi-tenant context. 427 | UNSUPPORTED_TENANT_OPERATION: 'UNSUPPORTED_TENANT_OPERATION', 428 | // A verified email is required for the specified action. For example a multi-factor user 429 | // requires a verified email. 430 | UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL', 431 | // User on which action is to be performed is not found. 432 | USER_NOT_FOUND: 'USER_NOT_FOUND', 433 | // User record is disabled. 434 | USER_DISABLED: 'USER_DISABLED', 435 | // Password provided is too weak. 436 | WEAK_PASSWORD: 'INVALID_PASSWORD', 437 | // Unrecognized reCAPTCHA action. 438 | INVALID_RECAPTCHA_ACTION: 'INVALID_RECAPTCHA_ACTION', 439 | // Unrecognized reCAPTCHA enforcement state. 440 | INVALID_RECAPTCHA_ENFORCEMENT_STATE: 'INVALID_RECAPTCHA_ENFORCEMENT_STATE', 441 | // reCAPTCHA is not enabled for account defender. 442 | RECAPTCHA_NOT_ENABLED: 'RECAPTCHA_NOT_ENABLED', 443 | }; 444 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { BaseAuth } from './auth'; 2 | import { AuthApiClient } from './auth-api-requests'; 3 | import type { RetryConfig } from './client'; 4 | import type { Credential } from './credential'; 5 | import type { KeyStorer } from './key-store'; 6 | import { WorkersKVStore } from './key-store'; 7 | 8 | export { type Credential, ServiceAccountCredential } from './credential'; 9 | export { emulatorHost, useEmulator } from './emulator'; 10 | export type { KeyStorer }; 11 | export type { EmulatorEnv } from './emulator'; 12 | export type { FirebaseIdToken } from './token-verifier'; 13 | export type { RetryConfig }; 14 | export * from './errors'; 15 | 16 | export class Auth extends BaseAuth { 17 | private static instance?: Auth; 18 | private static withCredential?: Auth; 19 | 20 | private constructor(projectId: string, keyStore: KeyStorer, credential?: Credential) { 21 | super(projectId, keyStore, credential); 22 | } 23 | 24 | static getOrInitialize(projectId: string, keyStore: KeyStorer, credential?: Credential): Auth { 25 | if (!Auth.withCredential && credential !== undefined) { 26 | Auth.withCredential = new Auth(projectId, keyStore, credential); 27 | } 28 | if (Auth.withCredential) { 29 | return Auth.withCredential; 30 | } 31 | if (!Auth.instance) { 32 | Auth.instance = new Auth(projectId, keyStore); 33 | } 34 | return Auth.instance; 35 | } 36 | } 37 | 38 | export class WorkersKVStoreSingle extends WorkersKVStore { 39 | private static instance?: Map; 40 | 41 | private constructor(cacheKey: string, cfKVNamespace: KVNamespace) { 42 | super(cacheKey, cfKVNamespace); 43 | } 44 | 45 | static getOrInitialize(cacheKey: string, cfKVNamespace: KVNamespace): WorkersKVStoreSingle { 46 | if (!WorkersKVStoreSingle.instance) { 47 | WorkersKVStoreSingle.instance = new Map(); 48 | } 49 | const instance = WorkersKVStoreSingle.instance.get(cacheKey); 50 | if (instance) { 51 | return instance; 52 | } 53 | const newInstance = new WorkersKVStoreSingle(cacheKey, cfKVNamespace); 54 | WorkersKVStoreSingle.instance.set(cacheKey, newInstance); 55 | return newInstance; 56 | } 57 | } 58 | 59 | export class AdminAuthApiClient extends AuthApiClient { 60 | private static instance?: AdminAuthApiClient; 61 | 62 | private constructor(projectId: string, credential: Credential, retryConfig?: RetryConfig) { 63 | super(projectId, credential, retryConfig); 64 | } 65 | 66 | static getOrInitialize(projectId: string, credential: Credential, retryConfig?: RetryConfig) { 67 | if (!AdminAuthApiClient.instance) { 68 | AdminAuthApiClient.instance = new AdminAuthApiClient(projectId, credential, retryConfig); 69 | } 70 | return AdminAuthApiClient.instance; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/jwk-fetcher.ts: -------------------------------------------------------------------------------- 1 | import type { KeyStorer } from './key-store'; 2 | import { isNonNullObject, isObject, isURL } from './validator'; 3 | import { jwkFromX509 } from './x509'; 4 | 5 | export interface KeyFetcher { 6 | fetchPublicKeys(): Promise>; 7 | } 8 | 9 | interface JWKMetadata { 10 | keys: Array; 11 | } 12 | 13 | export const isJWKMetadata = (value: any): value is JWKMetadata => { 14 | if (!isNonNullObject(value) || !value.keys) { 15 | return false; 16 | } 17 | const keys = value.keys; 18 | if (!Array.isArray(keys)) { 19 | return false; 20 | } 21 | const filtered = keys.filter( 22 | (key): key is JsonWebKeyWithKid => isObject(key) && !!key.kid && typeof key.kid === 'string' 23 | ); 24 | return keys.length === filtered.length; 25 | }; 26 | 27 | export const isX509Certificates = (value: any): value is Record => { 28 | if (!isNonNullObject(value)) { 29 | return false; 30 | } 31 | const values = Object.values(value); 32 | if (values.length === 0) { 33 | return false; 34 | } 35 | for (const v of values) { 36 | if (typeof v !== 'string' || v === '') { 37 | return false; 38 | } 39 | } 40 | return true; 41 | }; 42 | 43 | /** 44 | * Class to fetch public keys from a client certificates URL. 45 | */ 46 | export class UrlKeyFetcher implements KeyFetcher { 47 | constructor( 48 | private readonly fetcher: Fetcher, 49 | private readonly keyStorer: KeyStorer 50 | ) {} 51 | 52 | /** 53 | * Fetches the public keys for the Google certs. 54 | * 55 | * @returns A promise fulfilled with public keys for the Google certs. 56 | */ 57 | public async fetchPublicKeys(): Promise> { 58 | const publicKeys = await this.keyStorer.get>(); 59 | if (publicKeys === null || typeof publicKeys !== 'object') { 60 | return await this.refresh(); 61 | } 62 | return publicKeys; 63 | } 64 | 65 | private async refresh(): Promise> { 66 | const resp = await this.fetcher.fetch(); 67 | if (!resp.ok) { 68 | const errorMessage = 'Error fetching public keys for Google certs: '; 69 | const text = await resp.text(); 70 | throw new Error(errorMessage + text); 71 | } 72 | 73 | const json = await resp.json(); 74 | const publicKeys = await this.retrievePublicKeys(json); 75 | 76 | const cacheControlHeader = resp.headers.get('cache-control'); 77 | 78 | // store the public keys cache in the KV store. 79 | const maxAge = parseMaxAge(cacheControlHeader); 80 | if (!isNaN(maxAge) && maxAge > 0) { 81 | await this.keyStorer.put(JSON.stringify(publicKeys), maxAge); 82 | } 83 | 84 | return publicKeys; 85 | } 86 | 87 | private async retrievePublicKeys(json: unknown): Promise> { 88 | if (isX509Certificates(json)) { 89 | const jwks: JsonWebKeyWithKid[] = []; 90 | for (const [kid, x509] of Object.entries(json)) { 91 | jwks.push(await jwkFromX509(kid, x509)); 92 | } 93 | return jwks; 94 | } 95 | if (!isJWKMetadata(json)) { 96 | throw new Error(`The public keys are not an object or null: "${json}`); 97 | } 98 | return json.keys; 99 | } 100 | } 101 | 102 | // parseMaxAge parses Cache-Control header and returns max-age value as number. 103 | // returns NaN when Cache-Control header is none or max-age is not found, the value is invalid. 104 | export const parseMaxAge = (cacheControlHeader: string | null): number => { 105 | if (cacheControlHeader === null) { 106 | return NaN; 107 | } 108 | const parts = cacheControlHeader.split(','); 109 | for (const part of parts) { 110 | const subParts = part.trim().split('='); 111 | if (subParts[0] !== 'max-age') { 112 | continue; 113 | } 114 | return Number(subParts[1]); // maxAge is a seconds value. 115 | } 116 | return NaN; 117 | }; 118 | 119 | export interface Fetcher { 120 | fetch(): Promise; 121 | } 122 | 123 | export class HTTPFetcher implements Fetcher { 124 | constructor(private readonly clientCertUrl: string) { 125 | if (!isURL(clientCertUrl)) { 126 | throw new Error('The provided public client certificate URL is not a valid URL.'); 127 | } 128 | } 129 | 130 | public fetch(): Promise { 131 | return fetch(this.clientCertUrl); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/jws-verifier.ts: -------------------------------------------------------------------------------- 1 | import { JwtError, JwtErrorCode } from './errors'; 2 | import type { KeyFetcher } from './jwk-fetcher'; 3 | import { HTTPFetcher, UrlKeyFetcher } from './jwk-fetcher'; 4 | import type { RS256Token } from './jwt-decoder'; 5 | import type { KeyStorer } from './key-store'; 6 | import { isNonNullObject } from './validator'; 7 | 8 | // https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_a_third-party_jwt_library 9 | // https://developer.mozilla.org/en-US/docs/Web/API/RsaHashedKeyGenParams 10 | export const rs256alg: RsaHashedKeyGenParams = { 11 | name: 'RSASSA-PKCS1-v1_5', 12 | modulusLength: 2048, 13 | publicExponent: new Uint8Array([0x01, 0x00, 0x01]), 14 | hash: 'SHA-256', 15 | }; 16 | 17 | export interface SignatureVerifier { 18 | verify(token: RS256Token): Promise; 19 | } 20 | 21 | /** 22 | * Class for verifying JWT signature with a public key. 23 | */ 24 | export class PublicKeySignatureVerifier implements SignatureVerifier { 25 | constructor(private keyFetcher: KeyFetcher) { 26 | if (!isNonNullObject(keyFetcher)) { 27 | throw new Error('The provided key fetcher is not an object or null.'); 28 | } 29 | } 30 | 31 | public static withCertificateUrl(clientCertUrl: string, keyStorer: KeyStorer): PublicKeySignatureVerifier { 32 | const fetcher = new HTTPFetcher(clientCertUrl); 33 | return new PublicKeySignatureVerifier(new UrlKeyFetcher(fetcher, keyStorer)); 34 | } 35 | 36 | /** 37 | * Verifies the signature of a JWT using the provided secret or a function to fetch 38 | * the public key. 39 | * 40 | * @param token - The JWT to be verified. 41 | * @throws If the JWT is not a valid RS256 token. 42 | * @returns A Promise resolving for a token with a valid signature. 43 | */ 44 | public async verify(token: RS256Token): Promise { 45 | const { header } = token.decodedToken; 46 | const publicKeys = await this.fetchPublicKeys(); 47 | for (const publicKey of publicKeys) { 48 | if (publicKey.kid !== header.kid) { 49 | continue; 50 | } 51 | const verified = await this.verifySignature(token, publicKey); 52 | if (verified) { 53 | // succeeded 54 | return; 55 | } 56 | throw new JwtError(JwtErrorCode.INVALID_SIGNATURE, 'The token signature is invalid.'); 57 | } 58 | throw new JwtError(JwtErrorCode.NO_MATCHING_KID, 'The token does not match the kid.'); 59 | } 60 | 61 | private async verifySignature(token: RS256Token, publicJWK: JsonWebKeyWithKid): Promise { 62 | try { 63 | const key = await crypto.subtle.importKey('jwk', publicJWK, rs256alg, false, ['verify']); 64 | return await crypto.subtle.verify(rs256alg, key, token.decodedToken.signature, token.getHeaderPayloadBytes()); 65 | } catch (err) { 66 | throw new JwtError(JwtErrorCode.INVALID_SIGNATURE, `Error verifying signature: ${err}`); 67 | } 68 | } 69 | 70 | private async fetchPublicKeys(): Promise> { 71 | try { 72 | return await this.keyFetcher.fetchPublicKeys(); 73 | } catch (err) { 74 | throw new JwtError(JwtErrorCode.KEY_FETCH_ERROR, `Error fetching public keys for Google certs: ${err}`); 75 | } 76 | } 77 | } 78 | 79 | /** 80 | * Class for verifying unsigned (emulator) JWTs. 81 | */ 82 | export class EmulatorSignatureVerifier implements SignatureVerifier { 83 | public async verify(): Promise { 84 | // Signature checks skipped for emulator; no need to fetch public keys. 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/jwt-decoder.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64Url } from './base64'; 2 | import { JwtError, JwtErrorCode } from './errors'; 3 | import { utf8Decoder, utf8Encoder } from './utf8'; 4 | import { isNonEmptyString, isNumber, isString } from './validator'; 5 | 6 | export interface TokenDecoder { 7 | decode(token: string): Promise; 8 | } 9 | 10 | export type DecodedHeader = { kid: string; alg: 'RS256' } & Record; 11 | 12 | export type DecodedPayload = { 13 | aud: string; 14 | exp: number; 15 | iat: number; 16 | iss: string; 17 | sub: string; 18 | } & Record; 19 | 20 | export type DecodedToken = { 21 | header: DecodedHeader; 22 | payload: DecodedPayload; 23 | signature: Uint8Array; 24 | }; 25 | 26 | export class RS256Token { 27 | constructor( 28 | private rawToken: string, 29 | public readonly decodedToken: DecodedToken 30 | ) {} 31 | /** 32 | * 33 | * @param token - The JWT to verify. 34 | * @param currentTimestamp - Current timestamp in seconds since the Unix epoch. 35 | * @param skipVerifyHeader - skip verification header content if true. 36 | * @throw Error if the token is invalid. 37 | * @returns 38 | */ 39 | public static decode(token: string, currentTimestamp: number, skipVerifyHeader: boolean = false): RS256Token { 40 | const tokenParts = token.split('.'); 41 | if (tokenParts.length !== 3) { 42 | throw new JwtError(JwtErrorCode.INVALID_ARGUMENT, 'token must consist of 3 parts'); 43 | } 44 | const header = decodeHeader(tokenParts[0], skipVerifyHeader); 45 | const payload = decodePayload(tokenParts[1], currentTimestamp); 46 | 47 | return new RS256Token(token, { 48 | header, 49 | payload, 50 | signature: decodeBase64Url(tokenParts[2]), 51 | }); 52 | } 53 | 54 | public getHeaderPayloadBytes(): Uint8Array { 55 | const rawToken = this.rawToken; 56 | 57 | // `${token.header}.${token.payload}` 58 | const trimmedSignature = rawToken.substring(0, rawToken.lastIndexOf('.')); 59 | return utf8Encoder.encode(trimmedSignature); 60 | } 61 | } 62 | 63 | const decodeHeader = (headerPart: string, skipVerifyHeader: boolean): DecodedHeader => { 64 | const header = decodeBase64JSON(headerPart); 65 | if (skipVerifyHeader) { 66 | return header; 67 | } 68 | const kid = header.kid; 69 | if (!isString(kid)) { 70 | throw new JwtError(JwtErrorCode.NO_KID_IN_HEADER, `kid must be a string but got ${kid}`); 71 | } 72 | const alg = header.alg; 73 | if (isString(alg) && alg !== 'RS256') { 74 | throw new JwtError(JwtErrorCode.INVALID_ARGUMENT, `algorithm must be RS256 but got ${alg}`); 75 | } 76 | return header; 77 | }; 78 | 79 | const decodePayload = (payloadPart: string, currentTimestamp: number): DecodedPayload => { 80 | const payload = decodeBase64JSON(payloadPart); 81 | 82 | if (!isNonEmptyString(payload.aud)) { 83 | throw new JwtError(JwtErrorCode.INVALID_ARGUMENT, `"aud" claim must be a string but got "${payload.aud}"`); 84 | } 85 | 86 | if (!isNonEmptyString(payload.sub)) { 87 | throw new JwtError(JwtErrorCode.INVALID_ARGUMENT, `"sub" claim must be a string but got "${payload.sub}"`); 88 | } 89 | 90 | if (!isNonEmptyString(payload.iss)) { 91 | throw new JwtError(JwtErrorCode.INVALID_ARGUMENT, `"iss" claim must be a string but got "${payload.iss}"`); 92 | } 93 | 94 | if (!isNumber(payload.iat)) { 95 | throw new JwtError(JwtErrorCode.INVALID_ARGUMENT, `"iat" claim must be a number but got "${payload.iat}"`); 96 | } 97 | 98 | if (currentTimestamp < payload.iat) { 99 | throw new JwtError( 100 | JwtErrorCode.INVALID_ARGUMENT, 101 | `Incorrect "iat" claim must be a older than "${currentTimestamp}" (iat: "${payload.iat}")` 102 | ); 103 | } 104 | 105 | if (!isNumber(payload.exp)) { 106 | throw new JwtError(JwtErrorCode.INVALID_ARGUMENT, `"exp" claim must be a number but got "${payload.exp}"`); 107 | } 108 | 109 | if (currentTimestamp > payload.exp) { 110 | throw new JwtError( 111 | JwtErrorCode.TOKEN_EXPIRED, 112 | `Incorrect "exp" (expiration time) claim must be a newer than "${currentTimestamp}" (exp: "${payload.exp}")` 113 | ); 114 | } 115 | 116 | return payload; 117 | }; 118 | 119 | const decodeBase64JSON = (b64Url: string): any => { 120 | const decoded = decodeBase64Url(b64Url); 121 | try { 122 | return JSON.parse(utf8Decoder.decode(decoded)); 123 | } catch { 124 | return null; 125 | } 126 | }; 127 | -------------------------------------------------------------------------------- /src/key-store.ts: -------------------------------------------------------------------------------- 1 | export interface KeyStorer { 2 | get(): Promise; 3 | put(value: string, expirationTtl: number): Promise; 4 | } 5 | 6 | /** 7 | * Class to get or store fetched public keys from a client certificates URL. 8 | */ 9 | export class WorkersKVStore implements KeyStorer { 10 | constructor( 11 | private readonly cacheKey: string, 12 | private readonly cfKVNamespace: KVNamespace 13 | ) {} 14 | 15 | public async get(): Promise { 16 | return await this.cfKVNamespace.get(this.cacheKey, 'json'); 17 | } 18 | 19 | public async put(value: string, expirationTtl: number): Promise { 20 | await this.cfKVNamespace.put(this.cacheKey, value, { 21 | expirationTtl, 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/token-verifier.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorInfo } from './errors'; 2 | import { AuthClientErrorCode, FirebaseAuthError, JwtError, JwtErrorCode } from './errors'; 3 | import type { SignatureVerifier } from './jws-verifier'; 4 | import { EmulatorSignatureVerifier, PublicKeySignatureVerifier } from './jws-verifier'; 5 | import type { DecodedPayload } from './jwt-decoder'; 6 | import { RS256Token } from './jwt-decoder'; 7 | import type { KeyStorer } from './key-store'; 8 | import { isNonEmptyString, isNonNullObject, isString, isURL } from './validator'; 9 | 10 | // Audience to use for Firebase Auth Custom tokens 11 | export const FIREBASE_AUDIENCE = 12 | 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; 13 | 14 | const EMULATOR_VERIFIER = new EmulatorSignatureVerifier(); 15 | 16 | /** 17 | * Interface representing a decoded Firebase ID token, returned from the 18 | * {@link BaseAuth.verifyIdToken} method. 19 | * 20 | * Firebase ID tokens are OpenID Connect spec-compliant JSON Web Tokens (JWTs). 21 | * See the 22 | * [ID Token section of the OpenID Connect spec](http://openid.net/specs/openid-connect-core-1_0.html#IDToken) 23 | * for more information about the specific properties below. 24 | */ 25 | export interface FirebaseIdToken { 26 | /** 27 | * The audience for which this token is intended. 28 | * 29 | * This value is a string equal to your Firebase project ID, the unique 30 | * identifier for your Firebase project, which can be found in [your project's 31 | * settings](https://console.firebase.google.com/project/_/settings/general/android:com.random.android). 32 | */ 33 | aud: string; 34 | 35 | /** 36 | * Time, in seconds since the Unix epoch, when the end-user authentication 37 | * occurred. 38 | * 39 | * This value is not set when this particular ID token was created, but when the 40 | * user initially logged in to this session. In a single session, the Firebase 41 | * SDKs will refresh a user's ID tokens every hour. Each ID token will have a 42 | * different [`iat`](#iat) value, but the same `auth_time` value. 43 | */ 44 | auth_time: number; 45 | 46 | /** 47 | * The email of the user to whom the ID token belongs, if available. 48 | */ 49 | email?: string; 50 | 51 | /** 52 | * Whether or not the email of the user to whom the ID token belongs is 53 | * verified, provided the user has an email. 54 | */ 55 | email_verified?: boolean; 56 | 57 | /** 58 | * The ID token's expiration time, in seconds since the Unix epoch. That is, the 59 | * time at which this ID token expires and should no longer be considered valid. 60 | * 61 | * The Firebase SDKs transparently refresh ID tokens every hour, issuing a new 62 | * ID token with up to a one hour expiration. 63 | */ 64 | exp: number; 65 | 66 | /** 67 | * Information about the sign in event, including which sign in provider was 68 | * used and provider-specific identity details. 69 | * 70 | * This data is provided by the Firebase Authentication service and is a 71 | * reserved claim in the ID token. 72 | */ 73 | firebase: { 74 | /** 75 | * Provider-specific identity details corresponding 76 | * to the provider used to sign in the user. 77 | */ 78 | identities: { 79 | [key: string]: any; 80 | }; 81 | 82 | /** 83 | * The ID of the provider used to sign in the user. 84 | * One of `"anonymous"`, `"password"`, `"facebook.com"`, `"github.com"`, 85 | * `"google.com"`, `"twitter.com"`, `"apple.com"`, `"microsoft.com"`, 86 | * `"yahoo.com"`, `"phone"`, `"playgames.google.com"`, `"gc.apple.com"`, 87 | * or `"custom"`. 88 | * 89 | * Additional Identity Platform provider IDs include `"linkedin.com"`, 90 | * OIDC and SAML identity providers prefixed with `"saml."` and `"oidc."` 91 | * respectively. 92 | */ 93 | sign_in_provider: string; 94 | 95 | /** 96 | * The type identifier or `factorId` of the second factor, provided the 97 | * ID token was obtained from a multi-factor authenticated user. 98 | * For phone, this is `"phone"`. 99 | */ 100 | sign_in_second_factor?: string; 101 | 102 | /** 103 | * The `uid` of the second factor used to sign in, provided the 104 | * ID token was obtained from a multi-factor authenticated user. 105 | */ 106 | second_factor_identifier?: string; 107 | 108 | /** 109 | * The ID of the tenant the user belongs to, if available. 110 | */ 111 | tenant?: string; 112 | [key: string]: any; 113 | }; 114 | 115 | /** 116 | * The ID token's issued-at time, in seconds since the Unix epoch. That is, the 117 | * time at which this ID token was issued and should start to be considered 118 | * valid. 119 | * 120 | * The Firebase SDKs transparently refresh ID tokens every hour, issuing a new 121 | * ID token with a new issued-at time. If you want to get the time at which the 122 | * user session corresponding to the ID token initially occurred, see the 123 | * [`auth_time`](#auth_time) property. 124 | */ 125 | iat: number; 126 | 127 | /** 128 | * The issuer identifier for the issuer of the response. 129 | * 130 | * This value is a URL with the format 131 | * `https://securetoken.google.com/`, where `` is the 132 | * same project ID specified in the [`aud`](#aud) property. 133 | */ 134 | iss: string; 135 | 136 | /** 137 | * The phone number of the user to whom the ID token belongs, if available. 138 | */ 139 | phone_number?: string; 140 | 141 | /** 142 | * The photo URL for the user to whom the ID token belongs, if available. 143 | */ 144 | picture?: string; 145 | 146 | /** 147 | * The `uid` corresponding to the user who the ID token belonged to. 148 | * 149 | * As a convenience, this value is copied over to the [`uid`](#uid) property. 150 | */ 151 | sub: string; 152 | 153 | /** 154 | * The `uid` corresponding to the user who the ID token belonged to. 155 | * 156 | * This value is not actually in the JWT token claims itself. It is added as a 157 | * convenience, and is set as the value of the [`sub`](#sub) property. 158 | */ 159 | uid: string; 160 | 161 | /** 162 | * Other arbitrary claims included in the ID token. 163 | */ 164 | [key: string]: any; 165 | } 166 | 167 | const makeExpectedbutGotMsg = (want: any, got: any) => `Expected "${want}" but got "${got}".`; 168 | 169 | /** 170 | * Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies. 171 | * 172 | * @internal 173 | */ 174 | export class FirebaseTokenVerifier { 175 | private readonly shortNameArticle: string; 176 | 177 | constructor( 178 | private readonly signatureVerifier: SignatureVerifier, 179 | private projectId: string, 180 | private issuer: string, 181 | private tokenInfo: FirebaseTokenInfo 182 | ) { 183 | if (!isNonEmptyString(projectId)) { 184 | throw new FirebaseAuthError( 185 | AuthClientErrorCode.INVALID_ARGUMENT, 186 | 'Your Firebase project ID must be a non-empty string' 187 | ); 188 | } else if (!isURL(issuer)) { 189 | throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, 'The provided JWT issuer is an invalid URL.'); 190 | } else if (!isNonNullObject(tokenInfo)) { 191 | throw new FirebaseAuthError( 192 | AuthClientErrorCode.INVALID_ARGUMENT, 193 | 'The provided JWT information is not an object or null.' 194 | ); 195 | } else if (!isURL(tokenInfo.url)) { 196 | throw new FirebaseAuthError( 197 | AuthClientErrorCode.INVALID_ARGUMENT, 198 | 'The provided JWT verification documentation URL is invalid.' 199 | ); 200 | } else if (!isNonEmptyString(tokenInfo.verifyApiName)) { 201 | throw new FirebaseAuthError( 202 | AuthClientErrorCode.INVALID_ARGUMENT, 203 | 'The JWT verify API name must be a non-empty string.' 204 | ); 205 | } else if (!isNonEmptyString(tokenInfo.jwtName)) { 206 | throw new FirebaseAuthError( 207 | AuthClientErrorCode.INVALID_ARGUMENT, 208 | 'The JWT public full name must be a non-empty string.' 209 | ); 210 | } else if (!isNonEmptyString(tokenInfo.shortName)) { 211 | throw new FirebaseAuthError( 212 | AuthClientErrorCode.INVALID_ARGUMENT, 213 | 'The JWT public short name must be a non-empty string.' 214 | ); 215 | } else if (!isNonNullObject(tokenInfo.expiredErrorCode) || !('code' in tokenInfo.expiredErrorCode)) { 216 | throw new FirebaseAuthError( 217 | AuthClientErrorCode.INVALID_ARGUMENT, 218 | 'The JWT expiration error code must be a non-null ErrorInfo object.' 219 | ); 220 | } 221 | this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; 222 | } 223 | 224 | /** 225 | * Verifies the format and signature of a Firebase Auth JWT token. 226 | * 227 | * @param jwtToken - The Firebase Auth JWT token to verify. 228 | * @param isEmulator - Whether to accept Auth Emulator tokens. 229 | * @param clockSkewSeconds - The number of seconds to tolerate when checking the token's iat. Must be between 0-60, and an integer. Defualts to 0. 230 | * @returns A promise fulfilled with the decoded claims of the Firebase Auth ID token. 231 | */ 232 | public verifyJWT(jwtToken: string, isEmulator = false, clockSkewSeconds: number = 5): Promise { 233 | if (!isString(jwtToken)) { 234 | throw new FirebaseAuthError( 235 | AuthClientErrorCode.INVALID_ARGUMENT, 236 | `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.` 237 | ); 238 | } 239 | 240 | if (clockSkewSeconds < 0 || clockSkewSeconds > 60 || !Number.isInteger(clockSkewSeconds)) { 241 | throw new FirebaseAuthError( 242 | AuthClientErrorCode.INVALID_ARGUMENT, 243 | 'clockSkewSeconds must be an integer between 0 and 60.' 244 | ); 245 | } 246 | return this.decodeAndVerify(jwtToken, isEmulator, clockSkewSeconds).then(payload => { 247 | payload.uid = payload.sub; 248 | return payload; 249 | }); 250 | } 251 | 252 | private async decodeAndVerify( 253 | token: string, 254 | isEmulator: boolean, 255 | clockSkewSeconds: number = 5 256 | ): Promise { 257 | const currentTimestamp = Math.floor(Date.now() / 1000) + clockSkewSeconds; 258 | try { 259 | const rs256Token = this.safeDecode(token, isEmulator, currentTimestamp); 260 | const { payload } = rs256Token.decodedToken; 261 | 262 | this.verifyPayload(payload, currentTimestamp); 263 | await this.verifySignature(rs256Token, isEmulator); 264 | 265 | return payload; 266 | } catch (err) { 267 | if (err instanceof JwtError) { 268 | throw this.mapJwtErrorToAuthError(err); 269 | } 270 | throw err; 271 | } 272 | } 273 | 274 | private safeDecode(jwtToken: string, isEmulator: boolean, currentTimestamp: number): RS256Token { 275 | try { 276 | return RS256Token.decode(jwtToken, currentTimestamp, isEmulator); 277 | } catch (err) { 278 | const verifyJwtTokenDocsMessage = 279 | ` See ${this.tokenInfo.url} ` + 280 | `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; 281 | const errorMessage = 282 | `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed ` + 283 | `the entire string JWT which represents ${this.shortNameArticle} ` + 284 | `${this.tokenInfo.shortName}.` + 285 | verifyJwtTokenDocsMessage; 286 | throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage + ` err: ${err}`); 287 | } 288 | } 289 | 290 | private verifyPayload( 291 | tokenPayload: DecodedPayload, 292 | currentTimestamp: number 293 | ): asserts tokenPayload is FirebaseIdToken { 294 | const payload = tokenPayload; 295 | 296 | const projectIdMatchMessage = 297 | ` Make sure the ${this.tokenInfo.shortName} comes from the same ` + 298 | 'Firebase project as the service account used to authenticate this SDK.'; 299 | const verifyJwtTokenDocsMessage = 300 | ` See ${this.tokenInfo.url} ` + 301 | `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; 302 | 303 | const createInvalidArgument = (errorMessage: string) => 304 | new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); 305 | 306 | if (payload.aud !== this.projectId && payload.aud !== FIREBASE_AUDIENCE) { 307 | throw createInvalidArgument( 308 | `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. ` + 309 | makeExpectedbutGotMsg(this.projectId, payload.aud) + 310 | projectIdMatchMessage + 311 | verifyJwtTokenDocsMessage 312 | ); 313 | } 314 | 315 | if (payload.iss !== this.issuer + this.projectId) { 316 | throw createInvalidArgument( 317 | `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. ` + 318 | makeExpectedbutGotMsg(this.issuer, payload.iss) + 319 | projectIdMatchMessage + 320 | verifyJwtTokenDocsMessage 321 | ); 322 | } 323 | 324 | if (payload.sub.length > 128) { 325 | throw createInvalidArgument( 326 | `${this.tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.` + verifyJwtTokenDocsMessage 327 | ); 328 | } 329 | 330 | // check auth_time claim 331 | if (typeof payload.auth_time !== 'number') { 332 | throw createInvalidArgument(`${this.tokenInfo.jwtName} has no "auth_time" claim. ` + verifyJwtTokenDocsMessage); 333 | } 334 | 335 | if (currentTimestamp < payload.auth_time) { 336 | throw createInvalidArgument( 337 | `${this.tokenInfo.jwtName} has incorrect "auth_time" claim. ` + verifyJwtTokenDocsMessage 338 | ); 339 | } 340 | } 341 | 342 | private async verifySignature(token: RS256Token, isEmulator: boolean): Promise { 343 | const verifier = isEmulator ? EMULATOR_VERIFIER : this.signatureVerifier; 344 | return await verifier.verify(token); 345 | } 346 | 347 | /** 348 | * Maps JwtError to FirebaseAuthError 349 | * 350 | * @param error - JwtError to be mapped. 351 | * @returns FirebaseAuthError or Error instance. 352 | */ 353 | private mapJwtErrorToAuthError(error: JwtError): Error { 354 | const verifyJwtTokenDocsMessage = 355 | ` See ${this.tokenInfo.url} ` + 356 | `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; 357 | if (error.code === JwtErrorCode.TOKEN_EXPIRED) { 358 | const errorMessage = 359 | `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` + 360 | ` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` + 361 | verifyJwtTokenDocsMessage; 362 | return new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage); 363 | } else if (error.code === JwtErrorCode.INVALID_SIGNATURE) { 364 | const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; 365 | return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); 366 | } else if (error.code === JwtErrorCode.NO_MATCHING_KID) { 367 | const errorMessage = 368 | `${this.tokenInfo.jwtName} has "kid" claim which does not ` + 369 | `correspond to a known public key. Most likely the ${this.tokenInfo.shortName} ` + 370 | 'is expired, so get a fresh token from your client app and try again.'; 371 | return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); 372 | } 373 | return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message); 374 | } 375 | } 376 | 377 | // URL containing the public keys for the Google certs (whose private keys are used to sign Firebase 378 | // Auth ID tokens) 379 | const CLIENT_JWK_URL = 'https://www.googleapis.com/robot/v1/metadata/jwk/securetoken@system.gserviceaccount.com'; 380 | 381 | /** 382 | * Interface that defines token related user facing information. 383 | * 384 | * @internal 385 | */ 386 | export interface FirebaseTokenInfo { 387 | /** Documentation URL. */ 388 | url: string; 389 | /** verify API name. */ 390 | verifyApiName: string; 391 | /** The JWT full name. */ 392 | jwtName: string; 393 | /** The JWT short name. */ 394 | shortName: string; 395 | /** JWT Expiration error code. */ 396 | expiredErrorCode: ErrorInfo; 397 | } 398 | 399 | /** 400 | * User facing token information related to the Firebase ID token. 401 | * 402 | * @internal 403 | */ 404 | export const ID_TOKEN_INFO: FirebaseTokenInfo = { 405 | url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens', 406 | verifyApiName: 'verifyIdToken()', 407 | jwtName: 'Firebase ID token', 408 | shortName: 'ID token', 409 | expiredErrorCode: AuthClientErrorCode.ID_TOKEN_EXPIRED, 410 | }; 411 | 412 | /** 413 | * Creates a new FirebaseTokenVerifier to verify Firebase ID tokens. 414 | * 415 | * @internal 416 | * @returns FirebaseTokenVerifier 417 | */ 418 | export function createIdTokenVerifier(projectID: string, keyStorer: KeyStorer): FirebaseTokenVerifier { 419 | const signatureVerifier = PublicKeySignatureVerifier.withCertificateUrl(CLIENT_JWK_URL, keyStorer); 420 | return baseCreateIdTokenVerifier(signatureVerifier, projectID); 421 | } 422 | 423 | /** 424 | * @internal 425 | * @returns FirebaseTokenVerifier 426 | */ 427 | export function baseCreateIdTokenVerifier( 428 | signatureVerifier: SignatureVerifier, 429 | projectID: string 430 | ): FirebaseTokenVerifier { 431 | return new FirebaseTokenVerifier(signatureVerifier, projectID, 'https://securetoken.google.com/', ID_TOKEN_INFO); 432 | } 433 | 434 | // URL containing the public keys for Firebase session cookies. 435 | const SESSION_COOKIE_CERT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys'; 436 | 437 | /** 438 | * User facing token information related to the Firebase session cookie. 439 | * 440 | * @internal 441 | */ 442 | export const SESSION_COOKIE_INFO: FirebaseTokenInfo = { 443 | url: 'https://firebase.google.com/docs/auth/admin/manage-cookies', 444 | verifyApiName: 'verifySessionCookie()', 445 | jwtName: 'Firebase session cookie', 446 | shortName: 'session cookie', 447 | expiredErrorCode: AuthClientErrorCode.SESSION_COOKIE_EXPIRED, 448 | }; 449 | 450 | /** 451 | * Creates a new FirebaseTokenVerifier to verify Firebase session cookies. 452 | * 453 | * @internal 454 | * @param app - Firebase app instance. 455 | * @returns FirebaseTokenVerifier 456 | */ 457 | export function createSessionCookieVerifier(projectID: string, keyStorer: KeyStorer): FirebaseTokenVerifier { 458 | const signatureVerifier = PublicKeySignatureVerifier.withCertificateUrl(SESSION_COOKIE_CERT_URL, keyStorer); 459 | return baseCreateSessionCookieVerifier(signatureVerifier, projectID); 460 | } 461 | 462 | /** 463 | * @internal 464 | * @returns FirebaseTokenVerifier 465 | */ 466 | export function baseCreateSessionCookieVerifier( 467 | signatureVerifier: SignatureVerifier, 468 | projectID: string 469 | ): FirebaseTokenVerifier { 470 | return new FirebaseTokenVerifier( 471 | signatureVerifier, 472 | projectID, 473 | 'https://session.firebase.google.com/', 474 | SESSION_COOKIE_INFO 475 | ); 476 | } 477 | -------------------------------------------------------------------------------- /src/utf8.ts: -------------------------------------------------------------------------------- 1 | export const utf8Encoder = new TextEncoder(); 2 | export const utf8Decoder = new TextDecoder(); 3 | -------------------------------------------------------------------------------- /src/validator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Validates that a string is a valid web URL. 3 | * 4 | * @param urlStr - The string to validate. 5 | * @returns Whether the string is valid web URL or not. 6 | */ 7 | export function isURL(urlStr: any): boolean { 8 | if (typeof urlStr !== 'string') { 9 | return false; 10 | } 11 | // Lookup illegal characters. 12 | const re = /[^a-z0-9:/?#[\]@!$&'()*+,;=.\-_~%]/i; 13 | if (re.test(urlStr)) { 14 | return false; 15 | } 16 | try { 17 | const uri = new URL(urlStr); 18 | const scheme = uri.protocol; 19 | const hostname = uri.hostname; 20 | const pathname = uri.pathname; 21 | if (scheme !== 'http:' && scheme !== 'https:') { 22 | return false; 23 | } 24 | // Validate hostname: Can contain letters, numbers, underscore and dashes separated by a dot. 25 | // Each zone must not start with a hyphen or underscore. 26 | if (!hostname || !/^[a-zA-Z0-9]+[\w-]*([.]?[a-zA-Z0-9]+[\w-]*)*$/.test(hostname)) { 27 | return false; 28 | } 29 | // Allow for pathnames: (/chars+)*/? 30 | // Where chars can be a combination of: a-z A-Z 0-9 - _ . ~ ! $ & ' ( ) * + , ; = : @ % 31 | const pathnameRe = /^(\/[\w\-.~!$'()*+,;=:@%]+)*\/?$/; 32 | // Validate pathname. 33 | if (pathname && pathname !== '/' && !pathnameRe.test(pathname)) { 34 | return false; 35 | } 36 | // Allow any query string and hash as long as no invalid character is used. 37 | } catch (e) { 38 | return false; 39 | } 40 | return true; 41 | } 42 | 43 | /** 44 | * Validates that a value is a number. 45 | * 46 | * @param value - The value to validate. 47 | * @returns Whether the value is a number or not. 48 | */ 49 | export function isNumber(value: any): value is number { 50 | return typeof value === 'number'; 51 | } 52 | 53 | /** 54 | * Validates that a value is a string. 55 | * 56 | * @param value - The value to validate. 57 | * @returns Whether the value is a string or not. 58 | */ 59 | export function isString(value: any): value is string { 60 | return typeof value === 'string'; 61 | } 62 | 63 | /** 64 | * Validates that a value is a non-empty string. 65 | * 66 | * @param value - The value to validate. 67 | * @returns Whether the value is a non-empty string or not. 68 | */ 69 | export function isNonEmptyString(value: any): value is string { 70 | return isString(value) && value !== ''; 71 | } 72 | 73 | /** 74 | * 75 | /** 76 | * Validates that a value is a nullable object. 77 | * 78 | * @param value - The value to validate. 79 | * @returns Whether the value is an object or not. 80 | */ 81 | export function isObject(value: any): boolean { 82 | return typeof value === 'object' && !Array.isArray(value); 83 | } 84 | 85 | /** 86 | * Validates that a value is a non-null object. 87 | * 88 | * @param value - The value to validate. 89 | * @returns Whether the value is a non-null object or not. 90 | */ 91 | export function isNonNullObject(value: T | null | undefined): value is T { 92 | return isObject(value) && value !== null; 93 | } 94 | 95 | /** 96 | * Validates that a string is a valid Firebase Auth uid. 97 | * 98 | * @param uid - The string to validate. 99 | * @returns Whether the string is a valid Firebase Auth uid. 100 | */ 101 | export function isUid(uid: any): boolean { 102 | return typeof uid === 'string' && uid.length > 0 && uid.length <= 128; 103 | } 104 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | export const version = '2.0.6'; 2 | -------------------------------------------------------------------------------- /src/x509.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64 } from './base64'; 2 | 3 | /** 4 | * Parses a sequence of ASN.1 elements from a given Uint8Array. 5 | * Internally, this function repeatedly calls `parseElement` on 6 | * the subarray until the entire sequence is consumed, returning 7 | * an array of parsed elements. 8 | */ 9 | function getElement(seq: Uint8Array) { 10 | const result = []; 11 | let next = 0; 12 | 13 | while (next < seq.length) { 14 | // Parse one ASN.1 element from the remaining subarray 15 | const nextPart = parseElement(seq.subarray(next)); 16 | result.push(nextPart); 17 | // Advance the pointer by the element's total byte length 18 | next += nextPart.byteLength; 19 | } 20 | return result; 21 | } 22 | 23 | /** 24 | * Parses a single ASN.1 element (in DER encoding) from the given byte array. 25 | * 26 | * Each element consists of: 27 | * 1) Tag (possibly multiple bytes if 0x1f is encountered) 28 | * 2) Length (short form or long form, possibly indefinite) 29 | * 3) Contents (the data payload) 30 | * 31 | * Returns an object containing: 32 | * - byteLength: total size (in bytes) of this element (including tag & length) 33 | * - contents: Uint8Array of just the element's contents 34 | * - raw: Uint8Array of the entire element (tag + length + contents) 35 | */ 36 | function parseElement(bytes: Uint8Array) { 37 | let position = 0; 38 | 39 | // --- Parse Tag --- 40 | // The tag is in the lower 5 bits (0x1f). If it's 0x1f, it indicates a multi-byte tag. 41 | let tag = bytes[0] & 0x1f; 42 | position++; 43 | if (tag === 0x1f) { 44 | tag = 0; 45 | // Continue reading the tag bytes while each byte >= 0x80 46 | while (bytes[position] >= 0x80) { 47 | tag = tag * 128 + bytes[position] - 0x80; 48 | position++; 49 | } 50 | tag = tag * 128 + bytes[position] - 0x80; 51 | position++; 52 | } 53 | 54 | // --- Parse Length --- 55 | let length = 0; 56 | // Short-form length: if less than 0x80, it's the length itself 57 | if (bytes[position] < 0x80) { 58 | length = bytes[position]; 59 | position++; 60 | } else if (length === 0x80) { 61 | // Indefinite length form: scan until 0x00 0x00 62 | length = 0; 63 | while (bytes[position + length] !== 0 || bytes[position + length + 1] !== 0) { 64 | if (length > bytes.byteLength) { 65 | throw new TypeError('invalid indefinite form length'); 66 | } 67 | length++; 68 | } 69 | const byteLength = position + length + 2; 70 | return { 71 | byteLength, 72 | contents: bytes.subarray(position, position + length), 73 | raw: bytes.subarray(0, byteLength), 74 | }; 75 | } else { 76 | // Long-form length: the lower 7 bits of this byte indicates how many bytes follow for length 77 | const numberOfDigits = bytes[position] & 0x7f; 78 | position++; 79 | length = 0; 80 | // Accumulate the length from these "numberOfDigits" bytes 81 | for (let i = 0; i < numberOfDigits; i++) { 82 | length = length * 256 + bytes[position]; 83 | position++; 84 | } 85 | } 86 | 87 | // The total byte length of this element (tag + length + contents) 88 | const byteLength = position + length; 89 | return { 90 | byteLength, 91 | contents: bytes.subarray(position, byteLength), 92 | raw: bytes.subarray(0, byteLength), 93 | }; 94 | } 95 | 96 | /** 97 | * Extracts the SubjectPublicKeyInfo (SPKI) portion from a DER-encoded X.509 certificate. 98 | * 99 | * Steps: 100 | * 1) Parse the entire certificate as an ASN.1 SEQUENCE. 101 | * 2) Retrieve the TBS (To-Be-Signed) Certificate, which is the first element. 102 | * 3) Parse the TBS Certificate to get its internal fields (version, serial, issuer, etc.). 103 | * 4) Depending on whether the version field is present (tag = 0xa0), the SPKI is either 104 | * at index 6 or 5 (skipping version if absent). 105 | * 5) Finally, encode the raw SPKI bytes in CryptoKey and return. 106 | */ 107 | async function spkiFromX509(buf: Uint8Array): Promise { 108 | // Parse the top-level ASN.1 structure, then get the top-level contents 109 | // which typically contain [ TBS Certificate, signatureAlgorithm, signature ]. 110 | // Retrieve TBS Certificate as [0], then parse TBS Certificate further. 111 | const tbsCertificate = getElement(getElement(parseElement(buf).contents)[0].contents); 112 | 113 | // In the TBS Certificate, check whether the first element (index 0) is a version field (tag=0xa0). 114 | // If it is, the SubjectPublicKeyInfo is the 7th element (index 6). 115 | // Otherwise, it is the 6th element (index 5). 116 | const spki = tbsCertificate[tbsCertificate[0].raw[0] === 0xa0 ? 6 : 5].raw; 117 | return await crypto.subtle.importKey( 118 | 'spki', 119 | spki, 120 | { 121 | name: 'RSASSA-PKCS1-v1_5', 122 | hash: 'SHA-256', 123 | }, 124 | true, 125 | ['verify'] 126 | ); 127 | } 128 | 129 | export async function jwkFromX509(kid: string, x509: string): Promise { 130 | const pem = x509.replace(/(?:-----(?:BEGIN|END) CERTIFICATE-----|\s)/g, ''); 131 | const raw = decodeBase64(pem); 132 | const spki = await spkiFromX509(raw); 133 | const { kty, alg, n, e } = await crypto.subtle.exportKey('jwk', spki); 134 | return { 135 | kid, 136 | use: 'sig', 137 | kty, 138 | alg, 139 | n, 140 | e, 141 | }; 142 | } 143 | -------------------------------------------------------------------------------- /tests/auth-api-request.test.ts: -------------------------------------------------------------------------------- 1 | import type { MockInstance } from 'vitest'; 2 | import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; 3 | import { FIREBASE_AUTH_CREATE_SESSION_COOKIE } from '../src/auth-api-requests'; 4 | import * as validator from '../src/validator'; 5 | 6 | describe('FIREBASE_AUTH_CREATE_SESSION_COOKIE', () => { 7 | // Spy on all validators. 8 | let isNonEmptyString: MockInstance; 9 | let isNumber: MockInstance; 10 | 11 | beforeEach(() => { 12 | isNonEmptyString = vi.spyOn(validator, 'isNonEmptyString'); 13 | isNumber = vi.spyOn(validator, 'isNumber'); 14 | }); 15 | afterEach(() => { 16 | vi.restoreAllMocks(); 17 | }); 18 | 19 | it('should return the correct endpoint', () => { 20 | expect(FIREBASE_AUTH_CREATE_SESSION_COOKIE.getEndpoint()).to.equal(':createSessionCookie'); 21 | }); 22 | 23 | it('should return the correct http method', () => { 24 | expect(FIREBASE_AUTH_CREATE_SESSION_COOKIE.getHttpMethod()).to.equal('POST'); 25 | }); 26 | 27 | describe('requestValidator', () => { 28 | const requestValidator = FIREBASE_AUTH_CREATE_SESSION_COOKIE.getRequestValidator(); 29 | it('should succeed with valid parameters passed', () => { 30 | const validRequest = { idToken: 'ID_TOKEN', validDuration: 60 * 60 }; 31 | expect(() => { 32 | return requestValidator(validRequest); 33 | }).not.to.throw(); 34 | expect(isNonEmptyString).toHaveBeenCalledWith('ID_TOKEN'); 35 | expect(isNumber).toHaveBeenCalledWith(60 * 60); 36 | }); 37 | it('should succeed with duration set at minimum allowed', () => { 38 | const validDuration = 60 * 5; 39 | const validRequest = { idToken: 'ID_TOKEN', validDuration }; 40 | expect(() => { 41 | return requestValidator(validRequest); 42 | }).not.to.throw(); 43 | expect(isNonEmptyString).toHaveBeenCalledWith('ID_TOKEN'); 44 | expect(isNumber).toHaveBeenCalledWith(validDuration); 45 | }); 46 | it('should succeed with duration set at maximum allowed', () => { 47 | const validDuration = 60 * 60 * 24 * 14; 48 | const validRequest = { idToken: 'ID_TOKEN', validDuration }; 49 | expect(() => { 50 | return requestValidator(validRequest); 51 | }).not.to.throw(); 52 | expect(isNonEmptyString).toHaveBeenCalledWith('ID_TOKEN'); 53 | expect(isNumber).toHaveBeenCalledWith(validDuration); 54 | }); 55 | it('should fail when idToken not passed', () => { 56 | const invalidRequest = { validDuration: 60 * 60 }; 57 | expect(() => { 58 | return requestValidator(invalidRequest); 59 | }).to.throw(); 60 | expect(isNonEmptyString).toHaveBeenCalledWith(undefined); 61 | }); 62 | it('should fail when validDuration not passed', () => { 63 | const invalidRequest = { idToken: 'ID_TOKEN' }; 64 | expect(() => { 65 | return requestValidator(invalidRequest); 66 | }).to.throw(); 67 | expect(isNumber).toHaveBeenCalledWith(undefined); 68 | }); 69 | describe('called with invalid parameters', () => { 70 | it('should fail with invalid idToken', () => { 71 | expect(() => { 72 | return requestValidator({ idToken: '', validDuration: 60 * 60 }); 73 | }).to.throw(); 74 | expect(isNonEmptyString).toHaveBeenCalledWith(''); 75 | }); 76 | it('should fail with invalid validDuration', () => { 77 | expect(() => { 78 | return requestValidator({ idToken: 'ID_TOKEN', validDuration: 'invalid' }); 79 | }).to.throw(); 80 | expect(isNonEmptyString).toHaveBeenCalledWith('ID_TOKEN'); 81 | expect(isNumber).toHaveBeenCalledWith('invalid'); 82 | }); 83 | it('should fail with validDuration less than minimum allowed', () => { 84 | // Duration less 5 minutes. 85 | const outOfBoundDuration = 60 * 5 - 1; 86 | expect(() => { 87 | return requestValidator({ idToken: 'ID_TOKEN', validDuration: outOfBoundDuration }); 88 | }).to.throw(); 89 | expect(isNonEmptyString).toHaveBeenCalledWith('ID_TOKEN'); 90 | expect(isNumber).toHaveBeenCalledWith(outOfBoundDuration); 91 | }); 92 | it('should fail with validDuration greater than maximum allowed', () => { 93 | // Duration greater than 14 days. 94 | const outOfBoundDuration = 60 * 60 * 24 * 14 + 1; 95 | expect(() => { 96 | return requestValidator({ idToken: 'ID_TOKEN', validDuration: outOfBoundDuration }); 97 | }).to.throw(); 98 | expect(isNonEmptyString).toHaveBeenCalledWith('ID_TOKEN'); 99 | expect(isNumber).toHaveBeenCalledWith(outOfBoundDuration); 100 | }); 101 | }); 102 | }); 103 | describe('responseValidator', () => { 104 | const responseValidator = FIREBASE_AUTH_CREATE_SESSION_COOKIE.getResponseValidator(); 105 | it('should succeed with sessionCookie returned', () => { 106 | const validResponse = { sessionCookie: 'SESSION_COOKIE' }; 107 | expect(() => { 108 | return responseValidator(validResponse); 109 | }).not.to.throw(); 110 | }); 111 | it('should fail when no session cookie is returned', () => { 112 | const invalidResponse = {}; 113 | expect(() => { 114 | responseValidator(invalidResponse); 115 | }).to.throw(); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /tests/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { ApiSettings } from '../src/api-requests'; 3 | import { BaseAuth } from '../src/auth'; 4 | import { AuthApiClient } from '../src/auth-api-requests'; 5 | import { AuthClientErrorCode, FirebaseAuthError } from '../src/errors'; 6 | import type { UserRecord } from '../src/user-record'; 7 | import type { EmulatorEnv, KeyStorer } from './../src/index'; 8 | import { 9 | EmulatedSigner, 10 | FirebaseTokenGenerator, 11 | NopCredential, 12 | projectId, 13 | signInWithCustomToken, 14 | } from './firebase-utils'; 15 | 16 | const env: EmulatorEnv = { 17 | FIREBASE_AUTH_EMULATOR_HOST: '127.0.0.1:9099', 18 | }; 19 | 20 | const sessionCookieUids = [ 21 | generateRandomString(20), 22 | generateRandomString(20), 23 | generateRandomString(20), 24 | generateRandomString(20), 25 | ]; 26 | 27 | describe('createSessionCookie()', () => { 28 | const expiresIn = 24 * 60 * 60 * 1000; 29 | 30 | const uid = sessionCookieUids[0]; 31 | const uid2 = sessionCookieUids[1]; 32 | const uid3 = sessionCookieUids[2]; 33 | const uid4 = sessionCookieUids[3]; 34 | 35 | const signer = new EmulatedSigner(); 36 | const tokenGenerator = new FirebaseTokenGenerator(signer); 37 | const keyStorer = new InMemoryKeyStorer('cache-key'); 38 | 39 | it('creates a valid Firebase session cookie', async () => { 40 | const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); 41 | 42 | const customToken = await tokenGenerator.createCustomToken(uid, { admin: true, groupId: '1234' }); 43 | const { idToken } = await signInWithCustomToken(customToken, env); 44 | 45 | const decodedToken = await auth.verifyIdToken(idToken, false, env); 46 | 47 | const expectedExp = Math.floor((new Date().getTime() + expiresIn) / 1000); 48 | const want = { 49 | ...decodedToken, 50 | iss: decodedToken.iss.replace('securetoken.google.com', 'session.firebase.google.com'), 51 | exp: undefined, 52 | iat: undefined, 53 | auth_time: undefined, 54 | }; 55 | const expectedIat = Math.floor(new Date().getTime() / 1000); 56 | 57 | const sessionCookie = await auth.createSessionCookie(idToken, { expiresIn }, env); 58 | const got = await auth.verifySessionCookie(sessionCookie, false, env); 59 | // Check for expected expiration with +/-5 seconds of variation. 60 | expect(got.exp).to.be.within(expectedExp - 5, expectedExp + 5); 61 | expect(got.iat).to.be.within(expectedIat - 5, expectedIat + 5); 62 | 63 | expect({ 64 | ...got, 65 | // exp and iat may vary depending on network connection latency. 66 | exp: undefined, 67 | iat: undefined, 68 | auth_time: undefined, 69 | }).to.deep.equal(want); 70 | }); 71 | 72 | it('creates a revocable session cookie', async () => { 73 | const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); 74 | 75 | const customToken = await tokenGenerator.createCustomToken(uid2, { admin: true, groupId: '1234' }); 76 | const { idToken } = await signInWithCustomToken(customToken, env); 77 | 78 | const sessionCookie = await auth.createSessionCookie(idToken, { expiresIn }, env); 79 | 80 | await new Promise(resolve => setTimeout(() => resolve(auth.revokeRefreshTokens(uid2, env)), 1000)); 81 | 82 | await expect(auth.verifySessionCookie(sessionCookie, false, env)).resolves.toHaveProperty('uid', uid2); 83 | 84 | await expect(auth.verifySessionCookie(sessionCookie, true, env)).rejects.toThrowError( 85 | new FirebaseAuthError(AuthClientErrorCode.SESSION_COOKIE_REVOKED) 86 | ); 87 | }); 88 | 89 | it('fails when called with a revoked ID token', async () => { 90 | const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); 91 | 92 | const customToken = await tokenGenerator.createCustomToken(uid3, { admin: true, groupId: '1234' }); 93 | const { idToken } = await signInWithCustomToken(customToken, env); 94 | 95 | await new Promise(resolve => setTimeout(() => resolve(auth.revokeRefreshTokens(uid3, env)), 1000)); 96 | // auth/id-token-expired 97 | await expect(auth.createSessionCookie(idToken, { expiresIn }, env)).rejects.toThrowError( 98 | new FirebaseAuthError(AuthClientErrorCode.ID_TOKEN_EXPIRED) 99 | ); 100 | }); 101 | 102 | it('fails when called with user disabled', async () => { 103 | const expiresIn = 24 * 60 * 60 * 1000; 104 | const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); 105 | 106 | const customToken = await tokenGenerator.createCustomToken(uid4, { admin: true, groupId: '1234' }); 107 | const { idToken } = await signInWithCustomToken(customToken, env); 108 | 109 | const decodedIdTokenClaims = await auth.verifyIdToken(idToken, false, env); 110 | expect(decodedIdTokenClaims.uid).toBe(uid4); 111 | 112 | const sessionCookie = await auth.createSessionCookie(idToken, { expiresIn }, env); 113 | const decodedIdToken = await auth.verifySessionCookie(sessionCookie, true, env); 114 | expect(decodedIdToken.uid).toBe(uid4); 115 | 116 | const cli = new TestAuthApiClient(projectId, new NopCredential()); 117 | 118 | const userRecord = await cli.disableUser(uid4, env); 119 | // Ensure disabled field has been updated. 120 | expect(userRecord.uid).toBe(uid4); 121 | expect(userRecord.disabled).toBe(true); 122 | 123 | await expect(auth.createSessionCookie(idToken, { expiresIn }, env)).rejects.toThrowError( 124 | new FirebaseAuthError(AuthClientErrorCode.USER_DISABLED) 125 | ); 126 | }); 127 | }); 128 | 129 | describe('verifySessionCookie()', () => { 130 | const uid = sessionCookieUids[0]; 131 | const keyStorer = new InMemoryKeyStorer('cache-key'); 132 | const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); 133 | const signer = new EmulatedSigner(); 134 | const tokenGenerator = new FirebaseTokenGenerator(signer); 135 | 136 | it('fails when called with an invalid session cookie', async () => { 137 | await expect(auth.verifySessionCookie('invalid-token', false, env)).rejects.toThrowError(FirebaseAuthError); 138 | }); 139 | 140 | it('fails when called with a Firebase ID token', async () => { 141 | const customToken = await tokenGenerator.createCustomToken(uid, { admin: true, groupId: '1234' }); 142 | const { idToken } = await signInWithCustomToken(customToken, env); 143 | 144 | await expect(auth.verifySessionCookie(idToken, false, env)).rejects.toThrowError(FirebaseAuthError); 145 | }); 146 | 147 | it('fails with checkRevoked set to true and corresponding user disabled', async () => { 148 | const expiresIn = 24 * 60 * 60 * 1000; 149 | const customToken = await tokenGenerator.createCustomToken(uid, { admin: true, groupId: '1234' }); 150 | const { idToken } = await signInWithCustomToken(customToken, env); 151 | 152 | const decodedIdTokenClaims = await auth.verifyIdToken(idToken, false, env); 153 | expect(decodedIdTokenClaims.uid).toBe(uid); 154 | 155 | const sessionCookie = await auth.createSessionCookie(idToken, { expiresIn }, env); 156 | const decodedIdToken = await auth.verifySessionCookie(sessionCookie, true, env); 157 | expect(decodedIdToken.uid).to.equal(uid); 158 | 159 | const cli = new TestAuthApiClient(projectId, new NopCredential()); 160 | const userRecord = await cli.disableUser(uid, env); 161 | 162 | // Ensure disabled field has been updated. 163 | expect(userRecord.uid).to.equal(uid); 164 | expect(userRecord.disabled).to.equal(true); 165 | 166 | await expect(auth.verifySessionCookie(sessionCookie, false, env)).resolves.toHaveProperty('uid', uid); 167 | 168 | await expect(auth.verifySessionCookie(sessionCookie, true, env)).rejects.toThrowError( 169 | new FirebaseAuthError(AuthClientErrorCode.USER_DISABLED) 170 | ); 171 | }); 172 | }); 173 | 174 | describe('getUser()', () => { 175 | const newUserUid = generateRandomString(20); 176 | const customClaims: { [key: string]: any } = { 177 | admin: true, 178 | groupId: '1234', 179 | }; 180 | const keyStorer = new InMemoryKeyStorer('cache-key'); 181 | const auth = new BaseAuth(projectId, keyStorer, new NopCredential()); 182 | const signer = new EmulatedSigner(); 183 | const tokenGenerator = new FirebaseTokenGenerator(signer); 184 | 185 | it('setCustomUserClaims() sets claims that are accessible via user ID token', async () => { 186 | // Register user 187 | const customToken = await tokenGenerator.createCustomToken(newUserUid, {}); 188 | await signInWithCustomToken(customToken, env); 189 | 190 | // Set custom claims on the user. 191 | await auth.setCustomUserClaims(newUserUid, customClaims, env); 192 | const userRecord = await auth.getUser(newUserUid, env); 193 | expect(userRecord.customClaims).toEqual(customClaims); 194 | 195 | const { idToken } = await signInWithCustomToken(customToken, env); 196 | const decodedIdToken = await auth.verifyIdToken(idToken, false, env); 197 | 198 | // Confirm expected claims set on the user's ID token. 199 | for (const key in customClaims) { 200 | if (Object.prototype.hasOwnProperty.call(customClaims, key)) { 201 | expect(decodedIdToken[key]).toEqual(customClaims[key]); 202 | } 203 | } 204 | 205 | // Test clearing of custom claims. 206 | await auth.setCustomUserClaims(newUserUid, null, env); 207 | const userRecord2 = await auth.getUser(newUserUid, env); 208 | 209 | // Custom claims should be cleared. 210 | expect(userRecord2.customClaims).toEqual({}); 211 | 212 | // Confirm all custom claims are cleared from id token. 213 | const { idToken: idToken2 } = await signInWithCustomToken(customToken, env); 214 | const decodedIdToken2 = await auth.verifyIdToken(idToken2, false, env); 215 | 216 | for (const key in customClaims) { 217 | if (Object.prototype.hasOwnProperty.call(customClaims, key)) { 218 | expect(decodedIdToken2[key]).toBeUndefined(); 219 | } 220 | } 221 | }); 222 | }); 223 | 224 | function generateRandomString(stringLength: number) { 225 | const randomValues = new Uint8Array(stringLength); 226 | crypto.getRandomValues(randomValues); 227 | let randomString = ''; 228 | for (let i = 0; i < stringLength; i++) { 229 | randomString += randomValues[i].toString(36)[0]; 230 | } 231 | return randomString; 232 | } 233 | 234 | class InMemoryKeyStorer implements KeyStorer { 235 | private store: Map = new Map(); 236 | private timerId: NodeJS.Timeout | null = null; 237 | 238 | constructor(private readonly cacheKey: string) {} 239 | 240 | public async get(): Promise { 241 | return (this.store.get(this.cacheKey) as ExpectedValue) || null; 242 | } 243 | 244 | public async put(value: string, expirationTtl: number): Promise { 245 | if (this.timerId) { 246 | clearTimeout(this.timerId); 247 | } 248 | this.store.set(this.cacheKey, value); 249 | this.timerId = setTimeout(() => this.store.delete(this.cacheKey), expirationTtl * 1000); 250 | } 251 | } 252 | 253 | const FIREBASE_AUTH_DISABLE_USER = new ApiSettings('v1', '/accounts:update', 'POST') 254 | // Set response validator. 255 | .setResponseValidator((response: any) => { 256 | // If the localId is not returned, then the request failed. 257 | if (!response.localId) { 258 | throw new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); 259 | } 260 | }); 261 | 262 | class TestAuthApiClient extends AuthApiClient { 263 | public async disableUser(uid: string, env?: EmulatorEnv): Promise { 264 | const request: any = { 265 | localId: uid, 266 | disableUser: true, 267 | }; 268 | const { localId } = await this.fetch<{ localId: string }>(FIREBASE_AUTH_DISABLE_USER, request, env); 269 | return await this.getAccountInfoByUid(localId, env); 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /tests/base64.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { decodeBase64Url, encodeBase64Url } from '../src/base64'; 3 | import { utf8Encoder } from '../src/utf8'; 4 | 5 | const urlRef = (s: string): string => s.replace(/\+|\//g, m => ({ '+': '-', '/': '_' })[m] ?? m); 6 | 7 | const str2UInt8Array = (s: string): Uint8Array => { 8 | const buffer = new Uint8Array(new ArrayBuffer(s.length)); 9 | for (let i = 0; i < buffer.byteLength; i++) { 10 | buffer[i] = s.charCodeAt(i); 11 | } 12 | return buffer; 13 | }; 14 | 15 | describe('base64', () => { 16 | describe.each([ 17 | // basic 18 | [utf8Encoder.encode('Hello, 世界'), 'SGVsbG8sIOS4lueVjA=='], 19 | 20 | // RFC 3548 examples 21 | [str2UInt8Array('\x14\xfb\x9c\x03\xd9\x7e'), 'FPucA9l+'], 22 | [str2UInt8Array('\x14\xfb\x9c\x03\xd9'), 'FPucA9k='], 23 | [str2UInt8Array('\x14\xfb\x9c\x03'), 'FPucAw=='], 24 | 25 | // RFC 4648 examples 26 | [str2UInt8Array(''), ''], 27 | [str2UInt8Array('f'), 'Zg=='], 28 | [str2UInt8Array('fo'), 'Zm8='], 29 | [str2UInt8Array('foo'), 'Zm9v'], 30 | [str2UInt8Array('foob'), 'Zm9vYg=='], 31 | [str2UInt8Array('fooba'), 'Zm9vYmE='], 32 | [str2UInt8Array('foobar'), 'Zm9vYmFy'], 33 | 34 | // Wikipedia examples 35 | [str2UInt8Array('sure.'), 'c3VyZS4='], 36 | [str2UInt8Array('sure'), 'c3VyZQ=='], 37 | [str2UInt8Array('sur'), 'c3Vy'], 38 | [str2UInt8Array('su'), 'c3U='], 39 | [str2UInt8Array('leasure.'), 'bGVhc3VyZS4='], 40 | [str2UInt8Array('easure.'), 'ZWFzdXJlLg=='], 41 | [str2UInt8Array('asure.'), 'YXN1cmUu'], 42 | [str2UInt8Array('sure.'), 'c3VyZS4='], 43 | ])('%s, %s', (decoded, encoded) => { 44 | it('encode', () => { 45 | const got = encodeBase64Url(decoded); 46 | const want = urlRef(encoded); 47 | expect(got).toStrictEqual(want); 48 | }); 49 | it('decode', () => { 50 | const got = decodeBase64Url(urlRef(encoded)); 51 | const want = decoded; 52 | expect(got).toStrictEqual(want); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /tests/client.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll } from 'vitest'; 2 | import { ApiSettings } from '../src/api-requests'; 3 | import { BaseClient, buildApiUrl } from '../src/client'; 4 | import type { EmulatorEnv } from '../src/emulator'; 5 | import { FirebaseAppError } from '../src/errors'; 6 | import { fetchMock } from './fetch'; 7 | import { NopCredential } from './firebase-utils'; 8 | 9 | describe('buildApiUrl', () => { 10 | it('should build correct url for production environment', () => { 11 | const projectId = 'test-project'; 12 | const apiSettings = new ApiSettings('v1', '/test-endpoint'); 13 | const expectedUrl = 'https://identitytoolkit.googleapis.com/v1/projects/test-project/test-endpoint'; 14 | 15 | const result = buildApiUrl(projectId, apiSettings); 16 | 17 | expect(result).toBe(expectedUrl); 18 | }); 19 | 20 | it('should build correct url for emulator environment', () => { 21 | const projectId = 'test-project'; 22 | const apiSettings = new ApiSettings('v1', '/test-endpoint'); 23 | const env: EmulatorEnv = { FIREBASE_AUTH_EMULATOR_HOST: '127.0.0.1:9099' }; 24 | const expectedUrl = 'http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/projects/test-project/test-endpoint'; 25 | 26 | const result = buildApiUrl(projectId, apiSettings, env); 27 | 28 | expect(result).toBe(expectedUrl); 29 | }); 30 | }); 31 | 32 | class TestClient extends BaseClient { 33 | async fetch(apiSettings: ApiSettings, requestData?: object, env?: EmulatorEnv): Promise { 34 | return await super.fetch(apiSettings, requestData, env); 35 | } 36 | } 37 | 38 | describe('BaseClient', () => { 39 | beforeAll(() => { 40 | fetchMock.disableNetConnect(); 41 | }); 42 | 43 | const projectid = 'test-project'; 44 | const testUrl = `https://identitytoolkit.googleapis.com/v1/projects/${projectid}:test`; 45 | const u = new URL(testUrl); 46 | const apiSettings = new ApiSettings('v1', ':test', 'POST'); 47 | 48 | it('should make valid request', async () => { 49 | const want = { data: 'test data' }; 50 | 51 | const origin = fetchMock.get(u.origin); 52 | origin 53 | .intercept({ 54 | method: 'POST', 55 | path: u.pathname, 56 | }) 57 | .reply(200, JSON.stringify(want)); 58 | 59 | const client = new TestClient(projectid, new NopCredential()); 60 | const res = await client.fetch<{ data: string }>(apiSettings, {}); 61 | 62 | expect(res).toEqual(want); 63 | }); 64 | 65 | it('should retry on failure and eventually succeed', async () => { 66 | const want = { data: 'test data' }; 67 | const errorResponse = { error: 'Server error' }; 68 | 69 | const origin = fetchMock.get(u.origin); 70 | origin 71 | .intercept({ 72 | method: 'POST', 73 | path: u.pathname, 74 | }) 75 | .reply(503, JSON.stringify(errorResponse)) 76 | .times(3); 77 | origin 78 | .intercept({ 79 | method: 'POST', 80 | path: u.pathname, 81 | }) 82 | .reply(200, JSON.stringify(want)); 83 | 84 | const client = new TestClient(projectid, new NopCredential(), { 85 | maxRetries: 4, 86 | statusCodes: [503], 87 | maxDelayInMillis: 1000, 88 | }); 89 | const res = await client.fetch<{ data: string }>(apiSettings, {}); 90 | 91 | expect(res).toEqual(want); 92 | }); 93 | 94 | it('should retry on failure but finally failure', async () => { 95 | const errorResponse = { error: 'Server error' }; 96 | 97 | const origin = fetchMock.get(u.origin); 98 | origin 99 | .intercept({ 100 | method: 'POST', 101 | path: u.pathname, 102 | }) 103 | .reply(503, JSON.stringify(errorResponse)) 104 | .times(5); 105 | 106 | const client = new TestClient(projectid, new NopCredential(), { 107 | maxRetries: 4, 108 | statusCodes: [503], 109 | maxDelayInMillis: 1000, 110 | }); 111 | 112 | await expect(client.fetch<{ data: string }>(apiSettings, {})).rejects.toThrowError(FirebaseAppError); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /tests/fetch.ts: -------------------------------------------------------------------------------- 1 | import { MockAgent } from 'undici'; 2 | 3 | // waiting to replace with: 4 | // https://github.com/cloudflare/workers-sdk/tree/bcoll/vitest-pool-workers/packages/vitest-pool-workers 5 | export const fetchMock = new MockAgent({ connections: 1 }); 6 | -------------------------------------------------------------------------------- /tests/firebase-utils.ts: -------------------------------------------------------------------------------- 1 | import type { Credential } from '../src'; 2 | import { encodeBase64Url, encodeObjectBase64Url } from '../src/base64'; 3 | import type { GoogleOAuthAccessToken } from '../src/credential'; 4 | import type { EmulatorEnv } from '../src/emulator'; 5 | import { emulatorHost } from '../src/emulator'; 6 | import { AuthClientErrorCode, FirebaseAuthError } from '../src/errors'; 7 | import { PublicKeySignatureVerifier } from '../src/jws-verifier'; 8 | import { FIREBASE_AUDIENCE, type FirebaseIdToken } from '../src/token-verifier'; 9 | import { utf8Encoder } from '../src/utf8'; 10 | import { isNonEmptyString, isNonNullObject } from '../src/validator'; 11 | import { signJWT, genTime, genIss, TestingKeyFetcher } from './jwk-utils'; 12 | 13 | export const projectId = 'project12345'; // see package.json 14 | export const userId = 'userId12345'; 15 | 16 | export async function generateIdToken( 17 | currentTimestamp?: number, 18 | overrides?: Partial 19 | ): Promise { 20 | const now = currentTimestamp ?? genTime(Date.now()); 21 | return Object.assign( 22 | { 23 | aud: projectId, 24 | exp: now + 9999, 25 | iat: now - 10000, // -10s 26 | iss: genIss(projectId), 27 | sub: userId, 28 | auth_time: now - 20000, // -20s 29 | uid: userId, 30 | firebase: { 31 | identities: {}, 32 | sign_in_provider: 'google.com', 33 | }, 34 | } satisfies FirebaseIdToken, 35 | overrides 36 | ); 37 | } 38 | 39 | export async function generateSessionCookie( 40 | currentTimestamp?: number, 41 | overrides?: Partial 42 | ): Promise { 43 | const now = currentTimestamp ?? genTime(Date.now()); 44 | return Object.assign( 45 | { 46 | aud: projectId, 47 | exp: now + 9999, 48 | iat: now - 10000, // -10s 49 | iss: 'https://session.firebase.google.com/' + projectId, 50 | sub: userId, 51 | auth_time: now - 20000, // -20s 52 | uid: userId, 53 | firebase: { 54 | identities: {}, 55 | sign_in_provider: 'google.com', 56 | }, 57 | } satisfies FirebaseIdToken, 58 | overrides 59 | ); 60 | } 61 | 62 | interface SignVerifyPair { 63 | jwt: string; 64 | verifier: PublicKeySignatureVerifier; 65 | } 66 | 67 | export async function createTestingSignVerifyPair(payload: FirebaseIdToken): Promise { 68 | const kid = 'aaaaaaaaaabbbbbbbbbbccccccccccdddddddddd'; 69 | const testingKeyFetcher = await TestingKeyFetcher.withKeyPairGeneration(kid); 70 | const jwt = await signJWT(kid, payload, testingKeyFetcher.getPrivateKey()); 71 | return { 72 | jwt, 73 | verifier: new PublicKeySignatureVerifier(testingKeyFetcher), 74 | }; 75 | } 76 | 77 | /** 78 | * CryptoSigner interface represents an object that can be used to sign JWTs. 79 | */ 80 | interface CryptoSigner { 81 | /** 82 | * The name of the signing algorithm. 83 | */ 84 | readonly algorithm: Algorithm; 85 | 86 | /** 87 | * Cryptographically signs a buffer of data. 88 | * 89 | * @param buffer - The data to be signed. 90 | * @returns A promise that resolves with the raw bytes of a signature. 91 | */ 92 | sign(buffer: Uint8Array): Promise; 93 | 94 | /** 95 | * Returns the ID of the service account used to sign tokens. 96 | * 97 | * @returns A promise that resolves with a service account ID. 98 | */ 99 | getAccountId(): Promise; 100 | } 101 | 102 | // List of blacklisted claims which cannot be provided when creating a custom token 103 | const BLACKLISTED_CLAIMS = [ 104 | 'acr', 105 | 'amr', 106 | 'at_hash', 107 | 'aud', 108 | 'auth_time', 109 | 'azp', 110 | 'cnf', 111 | 'c_hash', 112 | 'exp', 113 | 'iat', 114 | 'iss', 115 | 'jti', 116 | 'nbf', 117 | 'nonce', 118 | ]; 119 | 120 | /** 121 | * A CryptoSigner implementation that is used when communicating with the Auth emulator. 122 | * It produces unsigned tokens. 123 | */ 124 | export class EmulatedSigner implements CryptoSigner { 125 | public algorithm = { 126 | name: 'none', 127 | }; 128 | /** 129 | * @inheritDoc 130 | */ 131 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 132 | public async sign(buffer: Uint8Array): Promise { 133 | return utf8Encoder.encode(''); 134 | } 135 | 136 | /** 137 | * @inheritDoc 138 | */ 139 | public async getAccountId(): Promise { 140 | return 'firebase-auth-emulator@example.com'; 141 | } 142 | } 143 | 144 | export class FirebaseTokenGenerator { 145 | private readonly signer: CryptoSigner; 146 | 147 | constructor(signer: CryptoSigner) { 148 | if (!isNonNullObject(signer)) { 149 | throw new FirebaseAuthError( 150 | AuthClientErrorCode.INVALID_CREDENTIAL, 151 | 'INTERNAL ASSERT: Must provide a CryptoSigner to use FirebaseTokenGenerator.' 152 | ); 153 | } 154 | this.signer = signer; 155 | } 156 | 157 | /** 158 | * Creates a new Firebase Auth Custom token. 159 | * 160 | * @param uid - The user ID to use for the generated Firebase Auth Custom token. 161 | * @param developerClaims - Optional developer claims to include in the generated Firebase 162 | * Auth Custom token. 163 | * @returns A Promise fulfilled with a Firebase Auth Custom token signed with a 164 | * service account key and containing the provided payload. 165 | */ 166 | public async createCustomToken(uid: string, developerClaims?: { [key: string]: any }): Promise { 167 | let errorMessage: string | undefined; 168 | if (!isNonEmptyString(uid)) { 169 | errorMessage = '`uid` argument must be a non-empty string uid.'; 170 | } else if (uid.length > 128) { 171 | errorMessage = '`uid` argument must a uid with less than or equal to 128 characters.'; 172 | } else if (!this.isDeveloperClaimsValid_(developerClaims)) { 173 | errorMessage = '`developerClaims` argument must be a valid, non-null object containing the developer claims.'; 174 | } 175 | 176 | if (errorMessage) { 177 | throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); 178 | } 179 | 180 | const claims: { [key: string]: any } = {}; 181 | if (typeof developerClaims !== 'undefined') { 182 | for (const key in developerClaims) { 183 | /* istanbul ignore else */ 184 | if (Object.prototype.hasOwnProperty.call(developerClaims, key)) { 185 | if (BLACKLISTED_CLAIMS.indexOf(key) !== -1) { 186 | throw new FirebaseAuthError( 187 | AuthClientErrorCode.INVALID_ARGUMENT, 188 | `Developer claim "${key}" is reserved and cannot be specified.` 189 | ); 190 | } 191 | claims[key] = developerClaims[key]; 192 | } 193 | } 194 | } 195 | const account = await this.signer.getAccountId(); 196 | const header = { 197 | alg: this.signer.algorithm, 198 | typ: 'JWT', 199 | }; 200 | const iat = Math.floor(Date.now() / 1000); 201 | const body: Omit = { 202 | aud: FIREBASE_AUDIENCE, 203 | iat, 204 | exp: iat + 3600, 205 | iss: account, 206 | sub: account, 207 | uid, 208 | }; 209 | if (Object.keys(claims).length > 0) { 210 | body.claims = claims; 211 | } 212 | const token = `${encodeObjectBase64Url(header)}.${encodeObjectBase64Url(body)}`.replace(/=/g, ''); 213 | const signature = await this.signer.sign(utf8Encoder.encode(token)); 214 | const base64Signature = encodeBase64Url(signature).replace(/=/g, ''); 215 | return `${token}.${base64Signature}`; 216 | } 217 | 218 | /** 219 | * Returns whether or not the provided developer claims are valid. 220 | * 221 | * @param developerClaims - Optional developer claims to validate. 222 | * @returns True if the provided claims are valid; otherwise, false. 223 | */ 224 | // eslint-disable-next-line @typescript-eslint/naming-convention 225 | private isDeveloperClaimsValid_(developerClaims?: object): boolean { 226 | if (typeof developerClaims === 'undefined') { 227 | return true; 228 | } 229 | return isNonNullObject(developerClaims); 230 | } 231 | } 232 | 233 | interface signInWithCustomTokenResponse { 234 | kind: string; // deprecated 235 | idToken: string; 236 | refreshToken: string; 237 | expiresIn: string; // int64 format 238 | isNewUser: boolean; 239 | } 240 | 241 | export async function signInWithCustomToken( 242 | customToken: string, 243 | env?: EmulatorEnv 244 | ): Promise { 245 | const host = emulatorHost(env); 246 | if (!isNonEmptyString(host)) { 247 | throw new Error('unexpected emulator host is empty'); 248 | } 249 | const signInPath = '/identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=test1234'; 250 | const firebaseEmulatorSignInUrl = 'http://' + host + signInPath; 251 | const res = await fetch(firebaseEmulatorSignInUrl, { 252 | method: 'POST', 253 | body: JSON.stringify({ 254 | token: customToken, 255 | returnSecureToken: true, 256 | }), 257 | headers: { 258 | 'Content-Type': 'application/json', 259 | }, 260 | }); 261 | if (!res.ok) { 262 | throw new Error(res.statusText); 263 | } 264 | const json = await res.json(); 265 | return json as signInWithCustomTokenResponse; 266 | } 267 | 268 | export class NopCredential implements Credential { 269 | getAccessToken(): Promise { 270 | return Promise.resolve({ 271 | access_token: 'owner', 272 | expires_in: 9 * 3600, 273 | }); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /tests/jwk-fetcher.test.ts: -------------------------------------------------------------------------------- 1 | import { Miniflare } from 'miniflare'; 2 | import { describe, it, expect, vi } from 'vitest'; 3 | import type { Fetcher } from '../src/jwk-fetcher'; 4 | import { isJWKMetadata, isX509Certificates, parseMaxAge, UrlKeyFetcher } from '../src/jwk-fetcher'; 5 | import { WorkersKVStore } from '../src/key-store'; 6 | 7 | class HTTPMockFetcher implements Fetcher { 8 | constructor(private readonly response: Response) {} 9 | 10 | public fetch(): Promise { 11 | return Promise.resolve(this.response.clone()); 12 | } 13 | } 14 | 15 | const nullScript = 'export default { fetch: () => new Response(null, { status: 404 }) };'; 16 | const mf = new Miniflare({ 17 | modules: true, 18 | script: nullScript, 19 | kvNamespaces: ['TEST_NAMESPACE'], 20 | }); 21 | 22 | const validResponseJSON = `{ 23 | "keys": [ 24 | { 25 | "kid": "f90fb1ae048a548fb681ad6092b0b869ea467ac6", 26 | "e": "AQAB", 27 | "kty": "RSA", 28 | "n": "v1DLA89xpRpQ2bA2Ku__34z98eISnT1coBgA3QNjitmpM-4rf1pPNH6MKxOOj4ZxvzSeGlOjB7XiQwX3lQJ-ZDeSvS45fWIKrDW33AyFn-Z4VFJLVRb7j4sqLa6xsTj5rkbJBDwwGbGXOo37o5Ewfn0S52GFDjl2ALKexIgu7cUKKHsykr_h6D6RdhwpHvjG_H5Omq9mY7wDxLTvtYyrpN3wONAf4uMsJn9GDgMsAu7UkhDSICX5jmhVUDvYJA3FKokFyjG7PdetNnh00prL_CtH1Bs8f06sWwQKQMTDUrKEyEHuc2bzWNfGXRrc-c_gRNWP9k7vzOTcAIFSWlA7Fw", 29 | "alg": "RS256", 30 | "use": "sig" 31 | }, 32 | { 33 | "use": "sig", 34 | "kid": "9897cf9459e254ff1c67a4eb6efea52f21a9ba14", 35 | "n": "ylSiwcLD0KXrnzo4QlVFdVjx3OL5x0qYOgkcdLgiBxABUq9Y7AuIwABlCKVYcMCscUnooQvEATShnLdbqu0lLOaTiK1JxblIGonZrOB8-MlXn7-RnEmQNuMbvNK7QdwTrz3uzbqB64Z70DoC0qLVPT5v9ivzNfulh6UEuNVvFupC2zbrP84oxzRmpgcF0lxpiZf4qfCC2aKU8wDCqP14-PqHLI54nfm9QBLJLz4uS00OqdwWITSjX3nlBVcDqvCbJi3_V-eoBP42prVTreILWHw0SqP6FGt2lFPWeMnGinlRLAdwaEStrPzclvAupR5vEs3-m0UCOUt0rZOZBtTNkw", 36 | "e": "AQAB", 37 | "kty": "RSA", 38 | "alg": "RS256" 39 | } 40 | ] 41 | }`; 42 | 43 | const wantValidArray = [ 44 | { 45 | kid: 'f90fb1ae048a548fb681ad6092b0b869ea467ac6', 46 | e: 'AQAB', 47 | kty: 'RSA', 48 | n: 'v1DLA89xpRpQ2bA2Ku__34z98eISnT1coBgA3QNjitmpM-4rf1pPNH6MKxOOj4ZxvzSeGlOjB7XiQwX3lQJ-ZDeSvS45fWIKrDW33AyFn-Z4VFJLVRb7j4sqLa6xsTj5rkbJBDwwGbGXOo37o5Ewfn0S52GFDjl2ALKexIgu7cUKKHsykr_h6D6RdhwpHvjG_H5Omq9mY7wDxLTvtYyrpN3wONAf4uMsJn9GDgMsAu7UkhDSICX5jmhVUDvYJA3FKokFyjG7PdetNnh00prL_CtH1Bs8f06sWwQKQMTDUrKEyEHuc2bzWNfGXRrc-c_gRNWP9k7vzOTcAIFSWlA7Fw', 49 | alg: 'RS256', 50 | use: 'sig', 51 | }, 52 | { 53 | use: 'sig', 54 | kid: '9897cf9459e254ff1c67a4eb6efea52f21a9ba14', 55 | n: 'ylSiwcLD0KXrnzo4QlVFdVjx3OL5x0qYOgkcdLgiBxABUq9Y7AuIwABlCKVYcMCscUnooQvEATShnLdbqu0lLOaTiK1JxblIGonZrOB8-MlXn7-RnEmQNuMbvNK7QdwTrz3uzbqB64Z70DoC0qLVPT5v9ivzNfulh6UEuNVvFupC2zbrP84oxzRmpgcF0lxpiZf4qfCC2aKU8wDCqP14-PqHLI54nfm9QBLJLz4uS00OqdwWITSjX3nlBVcDqvCbJi3_V-eoBP42prVTreILWHw0SqP6FGt2lFPWeMnGinlRLAdwaEStrPzclvAupR5vEs3-m0UCOUt0rZOZBtTNkw', 56 | e: 'AQAB', 57 | kty: 'RSA', 58 | alg: 'RS256', 59 | }, 60 | ]; 61 | 62 | describe('UrlKeyFetcher', () => { 63 | it('expected normal flow', async () => { 64 | const cacheKey = 'normal-flow-key'; 65 | const mockedFetcher = new HTTPMockFetcher( 66 | new Response(validResponseJSON, { 67 | headers: { 68 | 'Cache-Control': 'public, max-age=18793, must-revalidate, no-transform', 69 | }, 70 | }) 71 | ); 72 | const TEST_NAMESPACE = await mf.getKVNamespace('TEST_NAMESPACE'); 73 | const urlKeyFetcher = new UrlKeyFetcher(mockedFetcher, new WorkersKVStore(cacheKey, TEST_NAMESPACE)); 74 | 75 | const httpFetcherSpy = vi.spyOn(mockedFetcher, 'fetch'); 76 | 77 | // first call (no-cache in KV) 78 | const firstKeys = await urlKeyFetcher.fetchPublicKeys(); 79 | expect(firstKeys).toEqual(wantValidArray); 80 | expect(httpFetcherSpy).toBeCalledTimes(1); 81 | 82 | // second call (has cache, get from KV) 83 | const secondKeys = await urlKeyFetcher.fetchPublicKeys(); 84 | expect(secondKeys).toEqual(wantValidArray); 85 | expect(httpFetcherSpy).toBeCalledTimes(1); // same as first 86 | 87 | // cache is expired 88 | await TEST_NAMESPACE.delete(cacheKey); 89 | 90 | // third call (expired-cache, get from origin server) 91 | const thirdKeys = await urlKeyFetcher.fetchPublicKeys(); 92 | expect(thirdKeys).toEqual(wantValidArray); 93 | expect(httpFetcherSpy).toBeCalledTimes(2); // updated 94 | }); 95 | 96 | it('normal flow but not max-age header in response', async () => { 97 | const cacheKey = 'normal-non-max-age-flow-key'; 98 | const mockedFetcher = new HTTPMockFetcher( 99 | new Response(validResponseJSON, { 100 | headers: {}, 101 | }) 102 | ); 103 | const TEST_NAMESPACE = await mf.getKVNamespace('TEST_NAMESPACE'); 104 | const urlKeyFetcher = new UrlKeyFetcher(mockedFetcher, new WorkersKVStore(cacheKey, TEST_NAMESPACE)); 105 | 106 | const httpFetcherSpy = vi.spyOn(mockedFetcher, 'fetch'); 107 | 108 | // first call (no-cache in KV) 109 | const firstKeys = await urlKeyFetcher.fetchPublicKeys(); 110 | expect(firstKeys).toEqual(wantValidArray); 111 | expect(httpFetcherSpy).toBeCalledTimes(1); 112 | 113 | // second call (no cache, get from origin server) 114 | const secondKeys = await urlKeyFetcher.fetchPublicKeys(); 115 | expect(secondKeys).toEqual(wantValidArray); 116 | expect(httpFetcherSpy).toBeCalledTimes(2); 117 | }); 118 | 119 | it('internal server error fetch', async () => { 120 | const cacheKey = 'failed-fetch-flow-key'; 121 | const internalServerMsg = 'Internal Server Error'; 122 | const mockedFetcher = new HTTPMockFetcher( 123 | new Response(internalServerMsg, { 124 | status: 500, 125 | }) 126 | ); 127 | const TEST_NAMESPACE = await mf.getKVNamespace('TEST_NAMESPACE'); 128 | const urlKeyFetcher = new UrlKeyFetcher(mockedFetcher, new WorkersKVStore(cacheKey, TEST_NAMESPACE)); 129 | 130 | await expect(() => urlKeyFetcher.fetchPublicKeys()).rejects.toThrowError( 131 | 'Error fetching public keys for Google certs: ' + internalServerMsg 132 | ); 133 | }); 134 | 135 | it('ok fetch but got text response', async () => { 136 | const cacheKey = 'ok-fetch-non-json-flow-key'; 137 | const mockedFetcher = new HTTPMockFetcher( 138 | new Response('{}', { 139 | status: 200, 140 | }) 141 | ); 142 | const TEST_NAMESPACE = await mf.getKVNamespace('TEST_NAMESPACE'); 143 | const urlKeyFetcher = new UrlKeyFetcher(mockedFetcher, new WorkersKVStore(cacheKey, TEST_NAMESPACE)); 144 | 145 | await expect(() => urlKeyFetcher.fetchPublicKeys()).rejects.toThrowError( 146 | 'The public keys are not an object or null:' 147 | ); 148 | }); 149 | }); 150 | 151 | describe('parseMaxAge', () => { 152 | it.each([ 153 | ['valid simple', 'max-age=604800', 604800], 154 | ['valid with other directives', 'public, max-age=18793, must-revalidate, no-transform', 18793], 155 | ['invalid cache-control header is null', null, NaN], 156 | ['invalid max-age is not found', 'public', NaN], 157 | ['invalid max-age is invalid format', 'public, max-age=hello', NaN], 158 | ])('%s', (_, cacheControlHeader, want) => { 159 | const maxAge = parseMaxAge(cacheControlHeader); 160 | expect(maxAge).toStrictEqual(want); 161 | }); 162 | }); 163 | 164 | describe('isJWKMetadata', () => { 165 | it('should return true for valid JWKMetadata', () => { 166 | const valid = JSON.parse(validResponseJSON); 167 | expect(isJWKMetadata(valid)).toBe(true); 168 | }); 169 | 170 | it('should return false for null', () => { 171 | expect(isJWKMetadata(null)).toBe(false); 172 | }); 173 | 174 | it('should return false for undefined', () => { 175 | expect(isJWKMetadata(undefined)).toBe(false); 176 | }); 177 | 178 | it('should return false for non-object', () => { 179 | expect(isJWKMetadata('string')).toBe(false); 180 | expect(isJWKMetadata(123)).toBe(false); 181 | expect(isJWKMetadata(true)).toBe(false); 182 | }); 183 | 184 | it('should return false for object without keys property', () => { 185 | const invalidJWKMetadata = { 186 | notKeys: [], 187 | }; 188 | expect(isJWKMetadata(invalidJWKMetadata)).toBe(false); 189 | }); 190 | 191 | it('returns false if keys is not an array', () => { 192 | expect(isJWKMetadata({ keys: {} })).toBe(false); 193 | expect(isJWKMetadata({ keys: 'string' })).toBe(false); 194 | }); 195 | 196 | it('returns false if keys is an array but its elements do not have a kid property', () => { 197 | expect(isJWKMetadata({ keys: [{}] })).toBe(false); 198 | }); 199 | 200 | it('returns false if keys is an array but kid is not a string', () => { 201 | expect(isJWKMetadata({ keys: [{ kid: 123 }] })).toBe(false); 202 | }); 203 | 204 | it('returns false if only some keys have a kid property that is a string', () => { 205 | expect(isJWKMetadata({ keys: [{ kid: 'string' }, {}] })).toBe(false); 206 | }); 207 | }); 208 | 209 | describe('isX509Certificates', () => { 210 | it('should return true for valid X509 certificates', () => { 211 | const validX509 = { 212 | cert1: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz6', 213 | cert2: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7', 214 | }; 215 | expect(isX509Certificates(validX509)).toBe(true); 216 | }); 217 | 218 | it('should return false for null', () => { 219 | expect(isX509Certificates(null)).toBe(false); 220 | }); 221 | 222 | it('should return false for undefined', () => { 223 | expect(isX509Certificates(undefined)).toBe(false); 224 | }); 225 | 226 | it('should return false for non-object', () => { 227 | expect(isX509Certificates('string')).toBe(false); 228 | expect(isX509Certificates(123)).toBe(false); 229 | expect(isX509Certificates(true)).toBe(false); 230 | }); 231 | 232 | it('should return false for object with non-string values', () => { 233 | const invalidX509 = { 234 | cert1: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz6', 235 | cert2: 123, 236 | }; 237 | expect(isX509Certificates(invalidX509)).toBe(false); 238 | }); 239 | 240 | it('should return false for object with empty values', () => { 241 | const invalidX509 = { 242 | cert1: '', 243 | cert2: '', 244 | }; 245 | expect(isX509Certificates(invalidX509)).toBe(false); 246 | }); 247 | 248 | it('should return false for object with mixed valid and invalid values', () => { 249 | const invalidX509 = { 250 | cert1: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz6', 251 | cert2: 123, 252 | cert3: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7', 253 | }; 254 | expect(isX509Certificates(invalidX509)).toBe(false); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /tests/jwk-utils.ts: -------------------------------------------------------------------------------- 1 | import { encodeBase64Url, encodeObjectBase64Url } from '../src/base64'; 2 | import type { KeyFetcher } from '../src/jwk-fetcher'; 3 | import { rs256alg } from '../src/jws-verifier'; 4 | import type { DecodedHeader, DecodedPayload } from '../src/jwt-decoder'; 5 | import { utf8Encoder } from '../src/utf8'; 6 | import type { JsonWebKeyWithKid } from '@cloudflare/workers-types'; 7 | 8 | export class TestingKeyFetcher implements KeyFetcher { 9 | constructor( 10 | public readonly kid: string, 11 | private readonly keyPair: CryptoKeyPair 12 | ) {} 13 | 14 | public static async withKeyPairGeneration(kid: string): Promise { 15 | const keyPair = await crypto.subtle.generateKey(rs256alg, true, ['sign', 'verify']); 16 | return new TestingKeyFetcher(kid, keyPair); 17 | } 18 | 19 | public async fetchPublicKeys(): Promise> { 20 | const publicJWK = await crypto.subtle.exportKey('jwk', this.keyPair.publicKey); 21 | return [{ kid: this.kid, ...publicJWK }]; 22 | } 23 | 24 | public getPrivateKey(): CryptoKey { 25 | return this.keyPair.privateKey; 26 | } 27 | } 28 | 29 | export const genTime = (ms: number = Date.now()): number => Math.floor(ms / 1000); 30 | export const genIss = (projectId: string = 'projectId1234'): string => 'https://securetoken.google.com/' + projectId; 31 | 32 | export const signJWT = async (kid: string, payload: DecodedPayload, privateKey: CryptoKey) => { 33 | const header: DecodedHeader = { 34 | alg: 'RS256', 35 | typ: 'JWT', 36 | kid, 37 | }; 38 | const encodedHeader = encodeObjectBase64Url(header).replace(/=/g, ''); 39 | const encodedPayload = encodeObjectBase64Url(payload).replace(/=/g, ''); 40 | const headerAndPayload = `${encodedHeader}.${encodedPayload}`; 41 | 42 | const signature = await crypto.subtle.sign(rs256alg, privateKey, utf8Encoder.encode(headerAndPayload)); 43 | 44 | const base64Signature = encodeBase64Url(signature).replace(/=/g, ''); 45 | return `${headerAndPayload}.${base64Signature}`; 46 | }; 47 | -------------------------------------------------------------------------------- /tests/jws-verifier.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { JwtError, JwtErrorCode } from '../src/errors'; 3 | import { PublicKeySignatureVerifier, rs256alg } from '../src/jws-verifier'; 4 | import type { DecodedPayload } from '../src/jwt-decoder'; 5 | import { RS256Token } from '../src/jwt-decoder'; 6 | import { genTime, genIss, signJWT, TestingKeyFetcher } from './jwk-utils'; 7 | 8 | describe('PublicKeySignatureVerifier', () => { 9 | const kid = 'kid123456'; 10 | const projectId = 'projectId1234'; 11 | const currentTimestamp = genTime(Date.now()); 12 | const payload: DecodedPayload = { 13 | aud: projectId, 14 | exp: currentTimestamp + 9999, 15 | iat: currentTimestamp - 10000, // -10s 16 | iss: genIss(projectId), 17 | sub: 'userId12345', 18 | }; 19 | 20 | it('valid', async () => { 21 | const testingKeyFetcher = await TestingKeyFetcher.withKeyPairGeneration(kid); 22 | const verifier = new PublicKeySignatureVerifier(testingKeyFetcher); 23 | 24 | const jwt = await signJWT(kid, payload, testingKeyFetcher.getPrivateKey()); 25 | const rs256Token = RS256Token.decode(jwt, currentTimestamp); 26 | await verifier.verify(rs256Token); 27 | }); 28 | 29 | it('invalid public key', async () => { 30 | const testingKeyFetcher = await TestingKeyFetcher.withKeyPairGeneration(kid); 31 | const verifier = new PublicKeySignatureVerifier(testingKeyFetcher); 32 | const anotherKeyPair = await crypto.subtle.generateKey(rs256alg, true, ['sign', 'verify']); 33 | 34 | // set another private key 35 | const jwt = await signJWT(kid, payload, anotherKeyPair.privateKey); 36 | const rs256Token = RS256Token.decode(jwt, currentTimestamp); 37 | await expect(verifier.verify(rs256Token)).rejects.toThrowError( 38 | new JwtError(JwtErrorCode.INVALID_SIGNATURE, 'The token signature is invalid.') 39 | ); 40 | }); 41 | 42 | it('invalid kid', async () => { 43 | const testingKeyFetcher = await TestingKeyFetcher.withKeyPairGeneration('mismachKid'); 44 | const verifier = new PublicKeySignatureVerifier(testingKeyFetcher); 45 | const jwt = await signJWT(kid, payload, testingKeyFetcher.getPrivateKey()); 46 | const rs256Token = RS256Token.decode(jwt, currentTimestamp); 47 | await expect(verifier.verify(rs256Token)).rejects.toThrowError( 48 | new JwtError(JwtErrorCode.NO_MATCHING_KID, 'The token does not match the kid.') 49 | ); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/jwt-decoder.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { encodeObjectBase64Url } from '../src/base64'; 3 | import { JwtError, JwtErrorCode } from '../src/errors'; 4 | import type { DecodedHeader, DecodedPayload } from '../src/jwt-decoder'; 5 | import { RS256Token } from '../src/jwt-decoder'; 6 | import { genTime, genIss, signJWT, TestingKeyFetcher } from './jwk-utils'; 7 | 8 | describe('TokenDecoder', () => { 9 | const kid = 'kid123456'; 10 | const projectId = 'projectId1234'; 11 | const currentTimestamp = genTime(Date.now()); 12 | const payload: DecodedPayload = { 13 | aud: projectId, 14 | exp: currentTimestamp, // now (for border test) 15 | iat: currentTimestamp - 10000, // -10s 16 | iss: genIss(projectId), 17 | sub: 'userId12345', 18 | }; 19 | 20 | it('invalid token', () => { 21 | expect(() => RS256Token.decode('invalid', currentTimestamp)).toThrowError( 22 | new JwtError(JwtErrorCode.INVALID_ARGUMENT, 'token must consist of 3 parts') 23 | ); 24 | }); 25 | 26 | describe('header', () => { 27 | const validHeader: DecodedHeader = { 28 | kid, 29 | alg: 'RS256', 30 | }; 31 | 32 | it('invalid kid', () => { 33 | const headerPart = encodeObjectBase64Url({ 34 | ...validHeader, 35 | kid: undefined, 36 | }); 37 | const jwt = `${headerPart}.payload.signature`; 38 | expect(() => RS256Token.decode(jwt, currentTimestamp)).toThrowError( 39 | new JwtError(JwtErrorCode.NO_KID_IN_HEADER, `kid must be a string but got ${undefined}`) 40 | ); 41 | }); 42 | 43 | it('invalid alg', () => { 44 | const headerPart = encodeObjectBase64Url({ 45 | ...validHeader, 46 | alg: 'HS256', 47 | }); 48 | const jwt = `${headerPart}.payload.signature`; 49 | expect(() => RS256Token.decode(jwt, currentTimestamp)).toThrowError( 50 | new JwtError(JwtErrorCode.INVALID_ARGUMENT, `algorithm must be RS256 but got ${'HS256'}`) 51 | ); 52 | }); 53 | }); 54 | 55 | describe('payload', () => { 56 | it.each([ 57 | [ 58 | 'aud', 59 | { 60 | ...payload, 61 | aud: '', 62 | }, 63 | new JwtError(JwtErrorCode.INVALID_ARGUMENT, '"aud" claim must be a string but got ""'), 64 | ], 65 | [ 66 | 'sub', 67 | { 68 | ...payload, 69 | sub: '', 70 | }, 71 | new JwtError(JwtErrorCode.INVALID_ARGUMENT, '"sub" claim must be a string but got ""'), 72 | ], 73 | [ 74 | 'iss', 75 | { 76 | ...payload, 77 | iss: '', 78 | }, 79 | new JwtError(JwtErrorCode.INVALID_ARGUMENT, '"iss" claim must be a string but got ""'), 80 | ], 81 | [ 82 | 'iat is in future', 83 | { 84 | ...payload, 85 | iat: currentTimestamp + 10000, // +10s 86 | }, 87 | new JwtError( 88 | JwtErrorCode.INVALID_ARGUMENT, 89 | `Incorrect "iat" claim must be a older than "${currentTimestamp}" (iat: "${currentTimestamp + 10000}")` 90 | ), 91 | ], 92 | [ 93 | 'exp is in past', 94 | { 95 | ...payload, 96 | exp: currentTimestamp - 10000, // -10s 97 | }, 98 | new JwtError( 99 | JwtErrorCode.INVALID_ARGUMENT, 100 | `Incorrect "exp" (expiration time) claim must be a newer than "${currentTimestamp}" (exp: "${ 101 | currentTimestamp - 10000 102 | }")` 103 | ), 104 | ], 105 | ])('invalid %s', async (_, payload, wantErr) => { 106 | const testingKeyFetcher = await TestingKeyFetcher.withKeyPairGeneration('mismachKid'); 107 | const jwt = await signJWT(kid, payload, testingKeyFetcher.getPrivateKey()); 108 | expect(() => RS256Token.decode(jwt, currentTimestamp)).toThrowError(wantErr); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto'; 2 | import { setGlobalDispatcher } from 'undici'; 3 | import { vi, beforeAll, afterAll } from 'vitest'; 4 | import { fetchMock } from './fetch'; 5 | 6 | vi.stubGlobal('crypto', crypto); 7 | 8 | beforeAll(() => { 9 | setGlobalDispatcher(fetchMock); 10 | }); 11 | 12 | afterAll(() => { 13 | fetchMock.deactivate(); 14 | fetchMock.enableNetConnect(); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/token-verifier.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { PublicKeySignatureVerifier, rs256alg } from '../src/jws-verifier'; 3 | import { FIREBASE_AUDIENCE, baseCreateIdTokenVerifier, baseCreateSessionCookieVerifier } from '../src/token-verifier'; 4 | import { createTestingSignVerifyPair, generateIdToken, generateSessionCookie, projectId } from './firebase-utils'; 5 | import { genTime, signJWT, TestingKeyFetcher } from './jwk-utils'; 6 | 7 | describe('FirebaseTokenVerifier', () => { 8 | const testCases = [ 9 | { 10 | name: 'createIdTokenVerifier', 11 | tokenGenerator: generateIdToken, 12 | firebaseTokenVerifier: baseCreateIdTokenVerifier, 13 | }, 14 | { 15 | name: 'createSessionCookieVerifier', 16 | tokenGenerator: generateSessionCookie, 17 | firebaseTokenVerifier: baseCreateSessionCookieVerifier, 18 | }, 19 | ]; 20 | for (const tc of testCases) { 21 | describe(tc.name, () => { 22 | const currentTimestamp = genTime(Date.now()); 23 | 24 | it.each([ 25 | ['valid without firebase emulator', tc.tokenGenerator(currentTimestamp)], 26 | [ 27 | 'valid custom token without firebase emulator', 28 | tc.tokenGenerator(currentTimestamp, { aud: FIREBASE_AUDIENCE }), 29 | ], 30 | ])('%s', async (_, promise) => { 31 | const payload = await promise; 32 | const pair = await createTestingSignVerifyPair(payload); 33 | const ftv = tc.firebaseTokenVerifier(pair.verifier, projectId); 34 | const token = await ftv.verifyJWT(pair.jwt, false); 35 | 36 | expect(token).toStrictEqual(payload); 37 | }); 38 | 39 | it.each([ 40 | ['aud', tc.tokenGenerator(currentTimestamp, { aud: 'unknown' }), 'has incorrect "aud" (audience) claim.'], 41 | [ 42 | 'iss', 43 | tc.tokenGenerator(currentTimestamp, { 44 | iss: projectId, // set just projectId 45 | }), 46 | 'has incorrect "iss" (issuer) claim.', 47 | ], 48 | [ 49 | 'sub', 50 | tc.tokenGenerator(currentTimestamp, { 51 | sub: 'x'.repeat(129), 52 | }), 53 | 'has "sub" (subject) claim longer than 128 characters.', 54 | ], 55 | [ 56 | 'auth_time', 57 | tc.tokenGenerator(currentTimestamp, { 58 | auth_time: undefined, 59 | }), 60 | 'has no "auth_time" claim.', 61 | ], 62 | [ 63 | 'auth_time is in future', 64 | tc.tokenGenerator(currentTimestamp, { 65 | auth_time: currentTimestamp + 3000, // +3s 66 | }), 67 | 'has incorrect "auth_time" claim.', 68 | ], 69 | ])('invalid verifyPayload %s', async (_, promise, wantContainMsg) => { 70 | const payload = await promise; 71 | const pair = await createTestingSignVerifyPair(payload); 72 | const ftv = tc.firebaseTokenVerifier(pair.verifier, projectId); 73 | await expect(() => ftv.verifyJWT(pair.jwt, false)).rejects.toThrowError(wantContainMsg); 74 | }); 75 | 76 | it('valid with firebase emulator', async () => { 77 | const payload = await tc.tokenGenerator(currentTimestamp); 78 | const testingKeyFetcher = await TestingKeyFetcher.withKeyPairGeneration('valid-kid'); 79 | 80 | // sign as invalid private key with fetched public key 81 | const keyPair = await crypto.subtle.generateKey(rs256alg, true, ['sign', 'verify']); 82 | 83 | // set with invalid kid because jwt does not contain kid which issued from firebase emulator. 84 | const jwt = await signJWT('invalid-kid', payload, keyPair.privateKey); 85 | 86 | const verifier = new PublicKeySignatureVerifier(testingKeyFetcher); 87 | const ftv = tc.firebaseTokenVerifier(verifier, projectId); 88 | 89 | // firebase emulator ignores signature verification step. 90 | const token = await ftv.verifyJWT(jwt, true); 91 | 92 | expect(token).toStrictEqual(payload); 93 | }); 94 | }); 95 | } 96 | }); 97 | -------------------------------------------------------------------------------- /tests/validator.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { isNonEmptyString, isNonNullObject, isNumber, isObject, isString, isURL } from '../src/validator'; 3 | 4 | describe('validator', () => { 5 | describe('isURL', () => { 6 | it.each([ 7 | ['http://example.com/', true], 8 | ['http://example.com', true], 9 | ['https://example.com/', true], 10 | ['https://example.com', true], 11 | ['https://www.example.com:8080', true], 12 | ['http://localhost/path/name/', true], 13 | ['https://www.example.com:8080/path/name/index.php?a=1&b=2&c=3#abcd', true], 14 | ['http://www.example.com:8080/path/name/index.php?a=1&b=2&c=3#abcd', true], 15 | ['http://localhost/path/name/index.php?a=1&b=2&c=3#abcd', true], 16 | ['http://127.0.0.1/path/name/index.php?a=1&b=2&c=3#abcd', true], 17 | ['http://a--b.c-c.co-uk/', true], 18 | [null, false], 19 | [undefined, false], 20 | [['https://example.com'], false], // non-null string 21 | ['ftp://www.example.com:8080/path/name/file.png', false], 22 | ['http://-abc.com', false], 23 | ['http://www._abc.com', false], 24 | ['http://.com', false], 25 | ['123456789', false], 26 | ])('%p', (param, want) => { 27 | expect(isURL(param)).toBe(want); 28 | }); 29 | }); 30 | 31 | describe('isNumber', () => { 32 | describe('non-number', () => { 33 | const nonNumbers = [undefined, null, true, false, '', 'a', [], ['a'], {}, { a: 1 }]; 34 | nonNumbers.forEach(v => { 35 | it(`${v}`, () => expect(isNumber(v)).toBeFalsy()); 36 | }); 37 | }); 38 | 39 | describe('number', () => { 40 | const numbers = [NaN, 0, -1, 1, Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER, Infinity, -Infinity]; 41 | numbers.forEach(v => { 42 | it(`${v}`, () => expect(isNumber(v)).toBeTruthy()); 43 | }); 44 | }); 45 | }); 46 | 47 | describe('isString', () => { 48 | describe('non-string', () => { 49 | const nonStrings = [undefined, null, NaN, 0, 1, true, false, [], ['a'], {}, { a: 1 }]; 50 | nonStrings.forEach(v => { 51 | it(`${v}`, () => expect(isString(v)).toBeFalsy()); 52 | }); 53 | }); 54 | 55 | describe('string', () => { 56 | const strings = ['', ' ', 'foo']; 57 | strings.forEach(v => { 58 | it(`${v}`, () => expect(isString(v)).toBeTruthy()); 59 | }); 60 | }); 61 | }); 62 | 63 | describe('isNonEmptyString', () => { 64 | describe('non-non-empty-string', () => { 65 | const nonStrings = [undefined, null, NaN, 0, 1, true, false, [], ['a'], {}, { a: 1 }, '']; 66 | nonStrings.forEach(v => { 67 | it(`${v}`, () => expect(isNonEmptyString(v)).toBeFalsy()); 68 | }); 69 | }); 70 | 71 | describe('non-empty-string', () => { 72 | const strings = [' ', 'foo']; 73 | strings.forEach(v => { 74 | it(`${v}`, () => expect(isNonEmptyString(v)).toBeTruthy()); 75 | }); 76 | }); 77 | }); 78 | 79 | describe('isObject', () => { 80 | describe('non-object', () => { 81 | const nonObjects = [undefined, NaN, 0, 1, true, false, '', 'a', [], ['a']]; 82 | nonObjects.forEach(v => { 83 | it(`${v}`, () => expect(isObject(v)).toBeFalsy()); 84 | }); 85 | }); 86 | 87 | describe('object', () => { 88 | const objects = [null, {}, { a: 1 }]; 89 | objects.forEach(v => { 90 | it(`${v}`, () => expect(isObject(v)).toBeTruthy()); 91 | }); 92 | }); 93 | }); 94 | 95 | describe('isNonNullObject', () => { 96 | describe('non-non-null-object', () => { 97 | const nonNonNullObjects = [undefined, NaN, 0, 1, true, false, '', 'a', [], ['a'], null]; 98 | nonNonNullObjects.forEach(v => { 99 | it(`${v}`, () => expect(isNonNullObject(v)).toBeFalsy()); 100 | }); 101 | }); 102 | 103 | describe('object', () => { 104 | const nonNullObjects = [{}, { a: 1 }]; 105 | nonNullObjects.forEach(v => { 106 | it(`${v}`, () => expect(isNonNullObject(v)).toBeTruthy()); 107 | }); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /tests/x509.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { jwkFromX509 } from '../src/x509'; 3 | import type { JsonWebKeyWithKid } from '@cloudflare/workers-types'; 4 | 5 | const relyingpartyPublicKeys: Record = { 6 | 'bZ-_5g': 7 | '-----BEGIN CERTIFICATE-----\nMIIDHDCCAgSgAwIBAgIEHMOyTzANBgkqhkiG9w0BAQsFADAzMQ8wDQYDVQQDEwZH\naXRraXQxEzARBgNVBAoTCkdvb2dsZSBJbmMxCzAJBgNVBAYTAlVTMB4XDTI0MTIw\nNjAyMjUzM1oXDTI1MTIwMTAyMjUzM1owMzEPMA0GA1UEAxMGR2l0a2l0MRMwEQYD\nVQQKEwpHb29nbGUgSW5jMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBANyVAukjUNaA9z55PCK3I803APf7g5o+x+h89pOhFQdeHZd+\nAMamdbtLsbmgfZ0lxTaIAbaEdWW9ZLFTLbsO9F8Vc38n0goxdnngS85d1stih0Wm\nY2p04qAkuyFpjVMGLoTtcep9rguc+0UuDTBya0PsEsltE0Dgt8HVGl8ZFnF4QNY4\ncIFl/JTF0JPpGxCj801L/Za+KMneni1bMPxdn7NThoVbw39MVqdIYTjnWxFnDnZ5\nUSLIxOhFHtCaf6kQw3uNkykiZiM90XzADEb3RU1uShEweuSh/W890tnG8uXOe34/\nMVSRvwcbD4sJvMC4EKsYzzJ4mN/i4GC5gHfSjyMCAwEAAaM4MDYwDAYDVR0TAQH/\nBAIwADAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjAOBgNVHQ8BAf8EBAMCB4AwDQYJ\nKoZIhvcNAQELBQADggEBAJCNjAzki0cnWXSZZ17xVFCqbJwDjaubMVSPrGBvdiZP\nQyDNQc9BNKO57ui/rK1KhPh3TA/g5BElVjqsJa19WcUh9klG0bCRQyuiKyMg56WG\nIv/z7exyj5AeF8/Tk2pLws5LhhBpKN3gfiX+IIr+1dM2iaBN0PTk7+nUo2Yf7Zwg\nYlxXke96Xdi7mKNjT+0gd8C0D0A4PZeqKvEfACwHfbXc5jdIDmhl2yVbuNfAMAGR\nmJYbg3tMyH1gG+Yey8M87cPdUaivprn2wvBPAJAAsV6umdKlJgN/Qd7zZ7irGgfQ\nZMT3wPNaSlwZRbMow3gQCnwUAiAM6uTju32jbbhQE/8=\n-----END CERTIFICATE-----\n', 8 | _aLBDQ: 9 | '-----BEGIN CERTIFICATE-----\nMIIDHDCCAgSgAwIBAgIEBlNmITANBgkqhkiG9w0BAQsFADAzMQ8wDQYDVQQDEwZH\naXRraXQxEzARBgNVBAoTCkdvb2dsZSBJbmMxCzAJBgNVBAYTAlVTMB4XDTI0MDcx\nMTA5MzgzNFoXDTI1MDcwNjA5MzgzNFowMzEPMA0GA1UEAxMGR2l0a2l0MRMwEQYD\nVQQKEwpHb29nbGUgSW5jMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAMI0H9SjPV26sipRjPTl6HR6Q8pOQ/6GsfR2HOLT5bkpc0cq\n8NE0XqWDxf0g/mhVb3O5b/jN4q3LWFaVeISshcJINE7zKH+zKJhYwR8L79+fjzNN\noEwx4FESepTUHVW7qC49U1oac30oMD4c5ZCuCZ7OTgboASu2MOctR/p8+R6jsvdL\nfSpg4CKKy8AlUT+liuXpv4Oaptpp6murTmkDEqVJoLJKMpn0yiTPalAh8C514KXx\n3SxhPAOm5plsVxYGBYZ3XBOaB+Uu0xZi5HNYnSJmo9TihF/RWvE2mzE4IE8VA2YI\nZBNctcuCk2No/r9W/2gTF6e2YbrjfvkD0Vg+ovkCAwEAAaM4MDYwDAYDVR0TAQH/\nBAIwADAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjAOBgNVHQ8BAf8EBAMCB4AwDQYJ\nKoZIhvcNAQELBQADggEBADeGUKr/lCSUM/0Q5gxvcocmxtoD4WOylGs0dBPd/Gs2\nUZ55sXJXzmf4QUwLAOMBzHwr8mGcOYVtuXuOKLxPB/rNhFM+A841ZBTXsOFPRPXo\npi+fyNd0bDOG70MmSUx0LyHooj4cFHS/2mErOSjk70C43DqcTrPwLuUexiyCZVz3\nFQHJlc+PeHUd4ly4sAtCKNpTopc+VbEctMfsVHXuFDyw8DRP1G5T7+JfIwIyJ20J\n7pok5/SkEL1nr/BwGUcj/AxskHIFvAJuF1BJjxow0sRxbrChzLJYhZHdN7dvPNqq\neV+H6nbsUskrdeLWmzIR2xa4BIbygdCKJohuTn8ZYRU=\n-----END CERTIFICATE-----\n', 10 | usAeNA: 11 | '-----BEGIN CERTIFICATE-----\nMIIDHDCCAgSgAwIBAgIENHxoNzANBgkqhkiG9w0BAQsFADAzMQ8wDQYDVQQDEwZH\naXRraXQxEzARBgNVBAoTCkdvb2dsZSBJbmMxCzAJBgNVBAYTAlVTMB4XDTI0MDgx\nNjA5NDUxMFoXDTI1MDgxMTA5NDUxMFowMzEPMA0GA1UEAxMGR2l0a2l0MRMwEQYD\nVQQKEwpHb29nbGUgSW5jMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAMJXF9Y6Db3yIKjXDWvApd+ITJVWm9jQ2uhcD7TaMtYsjwiw\nd4TRETSZS/PC/01lm7smQbtBUEzewM9dGFtuZHisp6IFPsbRaYHYwU4nVn60YMYs\n9iQ5epppqCft/rVLBe5sBUESIS9su5IuCeTCPgzGicFqppmYBhiPSI7T7ztBoSSX\ng8eLajZb9/GqpjdQq6bLIbDLXsL4urMDskneh447UWqKyKr2mkZVsPxjnOfCjHby\n5kfJsuzF6wdyhRXBZ3SE791+RjGA/a07caDKQXDDfwkJ+Ilg/qdcDybzGg6My/D/\nK4imOyxmhLKKL1NA9RUG/WDGsiSozKelz1juGt8CAwEAAaM4MDYwDAYDVR0TAQH/\nBAIwADAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjAOBgNVHQ8BAf8EBAMCB4AwDQYJ\nKoZIhvcNAQELBQADggEBAC277O98HLdEabZdFcLIvapRGN69K7LvM8zKp9L2ZGYb\ndyxrReKvE8+5Oc1Da2CsTbGgMxe70XZMdKIElPaX6hOGXbLBjcfFzy7uZtyZthyh\njVC1zBifCWNVdRzaJpRNJ0Jt6ysX6EVeeL7hoZ3IcME9CfIu2CVKLMbRrykt3PfG\ntbCFTq+G7NSOCgf5fWvH8IFZInjiVM3CcGMsPfmnoIpIyc60Hx0WUJQ7kCkoW1DC\n61MxA3A8kugVK47/y42NM+ej7OthtSH4gVOybZBcYrmmFiAb4O/Ijcgt4GFC147x\n8KNG2B4OAH9l3EcKjPmrdkjIaJOR5mq7S3bnxO5PxPs=\n-----END CERTIFICATE-----\n', 12 | KRSnmg: 13 | '-----BEGIN CERTIFICATE-----\nMIIDHDCCAgSgAwIBAgIEZk8pnzANBgkqhkiG9w0BAQsFADAzMQ8wDQYDVQQDEwZH\naXRraXQxEzARBgNVBAoTCkdvb2dsZSBJbmMxCzAJBgNVBAYTAlVTMB4XDTI0MDky\nMTE3MjcwN1oXDTI1MDkxNjE3MjcwN1owMzEPMA0GA1UEAxMGR2l0a2l0MRMwEQYD\nVQQKEwpHb29nbGUgSW5jMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAMN5WgJ7EQbhDF8UquawppXBt9gvjbL6gnfVXaQi5KvrSp+P\nPffa3UBipxezgwjGfSfp7z02HZike5bKBSIa6sGWxoDfejLyz2lkRDGpdv0vtJdt\nC9b2xqIZ2jq0UD1Vn6aWGEE+y0mvp1QEWTRK5vF6bI/QGNwRuFIGSi1Sb8KVraFW\nIgw4RsS+B5aJlZqE8leHhjO1l5NJkWEh/uwwUKFs+dpWV/9SoBKrDTyPDBt0ZvF5\nYo8Xs5PxVIoEr38JysLZpJ6AWXXLIQN3mdGBd4Wm73o5MW39vObzgsJhgZ4+0jjV\ntWVUL+KpV3mSLaUxpjGa1Fz5qKXyqRfWwSSx3DcCAwEAAaM4MDYwDAYDVR0TAQH/\nBAIwADAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjAOBgNVHQ8BAf8EBAMCB4AwDQYJ\nKoZIhvcNAQELBQADggEBAHhffK+9nIRkRjuuiie8Eht+OpsOj3BdybN1b//rvLkh\nQtjInKHCWk/PGF5gJoWKIPhAXTGmzh9VBZRMWccBpeh55tC8ZYUu6Pb3V6zTH8vl\nF0VbAEP1mRfkB1x92PugSx7//TPHT1fQ/sKkydWKdTKw4u6DSO3uubm2yZde8OBr\n4JWPFRZKShprumQEM2ZlvbddusOasEiK9u4tbq2XM0ySoxxJix4QlTtU1NQ0noIU\nrYksonUE/hOgl3N1rU2Z7Hx04Ig6XzTBltYv4JVCg5nslbsJE6XjcrzhBaIo+Pe6\n60rol7aw3BYuk1pZVouz4xgRjVfeVQVV48Wk+JTbA1Q=\n-----END CERTIFICATE-----\n', 14 | '-WZpKQ': 15 | '-----BEGIN CERTIFICATE-----\nMIIDHDCCAgSgAwIBAgIEN2AQGjANBgkqhkiG9w0BAQsFADAzMQ8wDQYDVQQDEwZH\naXRraXQxEzARBgNVBAoTCkdvb2dsZSBJbmMxCzAJBgNVBAYTAlVTMB4XDTI0MTAz\nMTAxMTcwOFoXDTI1MTAyNjAxMTcwOFowMzEPMA0GA1UEAxMGR2l0a2l0MRMwEQYD\nVQQKEwpHb29nbGUgSW5jMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBANfahbn31sd4bntOODPKabKHU/eeqMiXOsaiai5JvIDQS5oL\nbWSkjs0L5A04kzPaETpRQCYXzEF2Ntad96fVpESAUhXD6DUuJarlAOOQyvF0FFtC\noWwfaqnDFbkB9n8v6sK9K9XcmTInp+FocJ5T+JOGGeZNQp6Bvfz6Yejwrg2kCamo\nXj0W7WeMThJURvd0k3ntxyJtpyoH43Ljci7+ZBhgtN3HyewNruqqFTQFfxzDjPok\n1pGDW8YxeQAZVlegg9hl/UBo1yja+rSJYP9T5XrAXgMBAEyicIRORMAPi0nRahO0\nQCLOtjLMEfc/JM6s5MR4G6LPOyp3SMk1Xbmn0vcCAwEAAaM4MDYwDAYDVR0TAQH/\nBAIwADAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjAOBgNVHQ8BAf8EBAMCB4AwDQYJ\nKoZIhvcNAQELBQADggEBAHHi7pUulvBqERPkntC3NrXW/Ceo+KKMUB4NwB3kpV0D\n5Nwa2yA6B3Q3Sr1yBLhVcxNR+sXct6YbfXQ2mF5wq/JGAxkPqXH7lmPGDULWKwRt\nY82aTNfT71tkx5NAcXSNwrf0GN6WPkjZ038BKUSC99McJI32f4iGKFei+tNWOd3H\nypYkm4tkXMbEISNb5KfLnoHYWryoFoPR1ZOrnmohv82S3kXt37fkB24eCJC46Pxy\nqF/e/SyJEQHY7RAW4rMfs8HPWJZRUmIhzxKa3PKmrcORvS97al9qY5KbKwlCfWIP\nHdPy1htgk4Jt8yOkQe6YnFUOWOx9Yxj6dCxV1BfNYg8=\n-----END CERTIFICATE-----\n', 16 | }; 17 | 18 | const sessionCookiePublicKeys: Record = { 19 | 'bZ-_5g': { 20 | kty: 'RSA', 21 | alg: 'RS256', 22 | use: 'sig', 23 | kid: 'bZ-_5g', 24 | n: '3JUC6SNQ1oD3Pnk8IrcjzTcA9_uDmj7H6Hz2k6EVB14dl34AxqZ1u0uxuaB9nSXFNogBtoR1Zb1ksVMtuw70XxVzfyfSCjF2eeBLzl3Wy2KHRaZjanTioCS7IWmNUwYuhO1x6n2uC5z7RS4NMHJrQ-wSyW0TQOC3wdUaXxkWcXhA1jhwgWX8lMXQk-kbEKPzTUv9lr4oyd6eLVsw_F2fs1OGhVvDf0xWp0hhOOdbEWcOdnlRIsjE6EUe0Jp_qRDDe42TKSJmIz3RfMAMRvdFTW5KETB65KH9bz3S2cby5c57fj8xVJG_BxsPiwm8wLgQqxjPMniY3-LgYLmAd9KPIw', 25 | e: 'AQAB', 26 | }, 27 | _aLBDQ: { 28 | kty: 'RSA', 29 | alg: 'RS256', 30 | use: 'sig', 31 | kid: '_aLBDQ', 32 | n: 'wjQf1KM9XbqyKlGM9OXodHpDyk5D_oax9HYc4tPluSlzRyrw0TRepYPF_SD-aFVvc7lv-M3irctYVpV4hKyFwkg0TvMof7MomFjBHwvv35-PM02gTDHgURJ6lNQdVbuoLj1TWhpzfSgwPhzlkK4Jns5OBugBK7Yw5y1H-nz5HqOy90t9KmDgIorLwCVRP6WK5em_g5qm2mnqa6tOaQMSpUmgskoymfTKJM9qUCHwLnXgpfHdLGE8A6bmmWxXFgYFhndcE5oH5S7TFmLkc1idImaj1OKEX9Fa8TabMTggTxUDZghkE1y1y4KTY2j-v1b_aBMXp7ZhuuN--QPRWD6i-Q', 33 | e: 'AQAB', 34 | }, 35 | usAeNA: { 36 | kty: 'RSA', 37 | alg: 'RS256', 38 | use: 'sig', 39 | kid: 'usAeNA', 40 | n: 'wlcX1joNvfIgqNcNa8Cl34hMlVab2NDa6FwPtNoy1iyPCLB3hNERNJlL88L_TWWbuyZBu0FQTN7Az10YW25keKynogU-xtFpgdjBTidWfrRgxiz2JDl6mmmoJ-3-tUsF7mwFQRIhL2y7ki4J5MI-DMaJwWqmmZgGGI9IjtPvO0GhJJeDx4tqNlv38aqmN1CrpsshsMtewvi6swOySd6HjjtRaorIqvaaRlWw_GOc58KMdvLmR8my7MXrB3KFFcFndITv3X5GMYD9rTtxoMpBcMN_CQn4iWD-p1wPJvMaDozL8P8riKY7LGaEsoovU0D1FQb9YMayJKjMp6XPWO4a3w', 41 | e: 'AQAB', 42 | }, 43 | KRSnmg: { 44 | kty: 'RSA', 45 | alg: 'RS256', 46 | use: 'sig', 47 | kid: 'KRSnmg', 48 | n: 'w3laAnsRBuEMXxSq5rCmlcG32C-NsvqCd9VdpCLkq-tKn48999rdQGKnF7ODCMZ9J-nvPTYdmKR7lsoFIhrqwZbGgN96MvLPaWREMal2_S-0l20L1vbGohnaOrRQPVWfppYYQT7LSa-nVARZNErm8Xpsj9AY3BG4UgZKLVJvwpWtoVYiDDhGxL4HlomVmoTyV4eGM7WXk0mRYSH-7DBQoWz52lZX_1KgEqsNPI8MG3Rm8Xlijxezk_FUigSvfwnKwtmknoBZdcshA3eZ0YF3habvejkxbf285vOCwmGBnj7SONW1ZVQv4qlXeZItpTGmMZrUXPmopfKpF9bBJLHcNw', 49 | e: 'AQAB', 50 | }, 51 | '-WZpKQ': { 52 | kty: 'RSA', 53 | alg: 'RS256', 54 | use: 'sig', 55 | kid: '-WZpKQ', 56 | n: '19qFuffWx3hue044M8ppsodT956oyJc6xqJqLkm8gNBLmgttZKSOzQvkDTiTM9oROlFAJhfMQXY21p33p9WkRIBSFcPoNS4lquUA45DK8XQUW0KhbB9qqcMVuQH2fy_qwr0r1dyZMien4WhwnlP4k4YZ5k1CnoG9_Pph6PCuDaQJqahePRbtZ4xOElRG93STee3HIm2nKgfjcuNyLv5kGGC03cfJ7A2u6qoVNAV_HMOM-iTWkYNbxjF5ABlWV6CD2GX9QGjXKNr6tIlg_1PlesBeAwEATKJwhE5EwA-LSdFqE7RAIs62MswR9z8kzqzkxHgbos87KndIyTVduafS9w', 57 | e: 'AQAB', 58 | }, 59 | }; 60 | 61 | describe('jwkFromX509', () => { 62 | const testCases = Object.entries(relyingpartyPublicKeys).map(([kid, x509]) => ({ 63 | kid, 64 | x509, 65 | expectedJwk: sessionCookiePublicKeys[kid], 66 | })); 67 | 68 | testCases.forEach(({ kid, x509, expectedJwk }) => { 69 | it(`should convert x509 certificate to JWK for kid: ${kid}`, async () => { 70 | const jwk = await jwkFromX509(kid, x509); 71 | expect(jwk).toEqual(expectedJwk); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "moduleResolution": "node", 7 | "outDir": "./dist", 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "strictPropertyInitialization": false, 13 | "strictNullChecks": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "resolveJsonModule": true, 17 | "types": [ 18 | "@cloudflare/workers-types" 19 | ], 20 | }, 21 | "include": [ 22 | "src/**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.main.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist/main" 6 | }, 7 | "exclude": [ 8 | "node_modules/**", 9 | "example" 10 | ] 11 | } -------------------------------------------------------------------------------- /tsconfig.module.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "module": "esnext", 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "rootDir": "src", 10 | "outDir": "dist/module" 11 | }, 12 | "exclude": [ 13 | "node_modules/**", 14 | "example" 15 | ] 16 | } -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | setupFiles: ['./tests/setup.ts'], 6 | }, 7 | }); 8 | --------------------------------------------------------------------------------