Files
accounted/lib/auth/api-keys.ts
T
Jakob Wennberg b8605aabfc fix(settings): derive API-key scope groups and tool counts from the scope catalogue (#1924)
The API-key settings panel carried a hand-copied list of scope groups that
had drifted to 24 of the 30 scopes in API_KEY_SCOPES: articles:read/write,
companies:write and the three reconciliation scopes were missing, so a key
minted in the dashboard could not call gnubok_create_company, the article
tools, the seven reconciliation tools or the matching v1 endpoints. The
per-scope "N verktyg" counts in the panel and in the API_KEY_SCOPES
descriptions were hand-maintained and wrong (reports:read said 18, actual
30; bookkeeping:write said 11, actual 22).

- Move the pure scope catalogue (API_KEY_SCOPES, scope lists, SCOPE_GROUPS,
  TOOL_SCOPE_MAP) into lib/auth/scope-catalog.ts with no server imports, so
  the client-side panel can bundle it. api-keys.ts re-exports everything,
  so existing imports are unchanged.
- SCOPE_GROUPS becomes a list of { domain, label, scopes } covering every
  scope (reconciliation has three), shared by the panel and the OAuth
  consent page. scopeKind() replaces the ad hoc suffix checks.
- TOOL_COUNT_BY_SCOPE is derived from TOOL_SCOPE_MAP at module load; the
  hand-written counts are removed from the catalogue descriptions.
- The panel renders groups and cards from the catalogue; i18n keys are
  derived from domain and scope id. The "(REST API)" heading suffix is
  computed from the counts instead of baked into the labels.
- New unit test asserts every scope belongs to exactly one group and that
  counts equal TOOL_SCOPE_MAP occurrences.
- New sv/en strings for the articles, companies:write and reconciliation
  scopes.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:35:42 +02:00

227 lines
8.2 KiB
TypeScript

import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceRoleClient } from '@/lib/supabase/service-client'
// Not lib/company/context: that module imports next/headers for the legacy
// company cookie, and this file is reachable from bundles where that import
// is a build error.
import { getActiveCompanyId } from '@/lib/company/active-company'
const KEY_PREFIX = 'gnubok_sk_'
const REFRESH_TOKEN_PREFIX = 'gnubok_rt_'
// ── API Key Scopes ──────────────────────────────────────────
// The catalogue lives in scope-catalog.ts (no server imports, safe for client
// bundles) and is re-exported here so existing imports keep working.
export {
API_KEY_SCOPES,
ALL_SCOPES,
DEFAULT_SCOPES,
DEFAULT_OAUTH_SCOPES,
PUBLIC_OAUTH_METADATA_SCOPES,
STAGING_SCOPES,
findStageApproveConflict,
SCOPE_GROUPS,
scopeKind,
TOOL_SCOPE_MAP,
TOOL_COUNT_BY_SCOPE,
} from './scope-catalog'
export type { ApiKeyScope, ScopeGroup } from './scope-catalog'
import { API_KEY_SCOPES, DEFAULT_SCOPES, type ApiKeyScope } from './scope-catalog'
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
if (scopes === null || scopes === undefined) return null
if (!Array.isArray(scopes)) return null
const valid = scopes.filter((s): s is ApiKeyScope => s in API_KEY_SCOPES)
return valid.length > 0 ? valid : null
}
/**
* Create a Supabase service client that doesn't require cookies.
* Used for API key validation (MCP, webhooks) where there's no browser session.
*/
export function createServiceClientNoCookies() {
return createServiceRoleClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
}
export function generateApiKey(mode: ApiKeyMode = 'live'): { key: string; hash: string; prefix: string } {
const random = crypto.randomBytes(32).toString('base64url')
// Test keys carry an explicit `test_` infix so integrators can tell at a
// glance which environment a key targets (matches the llms.txt contract:
// `gnubok_sk_test_<random>`). The infix is purely cosmetic: the authoritative
// mode is the `mode` column on api_keys, read back by hash in validateApiKey,
// so nothing trusts the key string. Both variants keep the `gnubok_sk_`
// prefix so the `startsWith(KEY_PREFIX)` check in validateApiKey still holds.
const key = mode === 'test' ? `${KEY_PREFIX}test_${random}` : `${KEY_PREFIX}${random}`
const hash = hashApiKey(key)
// First 18 chars: 'gnubok_sk_test_xyz' for test keys, 'gnubok_sk_xxxxxxxx'
// for live: the stored prefix is what the settings UI shows, so the test_
// infix is visible in the key list without exposing the secret.
const prefix = key.slice(0, KEY_PREFIX.length + 8)
return { key, hash, prefix }
}
/**
* SHA-256, deliberately, and NOT a slow KDF like bcrypt/argon2.
*
* CodeQL flags this as js/insufficient-password-hash. That rule exists for
* user-chosen passwords, which are low-entropy and brute-forceable, so the
* defence is to make each guess expensive. This input is not a password: keys
* come from generateApiKey as 32 CSPRNG bytes (`gnubok_sk_<base64url>`), and no
* work factor moves the needle on a 256-bit random secret.
*
* A slow KDF would also be actively worse here: this runs on the hot path of
* every MCP request, where the hash is the primary-key lookup used to find the
* row, so per-request cost is real latency for zero security gain.
*
* Do NOT "fix" this by changing the algorithm. The hash IS the stored
* credential, so a different function invalidates every live `gnubok_sk_` key,
* breaking existing MCP connections with no migration path.
*/
export function hashApiKey(key: string): string {
return crypto.createHash('sha256').update(key).digest('hex')
}
export function generateRefreshToken(): { token: string; hash: string } {
const random = crypto.randomBytes(32).toString('base64url')
const token = `${REFRESH_TOKEN_PREFIX}${random}`
const hash = crypto.createHash('sha256').update(token).digest('hex')
return { token, hash }
}
export function hashRefreshToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex')
}
export function isRefreshToken(token: string): boolean {
return token.startsWith(REFRESH_TOKEN_PREFIX)
}
export function extractBearerToken(request: Request): string | null {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) return null
return authHeader.slice(7)
}
/**
* Validate an API key and enforce rate limiting.
* Uses the DB RPC for atomic check + increment.
* Returns the user_id, company_id, api_key_id, name, and effective scopes on
* success, or an error with HTTP status.
* null scopes in DB → DEFAULT_SCOPES (read-only).
*
* api_key_id and api_key_name are returned so callers (e.g. the MCP server)
* can record actor attribution on pending_operations and audit_log.
* They may be undefined when the deployed DB hasn't yet run the migration
* that adds them to the RPC return shape.
*/
/**
* Operating mode of the API key. 'live' keys see real company data; 'test' keys
* are bound to deterministic sandbox companies. Keys created before the Phase 1
* migration default to 'live' for backwards compatibility.
*/
export type ApiKeyMode = 'live' | 'test'
export async function validateApiKey(
key: string
): Promise<
| {
userId: string
/**
* The key's default company. null only while the key's user has no
* company at all (minted from the OAuth popup before onboarding, issue
* #1814): the first validation after a company exists binds the key.
*/
companyId: string | null
apiKeyId?: string
apiKeyName?: string
scopes: ApiKeyScope[]
mode: ApiKeyMode
}
| { error: string; status: number }
> {
if (isRefreshToken(key)) {
return {
error: 'Refresh token cannot be used as access token; exchange it at /api/mcp-oauth/token',
status: 401,
}
}
if (!key.startsWith(KEY_PREFIX)) {
return { error: 'Invalid API key format', status: 401 }
}
const hash = hashApiKey(key)
const supabase = createServiceClientNoCookies()
const { data, error } = await supabase.rpc('validate_and_increment_api_key', {
p_key_hash: hash,
})
if (error || !data || data.length === 0) {
return { error: 'Invalid API key', status: 401 }
}
const row = data[0]
if (row.rate_limited) {
return { error: 'Rate limit exceeded', status: 429 }
}
const companyId: string | null =
row.company_id ?? (await bindUnboundKey(supabase, row.user_id, row.api_key_id))
return {
userId: row.user_id,
companyId,
apiKeyId: row.api_key_id,
apiKeyName: row.api_key_name,
scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES,
// `mode` may be undefined when the deployed DB hasn't yet run the Phase 1
// migration that adds it to the RPC return. Default to 'live' so existing
// keys behave unchanged.
mode: (row.mode === 'test' ? 'test' : 'live') as ApiKeyMode,
}
}
/**
* Late binding for keys minted before the user's first company existed.
*
* The OAuth token endpoint stores company_id NULL for such keys. Company
* creation happens in the web app (a Server Action) which knows nothing about
* the user's keys, so the binding is healed here, on the first validation after
* a company exists: one place, regardless of how the company was created.
* Returns null while the user still has no company. The UPDATE is best-effort:
* a failed write only means the next call resolves again.
*/
async function bindUnboundKey(
supabase: SupabaseClient,
userId: string,
apiKeyId: string | undefined
): Promise<string | null> {
let companyId: string | null
try {
companyId = await getActiveCompanyId(supabase, userId)
} catch {
return null
}
if (!companyId) return null
if (apiKeyId) {
await supabase
.from('api_keys')
.update({ company_id: companyId })
.eq('id', apiKeyId)
.is('company_id', null)
}
return companyId
}
/**
* Check if a given scope is allowed by the key's scopes.
*/
export function hasScope(keyScopes: ApiKeyScope[], required: ApiKeyScope): boolean {
return keyScopes.includes(required)
}