Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/backend-lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@
".": "./dist/index.js",
"./admin": "./dist/admin/index.js",
"./admin/*.js": "./dist/admin/*.js",
"./auth": "./dist/auth/index.js",
"./auth/*.js": "./dist/auth/*.js",
"./db": "./dist/db/index.js",
"./deploy": "./dist/deploy/index.js",
"./deploy/*.js": "./dist/deploy/*.js",
Expand Down
17 changes: 17 additions & 0 deletions packages/backend-lib/src/admin/adminMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import type { BackendRequestHandler } from '../server/server.model.js'
import type { BaseAdminService } from './base.admin.service.js'
import type { FirebaseSharedServiceCfg } from './firebase.shared.service.js'

/**
* @deprecated Use `AuthLoginRedirectMiddlewareCfg`.
*/
export interface RequireAdminCfg {
/**
* @default '/login.html'
Expand Down Expand Up @@ -35,11 +38,17 @@ export interface RequireAdminCfg {
andComparison?: boolean
}

/**
* @deprecated Use `AuthMiddleware`.
*/
export type AdminMiddleware = (
reqPermissions?: string[],
cfg?: RequireAdminCfg,
) => BackendRequestHandler

/**
* @deprecated Use `AuthService.getLoginRedirectMiddleware`.
*/
export function createAdminMiddleware(
adminService: BaseAdminService,
cfgDefaults: RequireAdminCfg = {},
Expand All @@ -56,6 +65,8 @@ export function createAdminMiddleware(
* If not authenticated - will redirect to `loginHtmlPath` (default to /login.html).
* If authenticated, but not authorized - will throw 403.
* Otherwise will just pass.
*
* @deprecated Use `AuthService.getLoginRedirectMiddleware`.
*/
export function requireAdminPermissions(
adminService: BaseAdminService,
Expand Down Expand Up @@ -97,6 +108,9 @@ interface LoginHtmlCfg {
firebaseAuthProvider: string
}

/**
* @deprecated Use `loginHtmlHandler`.
*/
export function loginHtml(firebaseServiceCfg: FirebaseSharedServiceCfg): BackendRequestHandler {
const {
apiKey: firebaseApiKey,
Expand Down Expand Up @@ -124,6 +138,9 @@ const getLoginHtml = _memoized((cfg: LoginHtmlCfg) => {
.replaceAll('<%= firebaseAuthProvider %>', cfg.firebaseAuthProvider)
})

/**
* @deprecated Use `AuthService.getLoginRedirectMiddleware`.
*/
export function getLoginHtmlRedirect(href: string): string {
return `
<html>
Expand Down
17 changes: 17 additions & 0 deletions packages/backend-lib/src/admin/base.admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { BackendRequest, BackendRequestHandler } from '../server/server.mod

/**
* Base implementation based on Firebase Auth tokens passed as 'admin_token' cookie.
*
* @deprecated Use `AuthService`, with `FirebaseAuthService` for `authenticate`.
*/
export class BaseAdminService {
constructor(
Expand Down Expand Up @@ -309,6 +311,9 @@ const adminInfoDisabled = (): AdminInfo => ({
permissions: [],
})

/**
* @deprecated Use `AuthServiceCfg` and `FirebaseAuthServiceCfg`.
*/
export interface AdminServiceCfg {
/**
* @default 'admin_token'
Expand All @@ -323,18 +328,27 @@ export interface AdminServiceCfg {
authEnabled?: boolean
}

/**
* @deprecated Use `PermissionInfo`.
*/
export interface AdminInfo {
email: string
permissions: string[]
}

/**
* @deprecated Use `ResolvedAuth`.
*/
export interface ResolvedAdmin {
/** undefined - no valid admin token */
email?: string
/** undefined - not an Admin */
permissions?: Set<string>
}

/**
* @deprecated Use `AuthCheckOptions`.
*/
export interface CheckPermissionsOptions {
/**
* false - one granted permission is enough.
Expand All @@ -344,6 +358,9 @@ export interface CheckPermissionsOptions {
andComparison?: boolean
}

/**
* @deprecated Use `AuthService.has` / `AuthService.require`.
*/
export interface CheckPermissionsResult {
/** undefined - no valid admin token */
email?: string
Expand Down
85 changes: 85 additions & 0 deletions packages/backend-lib/src/admin/firebaseAuth.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { FirebaseAuthError } from 'firebase-admin/auth'
import { describe, expect, test, vi } from 'vitest'
import { mockBackendRequest } from '../test/mocks.js'
import type { FirebaseTokenVerifier } from './firebaseAuth.service.js'
import { FirebaseAuthService } from './firebaseAuth.service.js'

describe('getToken', () => {
test('should read the cookie if only tokenCookie is configured', () => {
const service = new FirebaseAuthService(vi.fn<() => Promise<FirebaseTokenVerifier>>(), {
tokenCookie: 'auth_token',
})
const req = mockBackendRequest({
cookies: { auth_token: 'fromCookie' },
headers: { 'x-auth-token': 'fromHeader' },
})

const token = service.getToken(req)

expect(token).toBe('fromCookie')
})

test('should read the header if only tokenHeader is configured', () => {
const service = new FirebaseAuthService(vi.fn<() => Promise<FirebaseTokenVerifier>>(), {
tokenHeader: 'x-auth-token',
})
const req = mockBackendRequest({
cookies: { auth_token: 'fromCookie' },
headers: { 'x-auth-token': 'fromHeader' },
})

const token = service.getToken(req)

expect(token).toBe('fromHeader')
})

test('should read the cookie first, then the header, if both are configured', () => {
const service = new FirebaseAuthService(vi.fn<() => Promise<FirebaseTokenVerifier>>(), {
tokenCookie: 'auth_token',
tokenHeader: 'x-auth-token',
})
const reqWithBoth = mockBackendRequest({
cookies: { auth_token: 'fromCookie' },
headers: { 'x-auth-token': 'fromHeader' },
})
const reqWithHeader = mockBackendRequest({
headers: { 'x-auth-token': 'fromHeader' },
})

const tokenWithBoth = service.getToken(reqWithBoth)
const tokenWithHeader = service.getToken(reqWithHeader)

expect(tokenWithBoth).toBe('fromCookie')
expect(tokenWithHeader).toBe('fromHeader')
})
})

describe('getEmailByToken', () => {
test('should return undefined if the token is expired', async () => {
const service = new FirebaseAuthService(
async () => ({
verifyIdToken: async () => {
throw new FirebaseAuthError({ code: 'id-token-expired', message: 'expired' })
},
}),
{},
)

const email = await service.getEmailByToken('expired')

expect(email).toBeUndefined()
})

test('should reject on other verification failures', async () => {
const service = new FirebaseAuthService(
async () => ({
verifyIdToken: async () => {
throw new FirebaseAuthError({ code: 'argument-error', message: 'bad token' })
},
}),
{},
)

await expect(service.getEmailByToken('bad')).rejects.toThrow('bad token')
})
})
139 changes: 139 additions & 0 deletions packages/backend-lib/src/admin/firebaseAuth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { _Memo, _memoized } from '@naturalcycles/js-lib/decorators'
import { _assert } from '@naturalcycles/js-lib/error'
import { fs2 } from '@naturalcycles/nodejs-lib/fs2'
import type { Auth, FirebaseAuthError } from 'firebase-admin/auth'
import { srcDir } from '../paths.cnst.js'
import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js'
import type { FirebaseSharedServiceCfg } from './firebase.shared.service.js'

/**
* Verifies Firebase Auth tokens.
*/
export class FirebaseAuthService {
constructor(
private loadFirebaseAuth: () => Promise<FirebaseTokenVerifier>,
public cfg: FirebaseAuthServiceCfg,
) {}

/**
* Reads the configured cookie first, then the header.
*/
getToken(req: BackendRequest): string | undefined {
const { tokenCookie, tokenHeader } = this.cfg

if (tokenCookie) {
const token = req.cookies?.[tokenCookie]
if (token) return token
}

if (tokenHeader) return req.header(tokenHeader)
}

/**
* Returns undefined if there's no token, or it's expired.
* Rejects on any other verification failure.
*/
async getEmailByToken(token: string | undefined): Promise<string | undefined> {
if (!token) return

const auth = await this.getFirebaseAuth()
try {
const decodedToken = await auth.verifyIdToken(token)
return decodedToken.email
} catch (err) {
if ((err as Partial<FirebaseAuthError> | undefined)?.hasCode?.('id-token-expired')) return
throw err
}
}

@_Memo()
private async getFirebaseAuth(): Promise<FirebaseTokenVerifier> {
return await this.loadFirebaseAuth()
}

/**
* POST handler that stores the token from the `Authentication` header in `tokenCookie` (not validated).
* `Authentication: logout` clears it. The bundled login.html posts to `/admin/login`.
*/
getLoginHandler(): BackendRequestHandler {
const { tokenCookie } = this.cfg
_assert(tokenCookie, 'getLoginHandler requires tokenCookie to be configured')

return async (req, res) => {
const token = req.header('authentication')
_assert(token, `401 Unauthenticated`, {
userFriendly: true,
backendResponseStatusCode: 401,
})

let maxAge = 1000 * 60 * 60 * 24 * 30 // 30 days

if (token === 'logout') {
// delete the cookie
maxAge = 0
}

res
.cookie(tokenCookie, token, {
maxAge,
sameSite: 'lax', // can be: none, lax, strict
// comment these 2 lines to debug on localhost
httpOnly: true,
secure: true,
})
.status(204)
.end()
}
}
}

/**
* Serves login.html, configured for Firebase Auth.
*/
export function loginHtmlHandler(
firebaseServiceCfg: FirebaseSharedServiceCfg,
): BackendRequestHandler {
const {
apiKey: firebaseApiKey,
authDomain: firebaseAuthDomain,
adminAuthProvider: firebaseAuthProvider = 'GoogleAuthProvider',
} = firebaseServiceCfg

return (_req, res) => {
res.send(
getLoginHtml({
firebaseApiKey,
firebaseAuthDomain,
firebaseAuthProvider,
}),
)
}
}

const getLoginHtml = _memoized((cfg: LoginHtmlCfg) => {
return fs2
.readText(`${srcDir}/admin/login.html`)
.replaceAll('<%= firebaseApiKey %>', cfg.firebaseApiKey)
.replaceAll('<%= firebaseAuthDomain %>', cfg.firebaseAuthDomain)
.replaceAll('<%= firebaseAuthProvider %>', cfg.firebaseAuthProvider)
})

/**
* The part of Firebase `Auth` that FirebaseAuthService uses.
*/
export type FirebaseTokenVerifier = Pick<Auth, 'verifyIdToken'>

export interface FirebaseAuthServiceCfg {
/**
* Read by `getToken`, set by `getLoginHandler`.
*/
tokenCookie?: string

tokenHeader?: string
}

interface LoginHtmlCfg {
firebaseApiKey: string
firebaseAuthDomain: string
firebaseAuthProvider: string
}
1 change: 1 addition & 0 deletions packages/backend-lib/src/admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './adminMiddleware.js'
export * from './base.admin.service.js'
export * from './firebase.shared.service.js'
export * from './firebaseAuth.service.js'
export * from './secureHeaderMiddleware.js'
5 changes: 5 additions & 0 deletions packages/backend-lib/src/admin/secureHeaderMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import type { AdminMiddleware, RequireAdminCfg } from './adminMiddleware.js'
import { requireAdminPermissions } from './adminMiddleware.js'
import type { BaseAdminService } from './base.admin.service.js'

/**
* @deprecated Use `AuthLoginRedirectMiddlewareCfg` with `secureHeader`.
*/
export interface SecureHeaderMiddlewareCfg extends RequireAdminCfg {
adminService: BaseAdminService

Expand All @@ -22,6 +25,8 @@ export interface SecureHeaderMiddlewareCfg extends RequireAdminCfg {
/**
* Secures the endpoint by requiring a secret header to be present.
* Throws Error401Admin otherwise.
*
* @deprecated Use `AuthService.getLoginRedirectMiddleware` with `secureHeader`.
*/
export function createSecureHeaderMiddleware(cfg: SecureHeaderMiddlewareCfg): AdminMiddleware {
return reqPermissions => requireSecureHeaderOrAdmin(cfg, reqPermissions)
Expand Down
Loading