feat(firebase-auth): added a new middleware to verify session with firebase-auth (#402)

* fix(firebase-auth): updated firebase-auth-cloudflare-workers

* add(firebase-auth): added a new `verifySessionCookieFirebaseAuth` middleware to verify w/ session cookie
pull/403/head
Kei Kamikawa 2024-02-24 22:35:24 +09:00 committed by GitHub
parent 9b455b5408
commit c9f110d4e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1043 additions and 551 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/firebase-auth': minor
---
added a new `verifySessionCookieFirebaseAuth` middleware to verify w/ session cookie.

3
.gitignore vendored
View File

@ -14,4 +14,5 @@ coverage
yarn-error.log
# for debug or playing
sandbox
sandbox
*.log

View File

@ -4,7 +4,7 @@ This is a Firebase Auth middleware library for [Hono](https://github.com/honojs/
Currently only Cloudflare Workers are supported officially. However, it may work in other environments as well, so please let us know in an issue if it works.
## Synopsis
## Synopsis (verify w/ authorization header)
### Module Worker Syntax (recommend)
@ -100,6 +100,168 @@ You can specify a host for the Firebase Auth emulator. This config is mainly use
If not specified, check the [`FIREBASE_AUTH_EMULATOR_HOST` environment variable obtained from the request](https://github.com/Code-Hex/firebase-auth-cloudflare-workers#emulatorenv).
## Synopsis (verify w/ session cookie)
### Module Worker Syntax (recommend)
```ts
import { Hono } from 'hono'
import { setCookie } from 'hono/cookie';
import { csrf } from 'hono/csrf';
import { html } from 'hono/html';
import {
VerifySessionCookieFirebaseAuthConfig,
VerifyFirebaseAuthEnv,
verifySessionCookieFirebaseAuth,
getFirebaseToken,
} from '@hono/firebase-auth'
import { AdminAuthApiClient, ServiceAccountCredential } from 'firebase-auth-cloudflare-workers';
const config: VerifySessionCookieFirebaseAuthConfig = {
// specify your firebase project ID.
projectId: 'your-project-id',
redirects: {
signIn: "/login"
}
}
// You can specify here the extended VerifyFirebaseAuthEnv type.
//
// If you do not specify `keyStore` in the configuration, you need to set
// the variables `PUBLIC_JWK_CACHE_KEY` and `PUBLIC_JWK_CACHE_KV` in your
// wrangler.toml. This is because `WorkersKVStoreSingle` is used by default.
//
// For more details, please refer to: https://github.com/Code-Hex/firebase-auth-cloudflare-workers
type MyEnv = VerifyFirebaseAuthEnv & {
// See: https://github.com/Code-Hex/firebase-auth-cloudflare-workers?tab=readme-ov-file#adminauthapiclientgetorinitializeprojectid-string-credential-credential-retryconfig-retryconfig-adminauthapiclient
SERVICE_ACCOUNT_JSON: string
}
const app = new Hono<{ Bindings: MyEnv }>()
// set middleware
app.get('/login', csrf(), async c => {
// You can copy code from here
// https://github.com/Code-Hex/firebase-auth-cloudflare-workers/blob/0ce610fff257b0b60e2f8e38d89c8e012497d537/example/index.ts#L63C25-L63C37
const content = await html`...`
return c.html(content)
})
app.post('/login_session', csrf(), (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 = AdminAuthApiClient.getOrInitialize(
c.env.PROJECT_ID,
new ServiceAccountCredential(c.env.SERVICE_ACCOUNT_JSON)
);
const sessionCookie = await auth.createSessionCookie(
idToken,
expiresIn,
);
setCookie(c, 'session', sessionCookie, {
maxAge: expiresIn,
httpOnly: true,
secure: true
});
return c.json({ message: 'success' });
})
app.use('/admin/*', csrf(), verifySessionCookieFirebaseAuth(config));
app.get('/admin/hello', (c) => {
const idToken = getFirebaseToken(c) // get id-token object.
return c.json(idToken)
})
export default app
```
## Config (`VerifySessionCookieFirebaseAuthConfig`)
### `projectId: string` (**required**)
This field indicates your firebase project ID.
### `redirects: object` (**required**)
This object has a property named redirects, which in turn has a property named `signIn` of type string.
The `signIn` property is expected to hold a string representing the path to redirect to after a user has failed to sign-in.
### `cookieName?: string` (optional)
Based on this configuration, the session token has created by firebase auth is looked for in the cookie. The default is "session".
### `keyStore?: KeyStorer` (optional)
This is used to cache the public key used to validate the Firebase ID token (JWT). This KeyStorer type has been defined in [firebase-auth-cloudflare-workers](https://github.com/Code-Hex/firebase-auth-cloudflare-workers/tree/main#keystorer) library.
If you don't specify the field, this library uses [WorkersKVStoreSingle](https://github.com/Code-Hex/firebase-auth-cloudflare-workers/tree/main#workerskvstoresinglegetorinitializecachekey-string-cfkvnamespace-kvnamespace-workerskvstoresingle) instead. You must fill in the fields defined in `VerifyFirebaseAuthEnv`.
### `keyStoreInitializer?: (c: Context) => KeyStorer` (optional)
Use this when initializing KeyStorer and environment variables, etc. are required.
If you don't specify the field, this library uses [WorkersKVStoreSingle](https://github.com/Code-Hex/firebase-auth-cloudflare-workers/tree/main#workerskvstoresinglegetorinitializecachekey-string-cfkvnamespace-kvnamespace-workerskvstoresingle) instead. You must fill in the fields defined in `VerifyFirebaseAuthEnv`.
### `firebaseEmulatorHost?: string` (optional)
You can specify a host for the Firebase Auth emulator. This config is mainly used when **Service Worker Syntax** is used.
If not specified, check the [`FIREBASE_AUTH_EMULATOR_HOST` environment variable obtained from the request](https://github.com/Code-Hex/firebase-auth-cloudflare-workers#emulatorenv).
### What content should I read?
- [Manage Session Cookies](https://firebase.google.com/docs/auth/admin/manage-cookies)
### Security Considerations when using session cookies
When considering that a web framework uses tokens via cookies, security measures related to traditional browsers and cookies should be considered.
1. CSRF (Cross-Site Request Forgery)
2. XSS (Cross-Site Scripting)
3. MitM (Man-in-the-middle attack)
Let's consider each:
**CSRF**
This is provided by hono as a standard middleware feature
https://hono.dev/middleware/builtin/csrf
**XSS**
An attacker can inject a script and steal JWTs stored in cookies. Set the httpOnly flag on the cookie to prevent access from JavaScript. Additionally, configure "Content Security Policy" (CSP) to prevent unauthorized script execution. It is recommeded to force httpOnly and the functionality here: https://hono.dev/middleware/builtin/secure-headers
**MitM**
If your cookie security settings are inappropriate, there is a risk that your cookies will be stolen by a MitM. Use Samesite (or hono csrf middleware) and `__Secure-` prefix and `__Host-` prefix attributes.
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes
An example of good cookie settings:
```ts
const secureCookieSettings: CookieOptions = {
path: '/',
domain: <your_domain>,
secure: true,
httpOnly: true,
sameSite: 'Strict',
}
```
## Author
codehex <https://github.com/Code-Hex>

View File

@ -42,19 +42,19 @@
"access": "public"
},
"dependencies": {
"firebase-auth-cloudflare-workers": "^1.1.0"
"firebase-auth-cloudflare-workers": "^2.0.2"
},
"peerDependencies": {
"hono": ">=3.*"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20231025.0",
"firebase-tools": "^11.4.0",
"hono": "^3.11.7",
"miniflare": "^3.20231025.1",
"prettier": "^2.7.1",
"tsup": "^8.0.1",
"typescript": "^4.7.4",
"vitest": "^0.34.6"
"@cloudflare/workers-types": "^4.20240222.0",
"firebase-tools": "^13.3.1",
"hono": "3.12.12",
"miniflare": "^3.20240208.0",
"prettier": "^3.2.5",
"tsup": "^8.0.2",
"typescript": "^5.3.3",
"vitest": "^1.3.1"
}
}

View File

@ -1,3 +1,4 @@
import { getCookie } from 'hono/cookie'
import type { KeyStorer, FirebaseIdToken } from 'firebase-auth-cloudflare-workers'
import { Auth, WorkersKVStoreSingle } from 'firebase-auth-cloudflare-workers'
import type { Context, MiddlewareHandler } from 'hono'
@ -24,7 +25,7 @@ const defaultKeyStoreInitializer = (c: Context<{ Bindings: VerifyFirebaseAuthEnv
const res = new Response('Not Implemented', {
status: 501,
})
throw new HTTPException(501, { res })
throw new HTTPException(res.status, { res })
}
return WorkersKVStoreSingle.getOrInitialize(
c.env.PUBLIC_JWK_CACHE_KEY ?? defaultKVStoreJWKCacheKey,
@ -35,29 +36,32 @@ const defaultKeyStoreInitializer = (c: Context<{ Bindings: VerifyFirebaseAuthEnv
export const verifyFirebaseAuth = (userConfig: VerifyFirebaseAuthConfig): MiddlewareHandler => {
const config = {
projectId: userConfig.projectId,
AuthorizationHeaderKey: userConfig.authorizationHeaderKey ?? 'Authorization',
KeyStore: userConfig.keyStore,
authorizationHeaderKey: userConfig.authorizationHeaderKey ?? 'Authorization',
keyStore: userConfig.keyStore,
keyStoreInitializer: userConfig.keyStoreInitializer ?? defaultKeyStoreInitializer,
disableErrorLog: userConfig.disableErrorLog,
firebaseEmulatorHost: userConfig.firebaseEmulatorHost,
}
} satisfies VerifyFirebaseAuthConfig
// TODO(codehex): will be supported
const checkRevoked = false
return async (c, next) => {
const authorization = c.req.raw.headers.get(config.AuthorizationHeaderKey)
const authorization = c.req.raw.headers.get(config.authorizationHeaderKey)
if (authorization === null) {
const res = new Response('Bad Request', {
status: 400,
})
throw new HTTPException(400, { res })
throw new HTTPException(res.status, { res, message: 'authorization header is empty' })
}
const jwt = authorization.replace(/Bearer\s+/i, '')
const auth = Auth.getOrInitialize(
config.projectId,
config.KeyStore ?? config.keyStoreInitializer(c)
config.keyStore ?? config.keyStoreInitializer(c)
)
try {
const idToken = await auth.verifyIdToken(jwt, {
const idToken = await auth.verifyIdToken(jwt, checkRevoked, {
FIREBASE_AUTH_EMULATOR_HOST:
config.firebaseEmulatorHost ?? c.env.FIREBASE_AUTH_EMULATOR_HOST,
})
@ -73,7 +77,10 @@ export const verifyFirebaseAuth = (userConfig: VerifyFirebaseAuthConfig): Middle
const res = new Response('Unauthorized', {
status: 401,
})
throw new HTTPException(401, { res })
throw new HTTPException(res.status, {
res,
message: `failed to verify the requested firebase token: ${String(err)}`,
})
}
await next()
}
@ -90,3 +97,57 @@ export const getFirebaseToken = (c: Context): FirebaseIdToken | null => {
}
return idToken
}
export interface VerifySessionCookieFirebaseAuthConfig {
projectId: string
cookieName?: string
keyStore?: KeyStorer
keyStoreInitializer?: (c: Context) => KeyStorer
firebaseEmulatorHost?: string
redirects: {
signIn: string
}
}
export const verifySessionCookieFirebaseAuth = (
userConfig: VerifySessionCookieFirebaseAuthConfig
): MiddlewareHandler => {
const config = {
projectId: userConfig.projectId,
cookieName: userConfig.cookieName ?? 'session',
keyStore: userConfig.keyStore,
keyStoreInitializer: userConfig.keyStoreInitializer ?? defaultKeyStoreInitializer,
firebaseEmulatorHost: userConfig.firebaseEmulatorHost,
redirects: userConfig.redirects,
} satisfies VerifySessionCookieFirebaseAuthConfig
// TODO(codehex): will be supported
const checkRevoked = false
return async (c, next) => {
const auth = Auth.getOrInitialize(
config.projectId,
config.keyStore ?? config.keyStoreInitializer(c)
)
const session = getCookie(c, config.cookieName)
if (session === undefined) {
const res = c.redirect(config.redirects.signIn)
throw new HTTPException(res.status, { res, message: 'session is empty' })
}
try {
const idToken = await auth.verifySessionCookie(session, checkRevoked, {
FIREBASE_AUTH_EMULATOR_HOST:
config.firebaseEmulatorHost ?? c.env.FIREBASE_AUTH_EMULATOR_HOST,
})
setFirebaseToken(c, idToken)
} catch (err) {
const res = c.redirect(config.redirects.signIn)
throw new HTTPException(res.status, {
res,
message: `failed to verify the requested firebase token: ${String(err)}`,
})
}
await next()
}
}

View File

@ -1,10 +1,12 @@
import type { KeyStorer } from 'firebase-auth-cloudflare-workers'
import { Auth, WorkersKVStoreSingle } from 'firebase-auth-cloudflare-workers'
import type { Credential, KeyStorer } from 'firebase-auth-cloudflare-workers'
import { AdminAuthApiClient, Auth, WorkersKVStoreSingle } from 'firebase-auth-cloudflare-workers'
import { Hono } from 'hono'
import { Miniflare } from 'miniflare'
import { describe, it, expect, beforeAll, vi } from 'vitest'
import type { VerifyFirebaseAuthEnv } from '../src'
import { verifyFirebaseAuth, getFirebaseToken } from '../src'
import { verifyFirebaseAuth, getFirebaseToken, verifySessionCookieFirebaseAuth } from '../src'
import { GoogleOAuthAccessToken } from 'firebase-auth-cloudflare-workers/dist/main/credential'
import { setCookie } from 'hono/cookie'
describe('verifyFirebaseAuth middleware', () => {
const emulatorHost = '127.0.0.1:9099'
@ -244,7 +246,8 @@ describe('verifyFirebaseAuth middleware', () => {
})
const env: VerifyFirebaseAuthEnv = {
FIREBASE_AUTH_EMULATOR_HOST: 'localhost:9099',
FIREBASE_AUTH_EMULATOR_HOST: emulatorHost,
PUBLIC_JWK_CACHE_KV: undefined,
}
const res = await app.fetch(req, env)
@ -254,6 +257,316 @@ describe('verifyFirebaseAuth middleware', () => {
})
})
describe('verifySessionCookieFirebaseAuth middleware', () => {
const emulatorHost = '127.0.0.1:9099'
const validProjectId = 'example-project12345' // see package.json
const nullScript = 'export default { fetch: () => new Response(null, { status: 404 }) };'
const mf = new Miniflare({
modules: true,
script: nullScript,
kvNamespaces: ['PUBLIC_JWK_CACHE_KV'],
})
let user: signUpResponse
const env: VerifyFirebaseAuthEnv = {
FIREBASE_AUTH_EMULATOR_HOST: emulatorHost,
}
const expiresIn = 24 * 60 * 60 * 1000
beforeAll(async () => {
await deleteAccountEmulator(emulatorHost, validProjectId)
user = await signUpEmulator(emulatorHost, {
email: 'codehex@hono.js',
password: 'honojs',
})
await sleep(1000) // wait for iat
})
describe('service worker syntax', () => {
it('valid case, should be 200', async () => {
const app = new Hono()
resetAuth()
// This is assumed to be obtained from an environment variable.
const PUBLIC_JWK_CACHE_KEY = 'testing-cache-key'
const PUBLIC_JWK_CACHE_KV = (await mf.getKVNamespace(
'PUBLIC_JWK_CACHE_KV'
)) as unknown as KVNamespace
const cookieName = 'session-key'
app.get(
'/hello',
verifySessionCookieFirebaseAuth({
projectId: validProjectId,
cookieName,
keyStore: WorkersKVStoreSingle.getOrInitialize(PUBLIC_JWK_CACHE_KEY, PUBLIC_JWK_CACHE_KV),
firebaseEmulatorHost: emulatorHost,
redirects: {
signIn: '/login',
},
}),
(c) => c.json(getFirebaseToken(c))
)
app.post('/create-session', async (c) => {
const { idToken } = await c.req.json<{ idToken: string }>()
const adminAuthClient = AdminAuthApiClient.getOrInitialize(
validProjectId,
new NopCredential()
)
const sessionCookie = await adminAuthClient.createSessionCookie(idToken, expiresIn, env)
setCookie(c, cookieName, sessionCookie, {
httpOnly: true,
})
return c.newResponse(null, 200)
})
const req1 = new Request('http://localhost/create-session', {
method: 'POST',
body: JSON.stringify({
idToken: user.idToken,
}),
headers: {
'Content-Type': 'application/json',
},
})
const res1 = await app.request(req1)
expect(res1).not.toBeNull()
expect(res1.status).toBe(200)
const gotSetCookie = res1.headers.get('Set-Cookie')
expect(gotSetCookie).not.toBeNull()
const req2 = new Request('http://localhost/hello', {
method: 'GET',
headers: {
Cookie: gotSetCookie!,
'Content-Type': 'application/json',
},
})
const res2 = await app.request(req2)
expect(res2.status).toBe(200)
const json = await res2.json<{ aud: string; email: string }>()
expect(json.aud).toBe(validProjectId)
expect(json.email).toBe('codehex@hono.js')
})
})
describe('module worker syntax', () => {
const signInPath = '/login'
const adminAuthClient = AdminAuthApiClient.getOrInitialize(validProjectId, new NopCredential())
it.each([
[
'valid case, should be 200',
{
cookieName: 'session',
config: {
projectId: validProjectId,
},
wantStatus: 200,
},
],
[
'valid specified cookie name, should be 200',
{
cookieName: 'x-cookie',
config: {
projectId: validProjectId,
cookieName: 'x-cookie',
},
wantStatus: 200,
},
],
[
'mismatched cookie name, should be 302',
{
cookieName: 'x-cookie',
config: {
projectId: validProjectId, // see package.json
// No specified cookie name.
},
wantStatus: 302,
},
],
[
'invalid project ID, should be 302',
{
cookieName: 'session',
config: {
projectId: 'invalid-projectId',
},
wantStatus: 302,
},
],
])('%s', async (_, { cookieName, config, wantStatus }) => {
const app = new Hono<{ Bindings: VerifyFirebaseAuthEnv }>()
resetAuth()
const PUBLIC_JWK_CACHE_KV = (await mf.getKVNamespace(
'PUBLIC_JWK_CACHE_KV'
)) as unknown as KVNamespace
const env: VerifyFirebaseAuthEnv = {
FIREBASE_AUTH_EMULATOR_HOST: emulatorHost,
PUBLIC_JWK_CACHE_KEY: 'testing-cache-key',
PUBLIC_JWK_CACHE_KV,
}
app.use(
'*',
verifySessionCookieFirebaseAuth({
...config,
redirects: {
signIn: signInPath,
},
})
)
app.get('/hello', (c) => c.text('OK'))
const sessionCookie = await adminAuthClient.createSessionCookie(user.idToken, expiresIn, env)
const req = new Request('http://localhost/hello', {
headers: {
Cookie: `${cookieName}=${sessionCookie}; Path=/; HttpOnly`,
},
})
const res = await app.fetch(req, env)
expect(res).not.toBeNull()
expect(res.status).toBe(wantStatus)
if (wantStatus === 302) {
expect(res.headers.get('location')).toBe(signInPath)
}
})
// NOTE(codehex): We can't check this test because https://identitytoolkit.googleapis.com/v1/sessionCookiePublicKeys endpoint
// responds with `cache-control: no-cache, no-store, max-age=0, must-revalidate`
//
// it('specified keyStore is used', async () => {
// const nopKeyStore = new NopKeyStore()
// const getSpy = vi.spyOn(nopKeyStore, 'get')
// const putSpy = vi.spyOn(nopKeyStore, 'put')
// const app = new Hono<{ Bindings: VerifyFirebaseAuthEnv }>()
// resetAuth()
// const signInPath = '/login'
// app.use(
// '*',
// verifySessionCookieFirebaseAuth({
// projectId: validProjectId,
// keyStore: nopKeyStore,
// redirects: {
// signIn: signInPath,
// },
// })
// )
// app.get('/hello', (c) => c.text('OK'))
// const sessionCookie = await adminAuthClient.createSessionCookie(user.idToken, expiresIn, env)
// const req = new Request('http://localhost/hello', {
// headers: {
// Cookie: `session=${sessionCookie}; Path=/; HttpOnly`,
// },
// })
// // not use firebase emulator to check using key store
// const res = await app.fetch(req, {
// FIREBASE_AUTH_EMULATOR_HOST: undefined,
// })
// expect(res).not.toBeNull()
// expect(res.status).toBe(302)
// expect(res.headers.get('location')).toBe(signInPath)
// expect(getSpy).toHaveBeenCalled()
// expect(putSpy).toHaveBeenCalled()
// })
it('usable id-token in main handler', async () => {
const nopKeyStore = new NopKeyStore()
const app = new Hono<{ Bindings: VerifyFirebaseAuthEnv }>()
resetAuth()
app.use(
'*',
verifySessionCookieFirebaseAuth({
projectId: validProjectId,
keyStore: nopKeyStore,
redirects: {
signIn: signInPath,
},
})
)
app.get('/hello', (c) => c.json(getFirebaseToken(c)))
const sessionCookie = await adminAuthClient.createSessionCookie(user.idToken, expiresIn, env)
const req = new Request('http://localhost/hello', {
headers: {
Cookie: `session=${sessionCookie}; Path=/; HttpOnly`,
},
})
const res = await app.fetch(req, {
FIREBASE_AUTH_EMULATOR_HOST: emulatorHost,
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
const json = await res.json<{ aud: string; email: string }>()
expect(json.aud).toBe(validProjectId)
expect(json.email).toBe('codehex@hono.js')
})
it('invalid PUBLIC_JWK_CACHE_KV is undefined, should be 501', async () => {
const app = new Hono<{ Bindings: VerifyFirebaseAuthEnv }>()
resetAuth()
app.use(
'*',
verifySessionCookieFirebaseAuth({
projectId: validProjectId,
redirects: {
signIn: signInPath,
},
})
)
app.get('/hello', (c) => c.text('OK'))
const sessionCookie = await adminAuthClient.createSessionCookie(user.idToken, expiresIn, env)
const req = new Request('http://localhost/hello', {
headers: {
Cookie: `session=${sessionCookie}; Path=/; HttpOnly`,
},
})
const res = await app.fetch(req, {
FIREBASE_AUTH_EMULATOR_HOST: emulatorHost,
PUBLIC_JWK_CACHE_KV: undefined,
})
expect(res).not.toBeNull()
expect(res.status).toBe(501)
})
})
})
class NopKeyStore implements KeyStorer {
// eslint-disable-next-line @typescript-eslint/no-empty-function
constructor() {}
@ -346,3 +659,12 @@ const deleteAccountEmulator = async (emulatorHost: string, projectId: string): P
}
return
}
export class NopCredential implements Credential {
getAccessToken(): Promise<GoogleOAuthAccessToken> {
return Promise.resolve({
access_token: 'owner',
expires_in: 9 * 3600,
})
}
}

View File

@ -3,11 +3,12 @@
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": [
"node",
"@cloudflare/workers-types"
]
},
"include": [
"src/**/*.ts"
],
"types": [
"@cloudflare/workers-types"
],
}

988
yarn.lock

File diff suppressed because it is too large Load Diff