├── .cspell.json ├── .editorconfig ├── .eslintrc.json ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── node_ci.yaml │ ├── publish_ghpackages.yaml │ └── publish_npm.yaml ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── index.ts ├── lib │ ├── cache │ │ ├── cache-mock.ts │ │ ├── cache.ts │ │ └── cloudflare-kv.ts │ ├── flarebase-auth.spec.ts │ ├── flarebase-auth.ts │ ├── google-oauth.spec.ts │ ├── google-oauth.ts │ ├── models │ │ ├── index.ts │ │ ├── token.ts │ │ └── user.ts │ └── utils.ts └── test.env.example.json ├── tsconfig.json └── tsconfig.module.json /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/master/cspell.schema.json", 4 | "language": "en", 5 | "words": [ 6 | "bitjson", 7 | "bitauth", 8 | "cimg", 9 | "circleci", 10 | "codecov", 11 | "commitlint", 12 | "dependabot", 13 | "editorconfig", 14 | "esnext", 15 | "execa", 16 | "exponentiate", 17 | "globby", 18 | "libauth", 19 | "mkdir", 20 | "prettierignore", 21 | "sandboxed", 22 | "transpiled", 23 | "typedoc", 24 | "untracked", 25 | "flarebase", 26 | "marplex" 27 | ], 28 | "ignoreRegExpList": ["'.+?'"], 29 | "flagWords": [], 30 | "ignorePaths": [ 31 | "package.json", 32 | "package-lock.json", 33 | "yarn.lock", 34 | "tsconfig.json", 35 | "node_modules/**" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { "project": "./tsconfig.json" }, 5 | "env": { "es6": true }, 6 | "ignorePatterns": ["node_modules", "build", "coverage"], 7 | "plugins": ["import", "eslint-comments", "functional"], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:eslint-comments/recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:import/typescript", 13 | "prettier", 14 | "prettier/@typescript-eslint" 15 | ], 16 | "globals": { "BigInt": true, "console": true, "WebAssembly": true }, 17 | "rules": { 18 | "@typescript-eslint/explicit-module-boundary-types": "off", 19 | "eslint-comments/disable-enable-pair": [ 20 | "error", 21 | { "allowWholeFile": true } 22 | ], 23 | "eslint-comments/no-unused-disable": "error", 24 | "import/order": [ 25 | "error", 26 | { "newlines-between": "always", "alphabetize": { "order": "asc" } } 27 | ], 28 | "sort-imports": [ 29 | "error", 30 | { "ignoreDeclarationSort": true, "ignoreCase": true } 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | - Using welcoming and inclusive language 36 | - Being respectful of differing viewpoints and experiences 37 | - Gracefully accepting constructive criticism 38 | - Focusing on what is best for the community 39 | - Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | - The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | - Trolling, insulting/derogatory comments, and personal or political attacks 46 | - Public or private harassment 47 | - Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | - Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to ban temporarily or 61 | permanently any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | [version]: http://contributor-covenant.org/version/1/4/ 93 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - **I'm submitting a ...** 2 | [ ] bug report 3 | [ ] feature request 4 | [ ] question about the decisions made in the repository 5 | [ ] question about how to use this project 6 | 7 | - **Summary** 8 | 9 | - **Other information** (e.g. detailed explanation, stack traces, related issues, suggestions how to fix, links for us to have context, eg. StackOverflow, personal fork, etc.) 10 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) 2 | 3 | - **What is the current behavior?** (You can also link to an open issue here) 4 | 5 | - **What is the new behavior (if this is a feature change)?** 6 | 7 | - **Other information**: 8 | -------------------------------------------------------------------------------- /.github/workflows/node_ci.yaml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.4.0] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - name: Install dependencies 24 | run: npm install 25 | - name: Import secrets 26 | run: 'echo ${{ secrets.TEST_ENV }} | base64 -d > ./src/test.env.json' 27 | shell: bash 28 | - name: Run tests 29 | run: npm test 30 | -------------------------------------------------------------------------------- /.github/workflows/publish_ghpackages.yaml: -------------------------------------------------------------------------------- 1 | name: Publish package to GitHub Packages 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | packages: write 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: '18.4.0' 16 | registry-url: 'https://npm.pkg.github.com' 17 | scope: '@marplex' 18 | - name: Install dependencies 19 | run: npm install 20 | - name: Import secrets 21 | run: 'echo ${{ secrets.TEST_ENV }} | base64 -d > ./src/test.env.json' 22 | - name: Run tests 23 | run: npm test 24 | - name: Setup github package registry 25 | run: 'echo "registry=https://npm.pkg.github.com/@marplex" >> .npmrc' 26 | - name: Publish 27 | run: npm publish 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/publish_npm.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | # Setup .npmrc file to publish to npm 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: '18.4.0' 14 | registry-url: 'https://registry.npmjs.org' 15 | - name: Install dependencies 16 | run: npm install 17 | - name: Import secrets 18 | run: 'echo ${{ secrets.TEST_ENV }} | base64 -d > ./src/test.env.json' 19 | shell: bash 20 | - name: Run tests 21 | run: npm test 22 | - name: Publish 23 | run: npm publish --access public 24 | env: 25 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | .nyc_output 3 | build 4 | node_modules 5 | test 6 | src/**.js 7 | coverage 8 | *.log 9 | yarn.lock 10 | src/test.env.json 11 | *.env -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # package.json is formatted by package managers, so we ignore it here 2 | package.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 marco 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 |

Flarebase Auth

2 |

3 | Firebase/Admin Auth Javascript Library for Cloudflare Workers 4 |

5 |
6 | 7 |

8 | License 9 | GitHub CI 10 | NPM 11 | NPM Downloads 12 | Github 13 |

14 | 15 | # Supported operations: 16 | 17 | - [x] createSessionCookie() 18 | - [x] verifySessionCookie() 19 | - [x] signInWithEmailAndPassword() 20 | - [x] signUpWithEmailAndPassword() 21 | - [x] changePassword() 22 | - [x] lookupUser() 23 | 24 | # Install 25 | 26 | ```bash 27 | npm i @marplex/flarebase-auth 28 | ``` 29 | 30 | # Usage 31 | 32 | Flarebase tries to use the same method names and return values as the official Firebase/Admin SDK. Sometimes, the method signature are slightly different. 33 | 34 | **Create FlarebaseAuth** 35 | 36 | ```ts 37 | import { FlarebaseAuth } from '@marplex/flarebase-auth'; 38 | 39 | const auth = new FlarebaseAuth({ 40 | apiKey: 'Firebase api key', 41 | projectId: 'Firebase project id', 42 | privateKey: 'Firebase private key or service account private key', 43 | serviceAccountEmail: 'Firebase service account email', 44 | }); 45 | ``` 46 | 47 | **Sign-in with email/pass** 48 | 49 | ```ts 50 | //Sign in with username and password 51 | const { token, user } = await auth.signInWithEmailAndPassword( 52 | 'my@email.com', 53 | 'supersecurepassword' 54 | ); 55 | 56 | const userEmail = user.email; 57 | const refreshToken = token.refreshToken; 58 | ``` 59 | 60 | **Sign-up with email/pass** 61 | 62 | ```ts 63 | //Sign up with username and password 64 | const { token, user } = await auth.signUpWithEmailAndPassword( 65 | 'my@email.com', 66 | 'supersecurepassword' 67 | ); 68 | 69 | const userEmail = user.email; 70 | const refreshToken = token.refreshToken; 71 | ``` 72 | 73 | **Create session cookies** 74 | 75 | ```ts 76 | //Create a new session cookie from the user idToken 77 | const { token, user } = await auth.signInWithEmailAndPassword( 78 | 'my@email.com', 79 | 'supersecurepassword' 80 | ); 81 | 82 | const sessionCookie = await auth.createSessionCookie(token.idToken); 83 | ``` 84 | 85 | **Verify session cookies** 86 | 87 | ```ts 88 | auth 89 | .verifySessionCookie(sessionCookie) 90 | .then((token) => useToken(token)) 91 | .catch((e) => console.log('Invalid session cookie')); 92 | ``` 93 | 94 | **Cache OAuth tokens with Cloudflare KV** 95 | 96 | ```ts 97 | import { FlarebaseAuth, CloudflareKv } from '@marplex/flarebase-auth'; 98 | 99 | const auth = new FlarebaseAuth({ 100 | apiKey: 'Firebase api key', 101 | projectId: 'Firebase project id', 102 | privateKey: 'Firebase private key or service account private key', 103 | serviceAccountEmail: 'Firebase service account email', 104 | cache: new CloudflareKv(NAMESPACE), 105 | }); 106 | ``` 107 | 108 | # Test environment 109 | 110 | If you want to test this library, have a look at `/src/test.env.example.json`. 111 | Create a new file in the same directory called `test.env.json` with the real values and 112 | run the tests with `npm test` 113 | 114 | ```json 115 | { 116 | "FIREBASE_TEST_CREDENTIALS": { 117 | "apiKey": "MY FIREBASE API KEY", 118 | "projectId": "FIREBASE PROJECT ID", 119 | "privateKey": "FIREBASE PRIVATE KEY OR SERVICE ACCOUNT PRIVATE KEY", 120 | "serviceAccountEmail": "FIREBASE SERVICE ACCOUNT EMAIL" 121 | }, 122 | 123 | "FIREBASE_TEST_USER": { 124 | "email": "test@test.com", 125 | "password": "password123" 126 | } 127 | } 128 | ``` 129 | 130 | # To-do 131 | 132 | - [x] Add caching support (Cloudflare KV) 133 | - [ ] sendEmailVerification() 134 | - [ ] confirmEmailVerification() 135 | - [ ] deleteAccount() 136 | 137 | # License 138 | 139 | ```xml 140 | Copyright (c) 2022 Marco 141 | 142 | Permission is hereby granted, free of charge, to any person obtaining a copy 143 | of this software and associated documentation files (the "Software"), to deal 144 | in the Software without restriction, including without limitation the rights 145 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 146 | copies of the Software, and to permit persons to whom the Software is 147 | furnished to do so, subject to the following conditions: 148 | 149 | The above copyright notice and this permission notice shall be included in all 150 | copies or substantial portions of the Software. 151 | 152 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 153 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 154 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 155 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 156 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 157 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 158 | SOFTWARE. 159 | ``` 160 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@marplex/flarebase-auth", 3 | "version": "1.2.0", 4 | "description": "Firebase/Admin auth SDK for Cloudflare Workers", 5 | "main": "build/main/index.js", 6 | "typings": "build/main/index.d.ts", 7 | "module": "build/module/index.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/Marplex/flarebase-auth" 11 | }, 12 | "license": "MIT", 13 | "keywords": ["firebase","firebase sdk", "cloudflare worker", "cloudflare", "firebase auth"], 14 | "scripts": { 15 | "build": "run-p build:*", 16 | "build:main": "tsc -p tsconfig.json", 17 | "build:module": "tsc -p tsconfig.module.json", 18 | "fix": "run-s fix:*", 19 | "fix:prettier": "prettier \"src/**/*.ts\" --write", 20 | "fix:lint": "eslint src --ext .ts --fix", 21 | "test": "run-s build test:*", 22 | "test:lint": "eslint src --ext .ts", 23 | "test:prettier": "prettier \"src/**/*.ts\" --list-different", 24 | "test:spelling": "cspell \"{README.md,.github/*.md,src/**/*.ts}\"", 25 | "test:unit": "nyc --silent ava", 26 | "check-cli": "run-s test diff-integration-tests check-integration-tests", 27 | "check-integration-tests": "run-s check-integration-test:*", 28 | "diff-integration-tests": "mkdir -p diff && rm -rf diff/test && cp -r test diff/test && rm -rf diff/test/test-*/.git && cd diff && git init --quiet && git add -A && git commit --quiet --no-verify --allow-empty -m 'WIP' && echo '\\n\\nCommitted most recent integration test output in the \"diff\" directory. Review the changes with \"cd diff && git diff HEAD\" or your preferred git diff viewer.'", 29 | "watch:build": "tsc -p tsconfig.json -w", 30 | "watch:test": "nyc --silent ava --watch", 31 | "cov": "run-s build test:unit cov:html cov:lcov && open-cli coverage/index.html", 32 | "cov:html": "nyc report --reporter=html", 33 | "cov:lcov": "nyc report --reporter=lcov", 34 | "cov:send": "run-s cov:lcov && codecov", 35 | "cov:check": "nyc report && nyc check-coverage --lines 100 --functions 100 --branches 100", 36 | "doc": "run-s doc:html && open-cli build/docs/index.html", 37 | "doc:html": "typedoc src/ --exclude **/*.spec.ts --target ES6 --mode file --out build/docs", 38 | "doc:json": "typedoc src/ --exclude **/*.spec.ts --target ES6 --mode file --json build/docs/typedoc.json", 39 | "doc:publish": "gh-pages -m \"[ci skip] Updates\" -d build/docs", 40 | "version": "standard-version", 41 | "reset-hard": "git clean -dfx && git reset --hard && npm i", 42 | "prepare-release": "run-s reset-hard test cov:check doc:html version doc:publish" 43 | }, 44 | "engines": { 45 | "node": ">=10" 46 | }, 47 | "dependencies": { 48 | "jose": "^4.8.1" 49 | }, 50 | "devDependencies": { 51 | "@ava/typescript": "^1.1.1", 52 | "@istanbuljs/nyc-config-typescript": "^1.0.1", 53 | "@typescript-eslint/eslint-plugin": "^4.0.1", 54 | "@typescript-eslint/parser": "^4.0.1", 55 | "ava": "^4.3.0", 56 | "codecov": "^3.5.0", 57 | "cspell": "^4.1.0", 58 | "cz-conventional-changelog": "^3.3.0", 59 | "eslint": "^7.8.0", 60 | "eslint-config-prettier": "^6.11.0", 61 | "eslint-plugin-eslint-comments": "^3.2.0", 62 | "eslint-plugin-functional": "^3.0.2", 63 | "eslint-plugin-import": "^2.22.0", 64 | "gh-pages": "^3.1.0", 65 | "npm-run-all": "^4.1.5", 66 | "nyc": "^15.1.0", 67 | "open-cli": "^6.0.1", 68 | "prettier": "^2.1.1", 69 | "standard-version": "^9.0.0", 70 | "ts-node": "^9.0.0", 71 | "typedoc": "^0.19.0", 72 | "typescript": "^4.0.2" 73 | }, 74 | "files": [ 75 | "build/main", 76 | "build/module", 77 | "!**/*.spec.*", 78 | "!**/*.json", 79 | "CHANGELOG.md", 80 | "LICENSE", 81 | "README.md" 82 | ], 83 | "ava": { 84 | "failFast": true, 85 | "timeout": "60s", 86 | "typescript": { 87 | "rewritePaths": { 88 | "src/": "build/main/" 89 | } 90 | }, 91 | "files": [ 92 | "!build/module/**" 93 | ] 94 | }, 95 | "config": { 96 | "commitizen": { 97 | "path": "cz-conventional-changelog" 98 | } 99 | }, 100 | "prettier": { 101 | "singleQuote": true 102 | }, 103 | "nyc": { 104 | "extends": "@istanbuljs/nyc-config-typescript", 105 | "exclude": [ 106 | "**/*.spec.js" 107 | ] 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/flarebase-auth'; 2 | export * from './lib/models/token'; 3 | export * from './lib/models/user'; 4 | export { CloudflareKv } from './lib/cache/cloudflare-kv'; 5 | -------------------------------------------------------------------------------- /src/lib/cache/cache-mock.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from './cache'; 2 | 3 | export class TestCache implements Cache { 4 | cache = {}; 5 | 6 | get(key: string, _params?: any): Promise { 7 | return Promise.resolve(this.cache[key]); 8 | } 9 | 10 | put(key: string, value: any, _params?: any): Promise { 11 | this.cache[key] = value; 12 | return Promise.resolve(); 13 | } 14 | 15 | delete(key: string): Promise { 16 | delete this.cache[key]; 17 | return Promise.resolve(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/cache/cache.ts: -------------------------------------------------------------------------------- 1 | export interface Cache { 2 | get(key: string, params?: any): Promise; 3 | put(key: string, value: any, params?: any): Promise; 4 | delete(key: string): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/cache/cloudflare-kv.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from './cache'; 2 | 3 | export class CloudflareKv implements Cache { 4 | namespace: any; 5 | constructor(namespace: any) { 6 | this.namespace = namespace; 7 | } 8 | 9 | get(key: string, params?: any): Promise { 10 | return this.namespace.get(key, params ?? {}); 11 | } 12 | 13 | put(key: string, value: any, params?: any): Promise { 14 | return this.namespace.put(key, value, params ?? {}); 15 | } 16 | 17 | delete(key: string): Promise { 18 | return this.namespace.delete(key); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/flarebase-auth.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import test_config from '../test.env.json'; 4 | 5 | import { TestCache } from './cache/cache-mock'; 6 | import { FlarebaseAuth } from './flarebase-auth'; 7 | import { sleep } from './utils'; 8 | 9 | const credentials = test_config.FIREBASE_TEST_CREDENTIALS; 10 | const testUser = test_config.FIREBASE_TEST_USER; 11 | const auth = new FlarebaseAuth({ ...credentials, cache: new TestCache() }); 12 | 13 | test('should sign in with email and password', async (t) => { 14 | const { token } = await auth.signInWithEmailAndPassword( 15 | testUser.email, 16 | testUser.password 17 | ); 18 | 19 | t.is(token.email, testUser.email); 20 | }); 21 | 22 | test("should change user's password", async (t) => { 23 | //Sign in to get the token id 24 | const { token } = await auth.signInWithEmailAndPassword( 25 | testUser.email, 26 | testUser.password 27 | ); 28 | 29 | const decodedToken = await auth.changePassword(token.idToken, 'test123'); 30 | t.not(decodedToken, undefined); 31 | }); 32 | 33 | test('should verify a valid token id', async (t) => { 34 | const { token } = await auth.signInWithEmailAndPassword( 35 | testUser.email, 36 | testUser.password 37 | ); 38 | 39 | const session = await auth.verifyIdToken(token.idToken); 40 | t.not(session, undefined); 41 | }); 42 | 43 | test('should create session cookie from token id', async (t) => { 44 | await sleep(1000); 45 | const { token } = await auth.signInWithEmailAndPassword( 46 | testUser.email, 47 | testUser.password 48 | ); 49 | 50 | const session = await auth.createSessionCookie(token.idToken); 51 | 52 | t.not(session, undefined); 53 | }); 54 | 55 | test('should verify a valid session cookie', async (t) => { 56 | await sleep(1000); 57 | const { token } = await auth.signInWithEmailAndPassword( 58 | testUser.email, 59 | testUser.password 60 | ); 61 | 62 | const session = await auth.createSessionCookie(token.idToken); 63 | const verified = await auth.verifySessionCookie(session); 64 | 65 | t.not(verified, undefined); 66 | }); 67 | 68 | test('should not verify an invalid session cookie', async (t) => { 69 | const invalidToken = 70 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vdGVzdC1wcm9qZWN0LWlkIiwiYXVkIjoic2RzZHNkc2RzZCIsImF1dGhfdGltZSI6MTY1NTkyNDcxNCwidXNlcl9pZCI6InNkc2RzZHNkc2RzZCIsInN1YiI6InNkc2RzZHNkIiwiaWF0IjoxNjU1OTI0NzE0LCJleHAiOjE2NTU5MjgzMTQsImVtYWlsIjoidGVzdEBlbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZW1haWwiOlsidGVzdEBlbWFpbC5jb20iXX0sInNpZ25faW5fcHJvdmlkZXIiOiJwYXNzd29yZCJ9fQ.3PkmhTW1LaP3lGPnBat2N770TqEE026Xhp2whzbltJo'; 71 | 72 | await auth.verifySessionCookie(invalidToken).catch(() => t.pass()); 73 | }); 74 | -------------------------------------------------------------------------------- /src/lib/flarebase-auth.ts: -------------------------------------------------------------------------------- 1 | import { decodeProtectedHeader, importX509, jwtVerify } from 'jose'; 2 | 3 | import { Cache } from './cache/cache'; 4 | import { getAuthToken, verifyIdToken } from './google-oauth'; 5 | import { DecodedIdToken, User } from './models'; 6 | 7 | export type FlarebaseConfig = { 8 | readonly projectId: string; 9 | readonly apiKey: string; 10 | readonly serviceAccountEmail: string; 11 | readonly privateKey: string; 12 | readonly cache?: Cache; 13 | }; 14 | 15 | /** 16 | * Interact with Firebase REST Api and Google Identity Toolkit Api. 17 | * Made to work with Cloudflare Workers 18 | */ 19 | export class FlarebaseAuth { 20 | private BASE_URL = 'https://identitytoolkit.googleapis.com/v1/'; 21 | 22 | constructor(public readonly config: FlarebaseConfig) {} 23 | 24 | /** 25 | * Cache the result of an async function 26 | * @param action Function with result to be stored 27 | * @param key Where to find/store the value from/to the cache 28 | * @param expiration Cache expiration in seconds 29 | * @returns Cached result 30 | */ 31 | private async withCache( 32 | action: () => Promise, 33 | key: string, 34 | expiration: number 35 | ): Promise { 36 | if (!this.config.cache) return await action(); 37 | 38 | let result = (await this.config.cache.get(key)) as T; 39 | if (!result) { 40 | result = await action(); 41 | await this.config.cache.put(key, result, { expirationTtl: expiration }); 42 | } 43 | 44 | return result; 45 | } 46 | 47 | /** 48 | * Send a post request to the identity toolkit api 49 | * @param formData POST form data 50 | * @param endpoint endpoint of the identity toolkit googleapis 51 | * @returns HTTP Response 52 | */ 53 | private sendFirebaseAuthPostRequest( 54 | formData: Record, 55 | endpoint: string 56 | ): Promise { 57 | const params = { 58 | method: 'post', 59 | body: JSON.stringify(formData), 60 | headers: { 61 | 'Content-Type': 'application/json', 62 | }, 63 | }; 64 | 65 | const URI = 66 | this.BASE_URL + `accounts:${endpoint}?key=${this.config.apiKey}`; 67 | 68 | return fetch(URI, params); 69 | } 70 | 71 | /** 72 | * Retrieve user info from a Firebase ID token 73 | * @param idToken A valid Firebase ID token 74 | * @returns User info linked to this ID token 75 | */ 76 | public async lookupUser(idToken): Promise { 77 | const response = await this.sendFirebaseAuthPostRequest( 78 | { idToken: idToken }, 79 | 'lookup' 80 | ); 81 | 82 | if (response.status != 200) throw Error(await response.text()); 83 | const data = (await response.json()) as any; 84 | return data.users[0] as User; 85 | } 86 | 87 | /** 88 | * Sign in Firebase user with email and password 89 | * @param email Email of the Firebase user 90 | * @param password Password of the Firebase user 91 | * @returns The decoded JWT token payload and the signed in user info 92 | */ 93 | async signInWithEmailAndPassword( 94 | email: string, 95 | password: string 96 | ): Promise<{ token: DecodedIdToken; user: User }> { 97 | const response = await this.sendFirebaseAuthPostRequest( 98 | { 99 | email: email, 100 | password: password, 101 | returnSecureToken: 'true', 102 | }, 103 | 'signInWithPassword' 104 | ); 105 | 106 | if (response.status != 200) throw Error(await response.text()); 107 | const token = (await response.json()) as DecodedIdToken; 108 | const user = await this.lookupUser(token.idToken); 109 | 110 | return { token, user }; 111 | } 112 | 113 | /** 114 | * Change a user's password 115 | * @param idToken A Firebase Auth ID token for the user. 116 | * @param newPassword User's new password. 117 | * @returns The decoded JWT token payload 118 | */ 119 | async changePassword( 120 | idToken: string, 121 | newPassword: string 122 | ): Promise { 123 | const response = await this.sendFirebaseAuthPostRequest( 124 | { 125 | idToken: idToken, 126 | password: newPassword, 127 | returnSecureToken: 'true', 128 | }, 129 | 'update' 130 | ); 131 | 132 | if (response.status != 200) throw Error(await response.text()); 133 | const token = (await response.json()) as DecodedIdToken; 134 | 135 | return token; 136 | } 137 | 138 | /** 139 | * Delete a current user 140 | * @param idToken A Firebase Auth ID token for the user. 141 | */ 142 | async deleteAccount(idToken: string) { 143 | const response = await this.sendFirebaseAuthPostRequest( 144 | { 145 | idToken: idToken, 146 | }, 147 | 'delete' 148 | ); 149 | 150 | if (response.status != 200) throw Error(await response.text()); 151 | } 152 | 153 | /** 154 | * Sign up Firebase user with email and password 155 | * @param email Email of the Firebase user 156 | * @param password Password of the Firebase user 157 | * @returns The decoded JWT token payload and the signed in user info 158 | */ 159 | async signUpWithEmailAndPassword( 160 | email: string, 161 | password: string 162 | ): Promise<{ token: DecodedIdToken; user: User }> { 163 | const response = await this.sendFirebaseAuthPostRequest( 164 | { 165 | email: email, 166 | password: password, 167 | returnSecureToken: 'true', 168 | }, 169 | 'signUp' 170 | ); 171 | 172 | if (response.status != 200) throw Error(await response.text()); 173 | const token = (await response.json()) as DecodedIdToken; 174 | const user = await this.lookupUser(token.idToken); 175 | 176 | return { token, user }; 177 | } 178 | 179 | /** 180 | * Creates a session cookie for the given Identity Platform ID token. 181 | * The session cookie is used by the client to preserve the user's login state. 182 | * @param idToken A valid Identity Platform ID token 183 | * @param expiresIn The number of seconds until the session cookie expires. 184 | * Specify a duration in seconds, between five minutes and fourteen days, inclusively. 185 | * @returns The session cookie that has been created 186 | */ 187 | async createSessionCookie( 188 | idToken: string, 189 | expiresIn: number = 60 * 60 * 24 * 14 //14 days 190 | ): Promise { 191 | //Create the OAuth 2.0 token 192 | //OAuth token is cached until expiration (1h) 193 | const token = await this.withCache( 194 | () => 195 | getAuthToken( 196 | this.config.serviceAccountEmail, 197 | this.config.privateKey, 198 | 'https://www.googleapis.com/auth/identitytoolkit' 199 | ), 200 | 'google-oauth', 201 | 3600 202 | ); 203 | 204 | //Post params and header authorization 205 | const params = { 206 | method: 'post', 207 | body: JSON.stringify({ 208 | idToken: idToken, 209 | validDuration: expiresIn + '', 210 | }), 211 | headers: { 212 | 'Content-Type': 'application/json', 213 | Authorization: 'Bearer ' + token, 214 | }, 215 | }; 216 | 217 | //POST request 218 | const path = `projects/${this.config.projectId}:createSessionCookie`; 219 | const response = await fetch(this.BASE_URL + path, params); 220 | if (response.status != 200) throw Error(await response.text()); 221 | 222 | //Get session cookie 223 | const sessionCookieResponse = await response.json(); 224 | return sessionCookieResponse.sessionCookie as string; 225 | } 226 | 227 | /** 228 | * Verify if the provided session cookie is valid. 229 | * @param sessionCookie JWT session cookie generated from createSessionCookie 230 | * @returns The decoded JWT payload 231 | */ 232 | async verifySessionCookie(sessionCookie: string): Promise { 233 | //Fetch google public key 234 | const res = await fetch( 235 | 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys' 236 | ); 237 | 238 | const header = decodeProtectedHeader(sessionCookie); 239 | const data = await res.json(); 240 | if (!data[header.kid]) throw Error('Cannot find public key'); 241 | 242 | //Get certificate from JWT key id 243 | const certificate = data[header.kid]; 244 | const publicKey = await importX509(certificate, 'RS256'); 245 | 246 | //Verify the sessionCookie with the publicKey 247 | const { payload } = await jwtVerify(sessionCookie, publicKey, { 248 | issuer: `https://session.firebase.google.com/${this.config.projectId}`, 249 | audience: this.config.projectId, 250 | }); 251 | 252 | return payload as any as DecodedIdToken; 253 | } 254 | 255 | /** 256 | * Verifies a Firebase ID token (JWT). 257 | * If the token is valid, the promise is fulfilled with the token's decoded claims; otherwise, the promise is rejected. 258 | * @param idToken An Identity Platform ID token 259 | */ 260 | async verifyIdToken(idToken: string): Promise { 261 | return (await verifyIdToken(idToken)) as any as DecodedIdToken; 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/lib/google-oauth.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import test_config from '../test.env.json'; 4 | 5 | import { getAuthToken, verifyIdToken } from './google-oauth'; 6 | 7 | const credentials = test_config.FIREBASE_TEST_CREDENTIALS; 8 | 9 | test('should return an auth token from google', async (t) => { 10 | const token = await getAuthToken( 11 | credentials.serviceAccountEmail, 12 | credentials.privateKey, 13 | 'https://www.googleapis.com/auth/identitytoolkit' 14 | ); 15 | 16 | t.not(token, undefined); 17 | }); 18 | 19 | test('should not verify idToken because invalid', async (t) => { 20 | await verifyIdToken( 21 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vdGVzdC1wcm9qZWN0LWlkIiwiYXVkIjoic2RzZHNkc2RzZCIsImF1dGhfdGltZSI6MTY1NTkyNDcxNCwidXNlcl9pZCI6InNkc2RzZHNkc2RzZCIsInN1YiI6InNkc2RzZHNkIiwiaWF0IjoxNjU1OTI0NzE0LCJleHAiOjE2NTU5MjgzMTQsImVtYWlsIjoidGVzdEBlbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZW1haWwiOlsidGVzdEBlbWFpbC5jb20iXX0sInNpZ25faW5fcHJvdmlkZXIiOiJwYXNzd29yZCJ9fQ.3PkmhTW1LaP3lGPnBat2N770TqEE026Xhp2whzbltJo' 22 | ).catch((e) => { 23 | if (e.code) { 24 | t.is(e.code, 'ERR_JWT_EXPIRED'); 25 | } else { 26 | t.is(e.message, '"x509" must be X.509 formatted string'); 27 | } 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/lib/google-oauth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | decodeProtectedHeader, 3 | importPKCS8, 4 | importX509, 5 | JWTPayload, 6 | jwtVerify, 7 | SignJWT, 8 | } from 'jose'; 9 | 10 | /** 11 | * Get an OAuth 2.0 token from google authentication apis using 12 | * a service account 13 | * @param serviceAccountEmail Email of the service account 14 | * @param privateKey Private key of the service account 15 | * @param scope scope to request 16 | * @returns OAuth 2.0 token 17 | */ 18 | export async function getAuthToken( 19 | serviceAccountEmail: string, 20 | privateKey: string, 21 | scope: string 22 | ): Promise { 23 | const ecPrivateKey = await importPKCS8(privateKey, 'RS256'); 24 | 25 | const jwt = await new SignJWT({ scope: scope }) 26 | .setProtectedHeader({ alg: 'RS256' }) 27 | .setIssuer(serviceAccountEmail) 28 | .setAudience('https://oauth2.googleapis.com/token') 29 | .setExpirationTime('1h') 30 | .setIssuedAt() 31 | .sign(ecPrivateKey); 32 | 33 | const response = await fetch('https://oauth2.googleapis.com/token', { 34 | method: 'POST', 35 | headers: { 36 | 'Content-Type': 'application/x-www-form-urlencoded', 37 | 'Cache-Control': 'no-cache', 38 | Host: 'oauth2.googleapis.com', 39 | }, 40 | body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`, 41 | }); 42 | 43 | const oauth = await response.json(); 44 | return oauth.access_token; 45 | } 46 | 47 | /** 48 | * Verifies an Identity Platform ID token. 49 | * If the token is valid, the promise is fulfilled with the token's decoded claims; otherwise, the promise is rejected. 50 | * @param idToken An Identity Platform ID token 51 | */ 52 | export async function verifyIdToken(idToken: string): Promise { 53 | //Fetch public keys 54 | //TODO: Public keys should be cached until they expire 55 | const res = await fetch( 56 | 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com' 57 | ); 58 | const data = await res.json(); 59 | 60 | //Get the correct publicKey from the key id 61 | const header = decodeProtectedHeader(idToken); 62 | const certificate = data[header.kid]; 63 | const publicKey = await importX509(certificate, 'RS256'); 64 | 65 | //Verify JWT with public key 66 | const { payload } = await jwtVerify(idToken, publicKey); 67 | return payload; 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './token'; 2 | export * from './user'; 3 | -------------------------------------------------------------------------------- /src/lib/models/token.ts: -------------------------------------------------------------------------------- 1 | import { JWTPayload } from 'jose'; 2 | 3 | export interface DecodedIdToken extends JWTPayload { 4 | readonly idToken: string; 5 | readonly email: string; 6 | readonly refreshToken: string; 7 | readonly expiresIn: string; 8 | readonly localId: string; 9 | readonly registered: boolean; 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/models/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | /** 3 | * Whether the email has been verified 4 | */ 5 | readonly emailVerified: boolean; 6 | 7 | /** 8 | * Whether the user is authenticated using the ANONYMOUS provider. 9 | */ 10 | readonly isAnonymous: boolean; 11 | 12 | readonly displayName: string | null; 13 | 14 | /** 15 | * The email of the user. 16 | */ 17 | readonly email: string | null; 18 | 19 | /** 20 | * The phone number normalized based on the E.164 standard (e.g. +16505550101) for the 21 | * user. 22 | * 23 | * @remarks 24 | * This is null if the user has no phone credential linked to the account. 25 | */ 26 | readonly phoneNumber: string | null; 27 | 28 | /** 29 | * The profile photo URL of the user. 30 | */ 31 | readonly photoURL: string | null; 32 | 33 | /** 34 | * The provider used to authenticate the user. 35 | */ 36 | readonly providerId: string; 37 | 38 | /** 39 | * The user's unique ID, scoped to the project. 40 | */ 41 | readonly localId: string; 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export function sleep(ms: number) { 2 | return new Promise((r) => setTimeout(r, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /src/test.env.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "FIREBASE_TEST_CREDENTIALS": { 3 | "apiKey": "MY FIREBASE API KEY", 4 | "projectId": "FIREBASE PROJECT ID", 5 | "privateKey": "FIREBASE PRIVATE KEY OR SERVICE ACCOUNT PRIVATE KEY", 6 | "serviceAccountEmail": "FIREBASE SERVICE ACCOUNT EMAIL" 7 | }, 8 | 9 | "FIREBASE_TEST_USER": { 10 | "email": "test@test.com", 11 | "password": "password123" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es6", 5 | "outDir": "build/main", 6 | "rootDir": "src", 7 | "moduleResolution": "node", 8 | "module": "commonjs", 9 | "declaration": true, 10 | "inlineSourceMap": true, 11 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 12 | "resolveJsonModule": true /* Include modules imported with .json extension. */, 13 | 14 | // "strict": true /* Enable all strict type-checking options. */, 15 | 16 | /* Strict Type-Checking Options */ 17 | // "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 18 | // "strictNullChecks": true /* Enable strict null checks. */, 19 | // "strictFunctionTypes": true /* Enable strict checking of function types. */, 20 | // "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 21 | // "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 22 | // "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 23 | 24 | /* Additional Checks */ 25 | "noUnusedLocals": true /* Report errors on unused locals. */, 26 | "noUnusedParameters": true /* Report errors on unused parameters. */, 27 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 28 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 29 | 30 | /* Debugging Options */ 31 | "traceResolution": false /* Report module resolution log messages. */, 32 | "listEmittedFiles": false /* Print names of generated files part of the compilation. */, 33 | "listFiles": false /* Print names of files part of the compilation. */, 34 | "pretty": true /* Stylize errors and messages using color and context. */, 35 | 36 | /* Experimental Options */ 37 | // "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 38 | // "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 39 | 40 | "lib": ["DOM", "ES2015"], 41 | "types": [], 42 | "typeRoots": ["node_modules/@types", "src/types"] 43 | }, 44 | "include": ["src/**/*.ts"], 45 | "exclude": ["node_modules/**"], 46 | "compileOnSave": false 47 | } 48 | -------------------------------------------------------------------------------- /tsconfig.module.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "outDir": "build/module", 6 | "module": "esnext" 7 | }, 8 | "exclude": [ 9 | "node_modules/**" 10 | ] 11 | } 12 | --------------------------------------------------------------------------------