Skip to content

Commit

Permalink
Merge pull request #12 from Code-Hex/add/cookie-verify
Browse files Browse the repository at this point in the history
Supported cookie verification
  • Loading branch information
Code-Hex authored Feb 19, 2024
2 parents 5f62fa6 + 5742997 commit d66186a
Show file tree
Hide file tree
Showing 28 changed files with 1,707 additions and 181 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
node-version: 18
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm test-with-emulator
env:
CI: true
eslint:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ lerna-debug.log*
*.tsbuildinfo

# Dependency directories
node_modules/
node_modules/
.wrangler/
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,13 @@ $ npm i firebase-auth-cloudflare-workers

## API

### `Auth.getOrInitialize(projectId: string, keyStore: KeyStorer): Auth`
### `Auth.getOrInitialize(projectId: string, keyStore: KeyStorer, credential?: Credential): Auth`

Auth is created as a singleton object. This is because the Module Worker syntax only use environment variables at the time of request.

- `projectId` specifies the ID of the project for which firebase auth is used.
- `keyStore` is used to cache the public key used to validate the Firebase ID token (JWT).
- `credential` is an optional. This is used to utilize Admin APIs such as `createSessionCookie`. Currently, you can specify `ServiceAccountCredential` class, which allows you to use a service account.

See official document for project ID: https://firebase.google.com/docs/projects/learn-more#project-identifiers

Expand All @@ -125,6 +126,7 @@ Verifies a Firebase ID token (JWT). If the token is valid, the promise is fulfil

See the [ID Token section of the OpenID Connect spec](http://openid.net/specs/openid-connect-core-1_0.html#IDToken) for more information about the specific properties below.

- `idToken` The ID token to verify.
- `env` is an optional parameter. but this is using to detect should use emulator or not.

### `WorkersKVStoreSingle.getOrInitialize(cacheKey: string, cfKVNamespace: KVNamespace): WorkersKVStoreSingle`
Expand All @@ -138,6 +140,25 @@ This is implemented `KeyStorer` interface.
- `cacheKey` specifies the key of the public key cache.
- `cfKVNamespace` specifies the KV namespace which is bound your workers.

### `createSessionCookie(idToken: string, sessionCookieOptions: SessionCookieOptions, env?: EmulatorEnv): Promise<string>`

Creates a new Firebase session cookie with the specified options. The created JWT string can be set as a server-side session cookie with a custom cookie policy, and be used for session management. The session cookie JWT will have the same payload claims as the provided ID token. See [Manage Session Cookies](https://firebase.google.com/docs/auth/admin/manage-cookies) for code samples and detailed documentation.

- `idToken` The Firebase ID token to exchange for a session cookie.
- `sessionCookieOptions` The session cookie options which includes custom session duration.
- `env` is an optional parameter. but this is using to detect should use emulator or not.

**Required** service acccount credential to use this API. You need to set the credentials with `Auth.getOrInitialize`.

### `verifySessionCookie(sessionCookie: string, env?: EmulatorEnv): Promise<FirebaseIdToken>`

Verifies a Firebase session cookie. Returns a Promise with the cookie claims. Rejects the promise if the cookie could not be verified.

See [Verify Session Cookies](https://firebase.google.com/docs/auth/admin/manage-cookies#verify_session_cookie_and_check_permissions) for code samples and detailed documentation.

- `sessionCookie` The session cookie to verify.
- `env` is an optional parameter. but this is using to detect should use emulator or not.

### `emulatorHost(env?: EmulatorEnv): string | undefined`

Returns the host of your Firebase Auth Emulator. For example, this case returns `"127.0.0.1:9099"` if you configured like below.
Expand Down Expand Up @@ -185,14 +206,20 @@ Interface representing a decoded Firebase ID token, returned from the `authObj.v
I put an [example](https://github.com/Code-Hex/firebase-auth-cloudflare-workers/tree/master/example) directory as Module Worker Syntax. this is explanation how to run the code.

1. Clone this repository and change your directory to it.
2. Install dev dependencies as `yarn` command.
3. Run firebase auth emulator by `$ yarn start-firebase-emulator`
2. Install dev dependencies as `pnpm` command.
3. Run firebase auth emulator by `$ pnpm start-firebase-emulator`
4. Access to Emulator UI in your favorite browser.
5. Create a new user on Emulator UI. (email: `[email protected]` password: `test1234`)
6. Run example code on local (may serve as `localhost:8787`) by `$ yarn start-example`
6. Run example code on local (may serve as `localhost:8787`) by `$ pnpm start-example`
7. Get jwt for created user by `$ curl -s http://localhost:8787/get-jwt | jq .idToken -r`
8. Try authorization with user jwt `$ curl http://localhost:8787/ -H 'Authorization: Bearer PASTE-JWT-HERE'`

### for Session Cookie

You can try session cookie with your browser.

Access to `/admin/login` after started up Emulator and created an account (email: `[email protected]` password: `test1234`).

## Todo

### Non-required service account key.
Expand Down
200 changes: 171 additions & 29 deletions example/index.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,196 @@
import type { EmulatorEnv } from '../src';
import { Auth, emulatorHost, WorkersKVStoreSingle } from '../src';
import { Hono } from 'hono';
import { getCookie, setCookie } from 'hono/cookie';
import { csrf } from 'hono/csrf';
import { html } from 'hono/html';
import { Auth, EmulatorCredential, emulatorHost, WorkersKVStoreSingle } from '../src';

interface Bindings extends EmulatorEnv {
type Env = {
EMAIL_ADDRESS: string;
PASSWORD: string;
FIREBASE_AUTH_EMULATOR_HOST: string;
PUBLIC_JWK_CACHE_KV: KVNamespace;
PROJECT_ID: string;
PUBLIC_JWK_CACHE_KEY: string;
}

FIREBASE_AUTH_EMULATOR_HOST: string; // satisfied EmulatorEnv
};

const app = new Hono<{ Bindings: Env }>();

const signInPath = '/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=test1234';

export async function handleRequest(req: Request, env: Bindings) {
const url = new URL(req.url);
const firebaseEmuHost = emulatorHost(env);
if (url.pathname === '/get-jwt' && !!firebaseEmuHost) {
const firebaseEmulatorSignInUrl = 'http://' + firebaseEmuHost + signInPath;
const resp = await fetch(firebaseEmulatorSignInUrl, {
method: 'POST',
body: JSON.stringify({
email: env.EMAIL_ADDRESS,
password: env.PASSWORD,
returnSecureToken: true,
}),
headers: {
'Content-Type': 'application/json',
},
});
return resp;
}
app.get('/get-jwt', async c => {
const firebaseEmuHost = emulatorHost(c.env);
const firebaseEmulatorSignInUrl = 'http://' + firebaseEmuHost + signInPath;
return await fetch(firebaseEmulatorSignInUrl, {
method: 'POST',
body: JSON.stringify({
email: c.env.EMAIL_ADDRESS,
password: c.env.PASSWORD,
returnSecureToken: true,
}),
headers: {
'Content-Type': 'application/json',
},
});
});

const authorization = req.headers.get('Authorization');
app.post('/verify-header', async c => {
const authorization = c.req.raw.headers.get('Authorization');
if (authorization === null) {
return new Response(null, {
status: 400,
});
}
const jwt = authorization.replace(/Bearer\s+/i, '');
const auth = Auth.getOrInitialize(
env.PROJECT_ID,
WorkersKVStoreSingle.getOrInitialize(env.PUBLIC_JWK_CACHE_KEY, env.PUBLIC_JWK_CACHE_KV)
c.env.PROJECT_ID,
WorkersKVStoreSingle.getOrInitialize(c.env.PUBLIC_JWK_CACHE_KEY, c.env.PUBLIC_JWK_CACHE_KV)
);
const firebaseToken = await auth.verifyIdToken(jwt, env);
const firebaseToken = await auth.verifyIdToken(jwt, c.env);

return new Response(JSON.stringify(firebaseToken), {
headers: {
'Content-Type': 'application/json',
},
});
}
});

app.use('/admin/*', csrf());

app.get('/admin/login', async c => {
const content = await html`<html>
<head>
<meta charset="UTF-8" />
<title>Login</title>
</head>
<body>
<h1>Login Page</h1>
<button id="sign-in" type="button">Sign-In</button>
<script type="module">
// See https://firebase.google.com/docs/auth/admin/manage-cookies
//
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.5.0/firebase-app.js';
import $ from 'https://cdn.skypack.dev/jquery';
// Add Firebase products that you want to use
import {
getAuth,
signInWithEmailAndPassword,
onAuthStateChanged,
connectAuthEmulator,
signOut,
setPersistence,
inMemoryPersistence,
} from 'https://www.gstatic.com/firebasejs/10.5.0/firebase-auth.js';
const app = initializeApp({
apiKey: 'test1234',
authDomain: 'test',
projectId: 'project12345',
});
const auth = getAuth(app);
connectAuthEmulator(auth, 'http://127.0.0.1:9099');
setPersistence(auth, inMemoryPersistence);
/**
* @param {string} name The cookie name.
* @return {?string} The corresponding cookie value to lookup.
*/
function getCookie(name) {
const v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)');
return v ? v[2] : null;
}
/**
* @param {string} url The session login endpoint.
* @param {string} idToken The ID token to post to backend.
* @return {any} A jQuery promise that resolves on completion.
*/
function postIdTokenToSessionLogin(url, idToken) {
// POST to session login endpoint.
return $.ajax({
type: 'POST',
url: url,
data: JSON.stringify({ idToken: idToken }),
contentType: 'application/json',
});
}
$('#sign-in').on('click', function () {
console.log('clicked');
signInWithEmailAndPassword(auth, '[email protected]', 'test1234')
.then(({ user }) => {
// Get the user's ID token as it is needed to exchange for a session cookie.
const idToken = user.accessToken;
// Session login endpoint is queried and the session cookie is set.
// CSRF protection should be taken into account.
// ...
const csrfToken = getCookie('csrfToken');
return postIdTokenToSessionLogin('/admin/login_session', idToken, csrfToken);
})
.then(() => {
// A page redirect would suffice as the persistence is set to NONE.
return signOut(auth);
})
.then(() => {
window.location.assign('/admin/profile');
});
});
</script>
</body>
</html>`;
return c.html(content);
});

app.post('/admin/login_session', async c => {
const json = await c.req.json();
const idToken = json.idToken;
if (!idToken || typeof idToken !== 'string') {
return c.json({ message: 'invalid idToken' }, 400);
}
// Set session expiration to 5 days.
const expiresIn = 60 * 60 * 24 * 5 * 1000;
// Create the session cookie. This will also verify the ID token in the process.
// The session cookie will have the same claims as the ID token.
// To only allow session cookie setting on recent sign-in, auth_time in ID token
// can be checked to ensure user was recently signed in before creating a session cookie.
const auth = Auth.getOrInitialize(
c.env.PROJECT_ID,
WorkersKVStoreSingle.getOrInitialize(c.env.PUBLIC_JWK_CACHE_KEY, c.env.PUBLIC_JWK_CACHE_KV),
new EmulatorCredential() // You MUST use ServiceAccountCredential in real world
);
const sessionCookie = await auth.createSessionCookie(
idToken,
{
expiresIn,
},
c.env // This valus must be removed in real world
);
setCookie(c, 'session', sessionCookie, {
maxAge: expiresIn,
httpOnly: true,
// secure: true // set this in real world
});
return c.json({ message: 'success' });
});

app.get('/admin/profile', async c => {
const session = getCookie(c, 'session') ?? '';

const auth = Auth.getOrInitialize(
c.env.PROJECT_ID,
WorkersKVStoreSingle.getOrInitialize(c.env.PUBLIC_JWK_CACHE_KEY, c.env.PUBLIC_JWK_CACHE_KV),
new EmulatorCredential() // You MUST use ServiceAccountCredential in real world
);

try {
const decodedToken = await auth.verifySessionCookie(
session,
c.env // This valus must be removed in real world
);
return c.json(decodedToken);
} catch (err) {
return c.redirect('/admin/login');
}
});

export default { fetch: handleRequest };
export default app;
7 changes: 4 additions & 3 deletions example/wrangler.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name = "firebase-auth-example"
compatibility_date = "2022-07-05"
compatibility_date = "2023-12-01"
workers_dev = true
main = "index.ts"

tsconfig = "./tsconfig.json"

Expand All @@ -21,12 +22,12 @@ FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099"
EMAIL_ADDRESS = "[email protected]"
PASSWORD = "test1234"

PROJECT_ID = "example-project12345" # see package.json (for emulator)
PROJECT_ID = "project12345" # see package.json (for emulator)

# Specify cache key to store and get public jwk.
PUBLIC_JWK_CACHE_KEY = "public-jwk-cache-key"

[[kv_namespaces]]
binding = "PUBLIC_JWK_CACHE_KV"
id = ""
id = "testingId"
preview_id = "testingId"
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@
],
"scripts": {
"test": "vitest run",
"test-with-emulator": "firebase emulators:exec --project project12345 'vitest run'",
"build": "run-p build:*",
"build:main": "tsc -p tsconfig.main.json",
"build:module": "tsc -p tsconfig.module.json",
"start-firebase-emulator": "firebase emulators:start --project example-project12345",
"start-firebase-emulator": "firebase emulators:start --project project12345",
"start-example": "wrangler dev example/index.ts --config=example/wrangler.toml --local=true",
"prettier": "prettier --write --list-different \"**/*.ts\"",
"prettier:check": "prettier --check \"**/*.ts\"",
"lint": "eslint --ext .ts .",
"lint-fix": "eslint --fix --ext .ts .",
"prepublish": "run-p build:*"
"prepublish": "run-p build:*",
"wrangler": "wrangler"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240208.0",
Expand All @@ -36,10 +38,12 @@
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-import": "^2.29.1",
"firebase-tools": "^13.3.0",
"hono": "^4.0.4",
"miniflare": "^3.20240129.3",
"npm-run-all": "^4.1.5",
"prettier": "^3.2.5",
"typescript": "^5.3.3",
"undici": "^6.6.2",
"vitest": "^1.3.0",
"wrangler": "^3.28.3"
},
Expand Down
Loading

0 comments on commit d66186a

Please sign in to comment.