├── .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 |
9 |
10 |
11 |
12 |
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 |
--------------------------------------------------------------------------------