import { string } from 'alga-js' import fetchHelper from '../../utils/fetchHelper' import { AUTO_FINALIZE_SECRET } from '../../utils/autoFinalizeSecret' /** * Privileged read for /api/idempiere-auth/login auto-finalize ONLY. * * Returns just the two ad_user defaults the auto-finalize flow needs to * pick the right org / warehouse from a multi-record list: * { adOrgId, mWarehouseId } * * SECURITY: * - Gated by an in-memory secret (autoFinalizeSecret.ts) generated per * process via crypto.randomBytes(32). The login handler in this same * Node process is the only thing that knows the value — it's never * logged, never sent to the client, never persisted. External callers * can't hit this endpoint. * - Uses the iDempiere service token (config.api.idempieretoken). The * blast radius is bounded: this endpoint never returns the full * ad_user record, never returns the password, never returns anything * beyond the two FK ids. * - Never call this from anywhere else. If you need ad_user data for a * different reason, write a different endpoint with its own scope. * * (We considered also checking the password against ad_user.Password as * defense in depth, but: anyone with valid credentials can call /login * normally and get strictly more data, so the extra check wouldn't * protect any privilege escalation that the in-memory secret doesn't * already block. Skipped to keep the surface small.) */ const extractFkId = (raw: any): number => { if (raw == null) return 0 if (typeof raw === 'number') return raw if (typeof raw === 'string') { const n = Number(raw); return Number.isFinite(n) ? n : 0 } if (typeof raw === 'object') { const id = raw.id ?? raw.ID if (typeof id === 'number') return id if (typeof id === 'string') { const n = Number(id); return Number.isFinite(n) ? n : 0 } } return 0 } const pickField = (rec: any, names: string[]): any => { if (!rec || typeof rec !== 'object') return null for (const n of names) if (rec[n] != null) return rec[n] return null } export default defineEventHandler(async (event) => { const trace: any[] = [] const log = (step: string, info?: any) => { const entry = info === undefined ? { step } : { step, ...info } trace.push(entry) console.log('[auto-defaults]', JSON.stringify(entry)) } // Gate: only the in-process login handler knows the secret. const provided = getHeader(event, 'x-auto-finalize-secret') if (!provided || provided !== AUTO_FINALIZE_SECRET) { throw createError({ statusCode: 403, statusMessage: 'Forbidden' }) } const config = useRuntimeConfig() const serviceToken: string | undefined = (config.api as any)?.idempieretoken const body = await readBody(event).catch(() => ({} as any)) const userName = String(body?.userName ?? '').trim() const clientId = body?.clientId // Optional caller token (e.g. the just-issued resToken.token from /login). const callerToken = String(body?.callerToken ?? '').trim() || null log('input', { userName, clientId, hasCallerToken: !!callerToken, hasServiceToken: !!serviceToken }) if (!userName || !clientId) { log('missing-input') return { adOrgId: 0, mWarehouseId: 0, _trace: trace } } const safe = userName.replace(/'/g, "''") const cIdNum = Number(clientId) // iDempiere REST filter fields are lowercase-first (same convention as // writes — see memory: writes require lowercase-first, PascalCase rejected). // Try `name`, then `value` (login id), then `eMail`. const filters = [`name eq '${safe}'`, `value eq '${safe}'`, `eMail eq '${safe}'`] // Tokens to try in order: caller (user) token first, then service token. const tokens: { tk: string; kind: string }[] = [] if (callerToken) tokens.push({ tk: callerToken, kind: 'user' }) if (serviceToken && serviceToken !== callerToken) tokens.push({ tk: serviceToken, kind: 'service' }) if (!tokens.length) { log('no-token-available') return { adOrgId: 0, mWarehouseId: 0, _trace: trace } } for (const { tk, kind } of tokens) { for (const filter of filters) { // No $select — some iDempiere builds reject it (commas aren't always // url-encoded properly and / or the option isn't supported). The // function still only ever returns the two FK ids from the response, // so the wire format being wider doesn't widen what this endpoint // exposes to callers. const url = `models/ad_user?$filter=${string.urlEncode(filter)}&$top=10` const res: any = await fetchHelper(event, url, 'GET', tk, null).catch((e: any) => { log('fetch-failed', { kind, filter, status: e?.status, message: e?.message ?? String(e) }) return null }) const records: any[] | null = Array.isArray(res?.records) ? res.records : Array.isArray(res) ? res : null log('try', { kind, filter, count: records?.length ?? 0, // Help spot whether iDempiere returned the field shape we expect. firstRecordKeys: records?.[0] ? Object.keys(records[0]).slice(0, 12) : null, }) if (!records?.length) continue const match = records.find((u: any) => { const cId = (u?.AD_Client_ID && typeof u.AD_Client_ID === 'object') ? u.AD_Client_ID.id : u?.AD_Client_ID return Number(cId) === cIdNum }) ?? records[0] const result = { adOrgId: extractFkId(pickField(match, ['AD_Org_ID', 'ad_org_id', 'AD_ORG_ID', 'adOrgId'])), mWarehouseId: extractFkId(pickField(match, ['M_Warehouse_ID', 'm_warehouse_id', 'M_WAREHOUSE_ID', 'mWarehouseId'])), } log('resolved', { kind, ...result, matchedRecordId: match?.id }) return { ...result, _trace: trace } } } log('no-record-found') return { adOrgId: 0, mWarehouseId: 0, _trace: trace } })