From fabdbbbd0092e517c485e654f5697f3e36d0c02b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=B3=C3=B0i=20Karlsson?= Date: Mon, 5 Oct 2026 00:05:54 +0200 Subject: [PATCH 1/2] feat: separate admin service into auth and firebase auth --- packages/backend-lib/package.json | 2 + .../backend-lib/src/admin/adminMiddleware.ts | 17 + .../src/admin/base.admin.service.ts | 17 + .../src/admin/firebaseAuth.service.test.ts | 85 +++++ .../src/admin/firebaseAuth.service.ts | 139 ++++++++ packages/backend-lib/src/admin/index.ts | 1 + .../src/admin/secureHeaderMiddleware.ts | 5 + .../backend-lib/src/auth/auth.service.test.ts | 331 ++++++++++++++++++ packages/backend-lib/src/auth/auth.service.ts | 307 ++++++++++++++++ packages/backend-lib/src/auth/index.ts | 1 + 10 files changed, 905 insertions(+) create mode 100644 packages/backend-lib/src/admin/firebaseAuth.service.test.ts create mode 100644 packages/backend-lib/src/admin/firebaseAuth.service.ts create mode 100644 packages/backend-lib/src/auth/auth.service.test.ts create mode 100644 packages/backend-lib/src/auth/auth.service.ts create mode 100644 packages/backend-lib/src/auth/index.ts diff --git a/packages/backend-lib/package.json b/packages/backend-lib/package.json index 4ca39cabc..08f3030aa 100644 --- a/packages/backend-lib/package.json +++ b/packages/backend-lib/package.json @@ -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", diff --git a/packages/backend-lib/src/admin/adminMiddleware.ts b/packages/backend-lib/src/admin/adminMiddleware.ts index 29707287b..321445993 100644 --- a/packages/backend-lib/src/admin/adminMiddleware.ts +++ b/packages/backend-lib/src/admin/adminMiddleware.ts @@ -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' @@ -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 = {}, @@ -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, @@ -97,6 +108,9 @@ interface LoginHtmlCfg { firebaseAuthProvider: string } +/** + * @deprecated Use `loginHtmlHandler`. + */ export function loginHtml(firebaseServiceCfg: FirebaseSharedServiceCfg): BackendRequestHandler { const { apiKey: firebaseApiKey, @@ -124,6 +138,9 @@ const getLoginHtml = _memoized((cfg: LoginHtmlCfg) => { .replaceAll('<%= firebaseAuthProvider %>', cfg.firebaseAuthProvider) }) +/** + * @deprecated Use `AuthService.getLoginRedirectMiddleware`. + */ export function getLoginHtmlRedirect(href: string): string { return ` diff --git a/packages/backend-lib/src/admin/base.admin.service.ts b/packages/backend-lib/src/admin/base.admin.service.ts index c9da4b7a3..742557174 100644 --- a/packages/backend-lib/src/admin/base.admin.service.ts +++ b/packages/backend-lib/src/admin/base.admin.service.ts @@ -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( @@ -309,6 +311,9 @@ const adminInfoDisabled = (): AdminInfo => ({ permissions: [], }) +/** + * @deprecated Use `AuthServiceCfg` and `FirebaseAuthServiceCfg`. + */ export interface AdminServiceCfg { /** * @default 'admin_token' @@ -323,11 +328,17 @@ 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 @@ -335,6 +346,9 @@ export interface ResolvedAdmin { permissions?: Set } +/** + * @deprecated Use `AuthCheckOptions`. + */ export interface CheckPermissionsOptions { /** * false - one granted permission is enough. @@ -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 diff --git a/packages/backend-lib/src/admin/firebaseAuth.service.test.ts b/packages/backend-lib/src/admin/firebaseAuth.service.test.ts new file mode 100644 index 000000000..d17f68aba --- /dev/null +++ b/packages/backend-lib/src/admin/firebaseAuth.service.test.ts @@ -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>(), { + 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>(), { + 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>(), { + 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') + }) +}) diff --git a/packages/backend-lib/src/admin/firebaseAuth.service.ts b/packages/backend-lib/src/admin/firebaseAuth.service.ts new file mode 100644 index 000000000..a6401a259 --- /dev/null +++ b/packages/backend-lib/src/admin/firebaseAuth.service.ts @@ -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, + 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 { + if (!token) return + + const auth = await this.getFirebaseAuth() + try { + const decodedToken = await auth.verifyIdToken(token) + return decodedToken.email + } catch (err) { + if ((err as Partial | undefined)?.hasCode?.('id-token-expired')) return + throw err + } + } + + @_Memo() + private async getFirebaseAuth(): Promise { + 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 + +export interface FirebaseAuthServiceCfg { + /** + * Read by `getToken`, set by `getLoginHandler`. + */ + tokenCookie?: string + + tokenHeader?: string +} + +interface LoginHtmlCfg { + firebaseApiKey: string + firebaseAuthDomain: string + firebaseAuthProvider: string +} diff --git a/packages/backend-lib/src/admin/index.ts b/packages/backend-lib/src/admin/index.ts index 364a5344a..bd7a2ecac 100644 --- a/packages/backend-lib/src/admin/index.ts +++ b/packages/backend-lib/src/admin/index.ts @@ -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' diff --git a/packages/backend-lib/src/admin/secureHeaderMiddleware.ts b/packages/backend-lib/src/admin/secureHeaderMiddleware.ts index eee11f043..0c800b637 100644 --- a/packages/backend-lib/src/admin/secureHeaderMiddleware.ts +++ b/packages/backend-lib/src/admin/secureHeaderMiddleware.ts @@ -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 @@ -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) diff --git a/packages/backend-lib/src/auth/auth.service.test.ts b/packages/backend-lib/src/auth/auth.service.test.ts new file mode 100644 index 000000000..ba48b87ed --- /dev/null +++ b/packages/backend-lib/src/auth/auth.service.test.ts @@ -0,0 +1,331 @@ +import { _expectedError, AssertionError } from '@naturalcycles/js-lib/error' +import { afterAll, describe, expect, test, vi } from 'vitest' +import { getDefaultRouter } from '../express/getDefaultRouter.js' +import { mockBackendRequest } from '../test/mocks.js' +import { expressTestService } from '../testing/index.js' +import type { AuthCheck, ResolvedAuth } from './auth.service.js' +import { AuthService } from './auth.service.js' + +const middlewareAuthService = new AuthService({ + authenticate: async req => req.header('x-subject'), + getPermissions: async subject => (subject === 'p1@mail.com' ? ['p1'] : undefined), +}) + +const storedAuthService = new AuthService({ + authenticate: async () => undefined, + getPermissions: async () => undefined, + onCheck: vi.fn<(check: AuthCheck, ctx: string | undefined) => void>(), +}) +const storedResolved: ResolvedAuth = { subject: 'stored@mail.com', permissions: ['p1'] } + +const resource = getDefaultRouter() +resource.get('/json', middlewareAuthService.getMiddleware()(['p1']), (_req, res) => { + res.json({ ok: true }) +}) +resource.get( + '/login', + middlewareAuthService.getLoginRedirectMiddleware({ loginHtmlPath: '/login.html' })(['p1']), + (_req, res) => { + res.json({ ok: true }) + }, +) +resource.get( + '/secure', + middlewareAuthService.getMiddleware({ + secureHeader: { name: 'x-secure-header', value: 'secret1' }, + })(['p1']), + (_req, res) => { + res.json({ ok: true }) + }, +) +resource.get( + '/stored', + storedAuthService.getMiddleware({ + getResolved: () => storedResolved, + getCtx: () => 'ctx1', + })(['p1']), + (_req, res) => { + res.json({ ok: true }) + }, +) + +const app = await expressTestService.createAppFromResource(resource) + +afterAll(async () => { + await app.close() +}) + +describe('resolve', () => { + test('should resolve to empty object if there is no subject', async () => { + const authService = new AuthService({ + authenticate: async () => undefined, + getPermissions: vi.fn<() => Promise>(), + }) + + const resolved = await authService.resolve(mockBackendRequest()) + + expect(resolved).toEqual({}) + expect(authService.cfg.getPermissions).not.toHaveBeenCalled() + }) + + test('should resolve the subject and its permissions', async () => { + const authService = new AuthService({ + authenticate: async () => 'p1@mail.com', + getPermissions: async () => ['p1'], + }) + + const resolved = await authService.resolve(mockBackendRequest()) + + expect(resolved).toEqual({ + subject: 'p1@mail.com', + permissions: ['p1'], + alwaysAuthorized: false, + }) + }) + + test('should store the result of isAlwaysAuthorized', async () => { + const authService = new AuthService({ + authenticate: async () => 'p1@mail.com', + getPermissions: async () => ['p1'], + isAlwaysAuthorized: async subject => subject === 'p1@mail.com', + }) + + const resolved = await authService.resolve(mockBackendRequest()) + + expect(resolved.alwaysAuthorized).toBe(true) + }) +}) + +describe('require', () => { + test('should return the granted permissions on AND-comparison', () => { + const authService = new AuthService({ + authenticate: async () => undefined, + getPermissions: async () => undefined, + }) + const resolved = { subject: 'p1p2@mail.com', permissions: ['p1', 'p2'] } + + const permissionInfo = authService.require(resolved, ['p1']) + + expect(permissionInfo).toEqual({ subject: 'p1p2@mail.com', permissions: ['p1'] }) + }) + + test('should return only the granted permissions on OR-comparison', () => { + const authService = new AuthService({ + authenticate: async () => undefined, + getPermissions: async () => undefined, + }) + const resolved = { subject: 'p1@mail.com', permissions: ['p1'] } + + const permissionInfo = authService.require(resolved, ['p1', 'p2'], { andComparison: false }) + + expect(permissionInfo).toEqual({ subject: 'p1@mail.com', permissions: ['p1'] }) + }) + + test('should throw 401 if there is no subject', () => { + const authService = new AuthService({ + authenticate: async () => undefined, + getPermissions: async () => undefined, + }) + + const err = _expectedError(() => authService.require({}, ['p1']), AssertionError) + + expect(err.data).toMatchObject({ authRequired: true, backendResponseStatusCode: 401 }) + }) + + test('should throw 403 with the subject if not granted', () => { + const authService = new AuthService({ + authenticate: async () => undefined, + getPermissions: async () => undefined, + }) + const resolved = { subject: 'p1@mail.com', permissions: ['p1'] } + + const err = _expectedError(() => authService.require(resolved, ['p1', 'p2']), AssertionError) + + expect(err.data).toMatchObject({ + permissionsRequired: ['p1', 'p2'], + subject: 'p1@mail.com', + backendResponseStatusCode: 403, + }) + }) + + test('should grant and return all real permissions if alwaysAuthorized', () => { + const authService = new AuthService({ + authenticate: async () => undefined, + getPermissions: async () => undefined, + }) + const resolved = { + subject: 'p1@mail.com', + permissions: ['p1'], + alwaysAuthorized: true, + } + + const permissionInfo = authService.require(resolved, ['p2']) + + expect(permissionInfo).toEqual({ subject: 'p1@mail.com', permissions: ['p1'] }) + }) + + test('should grant if auth is disabled at runtime', () => { + const authService = new AuthService({ + authenticate: async () => undefined, + getPermissions: async () => undefined, + }) + + authService.cfg.authEnabled = false + const permissionInfo = authService.require({}, ['p1']) + authService.cfg.authEnabled = true + const err = _expectedError(() => authService.require({}, ['p1']), AssertionError) + + expect(permissionInfo).toEqual({ subject: 'authDisabled', permissions: [] }) + expect(err.data['backendResponseStatusCode']).toBe(401) + }) +}) + +describe('onCheck', () => { + test('should be called with ctx and meta', () => { + const authService = new AuthService({ + authenticate: async () => undefined, + getPermissions: async () => undefined, + onCheck: vi.fn<(check: AuthCheck, ctx: string | undefined) => void>(), + }) + const resolved = { subject: 'p1@mail.com', permissions: ['p1'] } + + authService.require(resolved, ['p1', 'p2'], { + andComparison: false, + ctx: 'ctx1', + meta: { recordId: 'r1' }, + }) + + expect(authService.cfg.onCheck).toHaveBeenCalledExactlyOnceWith( + { + subject: 'p1@mail.com', + granted: true, + alwaysAuthorized: false, + reqPermissions: ['p1', 'p2'], + checkedPermissions: ['p1'], + meta: { recordId: 'r1' }, + }, + 'ctx1', + ) + }) + + test('should be called with reqPermissions as checked if denied', () => { + const authService = new AuthService({ + authenticate: async () => undefined, + getPermissions: async () => undefined, + onCheck: vi.fn<(check: AuthCheck, ctx: undefined) => void>(), + }) + const resolved = { + subject: 'p1@mail.com', + permissions: ['p1'], + alwaysAuthorized: false, + } + + _expectedError(() => authService.require(resolved, ['p1', 'p2']), AssertionError) + + expect(authService.cfg.onCheck).toHaveBeenCalledExactlyOnceWith( + { + subject: 'p1@mail.com', + granted: false, + alwaysAuthorized: false, + reqPermissions: ['p1', 'p2'], + checkedPermissions: ['p1', 'p2'], + meta: {}, + }, + undefined, + ) + }) + + test('should not be called without a subject or when auth is disabled', () => { + const authService = new AuthService({ + authenticate: async () => undefined, + getPermissions: async () => undefined, + onCheck: vi.fn<(check: AuthCheck, ctx: undefined) => void>(), + }) + const resolved = { subject: 'p1@mail.com', permissions: ['p1'] } + + _expectedError(() => authService.require({}, ['p1']), AssertionError) + authService.cfg.authEnabled = false + authService.require(resolved, ['p1']) + + expect(authService.cfg.onCheck).not.toHaveBeenCalled() + }) +}) + +describe('middlewares', () => { + test('should respond JSON 401 if not authenticated', async () => { + const err = await app.expectError({ url: 'json' }) + + expect(err.data.responseStatusCode).toBe(401) + expect(err.cause.data).toMatchObject({ authRequired: true }) + }) + + test('should respond 401 with the login redirect if not authenticated', async () => { + const { statusCode, body } = await app.doFetch({ url: 'login', responseType: 'text' }) + + expect(statusCode).toBe(401) + expect(body).toContain('/login.html?autoLogin=1&returnUrl=') + }) + + test('should respond 403 with the subject if not granted', async () => { + const err = await app.expectError({ + url: 'json', + headers: { 'x-subject': 'nobody@mail.com' }, + }) + + expect(err.data.responseStatusCode).toBe(403) + expect(err.cause.data).toMatchObject({ subject: 'nobody@mail.com' }) + }) + + test('should pass if granted', async () => { + const body = await app.get('json', { headers: { 'x-subject': 'p1@mail.com' } }) + + expect(body).toEqual({ ok: true }) + }) + + test('should pass if the secure header matches', async () => { + const body = await app.get('secure', { headers: { 'x-secure-header': 'secret1' } }) + + expect(body).toEqual({ ok: true }) + }) + + test('should respond 401 if the secure header is wrong', async () => { + const err = await app.expectError({ + url: 'secure', + headers: { 'x-secure-header': 'wrong', 'x-subject': 'p1@mail.com' }, + }) + + expect(err.data.responseStatusCode).toBe(401) + expect(err.cause.data).toMatchObject({ authRequired: true }) + }) + + test('should fall back to the auth check if the secure header is missing', async () => { + const body = await app.get('secure', { headers: { 'x-subject': 'p1@mail.com' } }) + const err = await app.expectError({ url: 'secure' }) + + expect(body).toEqual({ ok: true }) + expect(err.data.responseStatusCode).toBe(401) + }) + + test('should use getResolved and getCtx', async () => { + const body = await app.get('stored') + + expect(body).toEqual({ ok: true }) + expect(storedAuthService.cfg.onCheck).toHaveBeenCalledWith( + expect.objectContaining({ subject: 'stored@mail.com', granted: true }), + 'ctx1', + ) + }) + + test('should pass if auth is disabled, before looking at the secure header', async () => { + middlewareAuthService.cfg.authEnabled = false + try { + const body = await app.get('json') + const secureBody = await app.get('secure', { headers: { 'x-secure-header': 'wrong' } }) + + expect(body).toEqual({ ok: true }) + expect(secureBody).toEqual({ ok: true }) + } finally { + middlewareAuthService.cfg.authEnabled = true + } + }) +}) diff --git a/packages/backend-lib/src/auth/auth.service.ts b/packages/backend-lib/src/auth/auth.service.ts new file mode 100644 index 000000000..5e2bd1bcc --- /dev/null +++ b/packages/backend-lib/src/auth/auth.service.ts @@ -0,0 +1,307 @@ +import { _assert, AppError } from '@naturalcycles/js-lib/error' +import type { ErrorData } from '@naturalcycles/js-lib/error' +import type { AnyObject, Promisable } from '@naturalcycles/js-lib/types' +import { timingSafeStringEqual } from '@naturalcycles/nodejs-lib' +import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js' + +/** + * Authenticates requests and checks permissions. + */ +export class AuthService { + constructor(cfg: AuthServiceCfg) { + this.cfg = { + authEnabled: true, + ...cfg, + } + } + + cfg: AuthServiceCfg & { authEnabled: boolean } + + /** + * Responds JSON 401 / 403. + */ + getMiddleware(cfg: AuthMiddlewareCfg = {}): AuthMiddleware { + return reqPermissions => this.createHandler(cfg, reqPermissions) + } + + /** + * Like `getMiddleware`, but 401 redirects to the login page. + */ + getLoginRedirectMiddleware(cfg: AuthLoginRedirectMiddlewareCfg): AuthMiddleware { + const loginHref = this.getLoginHref(cfg) + return reqPermissions => this.createHandler(cfg, reqPermissions, loginHref) + } + + private createHandler( + cfg: AuthMiddlewareCfg, + reqPermissions: string[] = [], + loginHref?: string, + ): BackendRequestHandler { + const { + getResolved = req => this.resolve(req), + getCtx, + andComparison = true, + secureHeader, + } = cfg + + return async (req, res, next) => { + if (!this.cfg.authEnabled) return next() + + if (secureHeader) { + const providedHeader = req.get(secureHeader.name) + if (providedHeader) { + if (timingSafeStringEqual(providedHeader, secureHeader.value)) { + return next() + } + + return next( + new AppError('secureHeader or authentication required', this.authRequiredData()), + ) + } + } + + try { + const resolved = await getResolved(req) + if (!resolved?.subject && loginHref) { + res.status(401).send(this.getLoginHtmlRedirect(loginHref)) + return + } + + this.require(resolved, reqPermissions, { andComparison, ctx: getCtx?.(req) }) + return next() + } catch (err) { + return next(err) + } + } + } + + /** + * Runs `authenticate`, then `getPermissions` and `isAlwaysAuthorized` if there's a subject. + * Ignores `authEnabled`. + */ + async resolve(req: BackendRequest): Promise { + const { authenticate, getPermissions, isAlwaysAuthorized } = this.cfg + + const subject = await authenticate(req) + if (!subject) return {} + + const permissions = await getPermissions(subject, req) + const alwaysAuthorized = isAlwaysAuthorized + ? await isAlwaysAuthorized(subject, permissions, req) + : false + + return { subject, permissions, alwaysAuthorized } + } + + /** + * Throws 401 if there's no subject, 403 if not granted. + * Returns the granted permissions (all of the subject's if alwaysAuthorized), + * or `{ subject: 'authDisabled', permissions: [] }` if auth is disabled. + */ + require( + resolved: ResolvedAuth | undefined, + reqPermissions: string[] = [], + opt: AuthCheckOptions = {}, + ): PermissionInfo { + if (!this.cfg.authEnabled) return { subject: 'authDisabled', permissions: [] } + + this.assertAuthenticated(resolved) + const { subject, permissions, alwaysAuthorized } = resolved + + const grantedPermissions = this.checkSubject(resolved, reqPermissions, opt) + this.assertGranted(subject, grantedPermissions, reqPermissions) + + if (alwaysAuthorized) { + return { subject, permissions: permissions?.slice() || [] } + } + + return { subject, permissions: grantedPermissions.slice() } + } + + private checkSubject( + resolved: AuthenticatedAuth, + reqPermissions: string[], + opt: AuthCheckOptions, + ): string[] | undefined { + const { subject, permissions, alwaysAuthorized = false } = resolved + const { andComparison = true, ctx, meta = {} } = opt + + const grantedPermissions = alwaysAuthorized + ? reqPermissions + : this.getGrantedPermissions(permissions, reqPermissions, andComparison) + + this.cfg.onCheck?.( + { + subject, + granted: !!grantedPermissions, + alwaysAuthorized, + reqPermissions, + checkedPermissions: grantedPermissions || reqPermissions, + meta, + }, + ctx, + ) + + return grantedPermissions + } + + private assertAuthenticated( + resolved: ResolvedAuth | undefined, + ): asserts resolved is AuthenticatedAuth { + _assert(resolved?.subject, 'authentication required', this.authRequiredData()) + } + + private authRequiredData(): ErrorData { + return { + authRequired: true, + backendResponseStatusCode: 401, + userFriendly: true, + } + } + + private assertGranted( + subject: string, + grantedPermissions: string[] | undefined, + reqPermissions: string[], + ): asserts grantedPermissions is string[] { + _assert(grantedPermissions, `Permissions required: [${reqPermissions.join(', ')}]`, { + permissionsRequired: reqPermissions, + subject, + backendResponseStatusCode: 403, + userFriendly: true, + }) + } + + /** + * undefined - not granted. + */ + private getGrantedPermissions( + permissions: string[] | undefined, + reqPermissions: string[], + andComparison: boolean, + ): string[] | undefined { + if (!permissions) return + const grantedPermissions = reqPermissions.filter(p => permissions.includes(p)) + const granted = andComparison + ? grantedPermissions.length === reqPermissions.length + : grantedPermissions.length > 0 + return granted ? grantedPermissions : undefined + } + + private getLoginHref(cfg: AuthLoginRedirectMiddlewareCfg): string { + return `${cfg.loginHtmlPath}?autoLogin=1&returnUrl=\${encodeURIComponent(location.href)}` + } + + private getLoginHtmlRedirect(href: string): string { + return ` + +401 Authentication Required + + + +` + } +} + +export interface AuthServiceCfg { + /** + * Returns the subject, or undefined if not authenticated. + */ + authenticate: (req: BackendRequest) => Promise + + /** + * undefined - no permission set (never granted). + */ + getPermissions: (subject: string, req: BackendRequest) => Promise + + /** + * true - every check is granted for this subject. + */ + isAlwaysAuthorized?: ( + subject: string, + permissions: string[] | undefined, + req: BackendRequest, + ) => Promisable + + /** + * Called on every `require` check that has a subject, while auth is enabled. Must not throw. + */ + onCheck?: (check: AuthCheck, ctx: CTX | undefined) => void + + /** + * false - every check is granted. For tests / local debugging. Read on every check. + * + * @default true + */ + authEnabled?: boolean +} + +export interface AuthCheck { + subject: string + granted: boolean + alwaysAuthorized: boolean + reqPermissions: string[] + /** The granted permissions, or reqPermissions if denied */ + checkedPermissions: string[] + meta: AnyObject +} + +export interface AuthLoginRedirectMiddlewareCfg extends AuthMiddlewareCfg { + loginHtmlPath: string +} + +export interface AuthMiddlewareCfg { + /** + * Defaults to `resolve(req)`. + */ + getResolved?: (req: BackendRequest) => Promisable + getCtx?: (req: BackendRequest) => CTX | undefined + /** + * @default true + */ + andComparison?: boolean + /** + * A request with this header passes without auth. A wrong value responds 401. + */ + secureHeader?: SecureHeaderCfg +} + +export interface SecureHeaderCfg { + /** e.g. 'Authorization' */ + name: string + value: string +} + +export type AuthMiddleware = (reqPermissions?: string[]) => BackendRequestHandler + +export interface AuthCheckOptions { + /** + * false - one granted permission is enough. + * + * @default true + */ + andComparison?: boolean + /** Passed to onCheck */ + ctx?: CTX + /** Passed to onCheck */ + meta?: AnyObject +} + +export interface PermissionInfo { + subject: string + permissions: string[] +} + +type AuthenticatedAuth = ResolvedAuth & { subject: string } + +export interface ResolvedAuth { + /** undefined - not authenticated */ + subject?: string + /** undefined - no permission set */ + permissions?: string[] + /** Result of `isAlwaysAuthorized` */ + alwaysAuthorized?: boolean +} diff --git a/packages/backend-lib/src/auth/index.ts b/packages/backend-lib/src/auth/index.ts new file mode 100644 index 000000000..9bf99b778 --- /dev/null +++ b/packages/backend-lib/src/auth/index.ts @@ -0,0 +1 @@ +export * from './auth.service.js' From 572557a5a2eb1298ab78c911bc40c6e5b39ddfe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=B3=C3=B0i=20Karlsson?= Date: Mon, 5 Oct 2026 00:44:12 +0200 Subject: [PATCH 2/2] fix: merge meta and ctx --- .../backend-lib/src/auth/auth.service.test.ts | 26 +++++++++---------- packages/backend-lib/src/auth/auth.service.ts | 16 +++++------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/packages/backend-lib/src/auth/auth.service.test.ts b/packages/backend-lib/src/auth/auth.service.test.ts index ba48b87ed..0d8575050 100644 --- a/packages/backend-lib/src/auth/auth.service.test.ts +++ b/packages/backend-lib/src/auth/auth.service.test.ts @@ -1,4 +1,5 @@ import { _expectedError, AssertionError } from '@naturalcycles/js-lib/error' +import type { AnyObject } from '@naturalcycles/js-lib/types' import { afterAll, describe, expect, test, vi } from 'vitest' import { getDefaultRouter } from '../express/getDefaultRouter.js' import { mockBackendRequest } from '../test/mocks.js' @@ -11,10 +12,10 @@ const middlewareAuthService = new AuthService({ getPermissions: async subject => (subject === 'p1@mail.com' ? ['p1'] : undefined), }) -const storedAuthService = new AuthService({ +const storedAuthService = new AuthService<{ requestId: string }>({ authenticate: async () => undefined, getPermissions: async () => undefined, - onCheck: vi.fn<(check: AuthCheck, ctx: string | undefined) => void>(), + onCheck: vi.fn<(check: AuthCheck, ctx: { requestId: string } | undefined) => void>(), }) const storedResolved: ResolvedAuth = { subject: 'stored@mail.com', permissions: ['p1'] } @@ -42,7 +43,7 @@ resource.get( '/stored', storedAuthService.getMiddleware({ getResolved: () => storedResolved, - getCtx: () => 'ctx1', + getCtx: () => ({ requestId: 'r1' }), })(['p1']), (_req, res) => { res.json({ ok: true }) @@ -181,18 +182,17 @@ describe('require', () => { }) describe('onCheck', () => { - test('should be called with ctx and meta', () => { - const authService = new AuthService({ + test('should be called with ctx', () => { + const authService = new AuthService<{ requestId: string }>({ authenticate: async () => undefined, getPermissions: async () => undefined, - onCheck: vi.fn<(check: AuthCheck, ctx: string | undefined) => void>(), + onCheck: vi.fn<(check: AuthCheck, ctx: { requestId: string } | undefined) => void>(), }) const resolved = { subject: 'p1@mail.com', permissions: ['p1'] } authService.require(resolved, ['p1', 'p2'], { andComparison: false, - ctx: 'ctx1', - meta: { recordId: 'r1' }, + ctx: { requestId: 'r1', recordId: 'rec1' }, }) expect(authService.cfg.onCheck).toHaveBeenCalledExactlyOnceWith( @@ -202,9 +202,8 @@ describe('onCheck', () => { alwaysAuthorized: false, reqPermissions: ['p1', 'p2'], checkedPermissions: ['p1'], - meta: { recordId: 'r1' }, }, - 'ctx1', + { requestId: 'r1', recordId: 'rec1' }, ) }) @@ -212,7 +211,7 @@ describe('onCheck', () => { const authService = new AuthService({ authenticate: async () => undefined, getPermissions: async () => undefined, - onCheck: vi.fn<(check: AuthCheck, ctx: undefined) => void>(), + onCheck: vi.fn<(check: AuthCheck, ctx: AnyObject | undefined) => void>(), }) const resolved = { subject: 'p1@mail.com', @@ -229,7 +228,6 @@ describe('onCheck', () => { alwaysAuthorized: false, reqPermissions: ['p1', 'p2'], checkedPermissions: ['p1', 'p2'], - meta: {}, }, undefined, ) @@ -239,7 +237,7 @@ describe('onCheck', () => { const authService = new AuthService({ authenticate: async () => undefined, getPermissions: async () => undefined, - onCheck: vi.fn<(check: AuthCheck, ctx: undefined) => void>(), + onCheck: vi.fn<(check: AuthCheck, ctx: AnyObject | undefined) => void>(), }) const resolved = { subject: 'p1@mail.com', permissions: ['p1'] } @@ -312,7 +310,7 @@ describe('middlewares', () => { expect(body).toEqual({ ok: true }) expect(storedAuthService.cfg.onCheck).toHaveBeenCalledWith( expect.objectContaining({ subject: 'stored@mail.com', granted: true }), - 'ctx1', + { requestId: 'r1' }, ) }) diff --git a/packages/backend-lib/src/auth/auth.service.ts b/packages/backend-lib/src/auth/auth.service.ts index 5e2bd1bcc..1d5fe3684 100644 --- a/packages/backend-lib/src/auth/auth.service.ts +++ b/packages/backend-lib/src/auth/auth.service.ts @@ -7,7 +7,7 @@ import type { BackendRequest, BackendRequestHandler } from '../server/server.mod /** * Authenticates requests and checks permissions. */ -export class AuthService { +export class AuthService { constructor(cfg: AuthServiceCfg) { this.cfg = { authEnabled: true, @@ -124,7 +124,7 @@ export class AuthService { opt: AuthCheckOptions, ): string[] | undefined { const { subject, permissions, alwaysAuthorized = false } = resolved - const { andComparison = true, ctx, meta = {} } = opt + const { andComparison = true, ctx } = opt const grantedPermissions = alwaysAuthorized ? reqPermissions @@ -137,7 +137,6 @@ export class AuthService { alwaysAuthorized, reqPermissions, checkedPermissions: grantedPermissions || reqPermissions, - meta, }, ctx, ) @@ -229,7 +228,7 @@ export interface AuthServiceCfg { /** * Called on every `require` check that has a subject, while auth is enabled. Must not throw. */ - onCheck?: (check: AuthCheck, ctx: CTX | undefined) => void + onCheck?: (check: AuthCheck, ctx: (CTX & AnyObject) | undefined) => void /** * false - every check is granted. For tests / local debugging. Read on every check. @@ -246,7 +245,6 @@ export interface AuthCheck { reqPermissions: string[] /** The granted permissions, or reqPermissions if denied */ checkedPermissions: string[] - meta: AnyObject } export interface AuthLoginRedirectMiddlewareCfg extends AuthMiddlewareCfg { @@ -258,7 +256,7 @@ export interface AuthMiddlewareCfg { * Defaults to `resolve(req)`. */ getResolved?: (req: BackendRequest) => Promisable - getCtx?: (req: BackendRequest) => CTX | undefined + getCtx?: (req: BackendRequest) => (CTX & AnyObject) | undefined /** * @default true */ @@ -284,10 +282,8 @@ export interface AuthCheckOptions { * @default true */ andComparison?: boolean - /** Passed to onCheck */ - ctx?: CTX - /** Passed to onCheck */ - meta?: AnyObject + /** Passed to onCheck: the app's CTX, plus any extra keys */ + ctx?: CTX & AnyObject } export interface PermissionInfo {