Skip to content
Open
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
31 changes: 21 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Generic CLI for `eac.zigbang.in` (E-Accounting / UniDocu).

Drives the UniDocu named-service API directly over HTTP. No headless browser at runtime — auth is just the `JSESSIONID` cookie pulled out of your local Chrome profile.
Drives the UniDocu named-service API directly over HTTP. No headless browser at runtime — auth is just the `JSESSIONID` cookie pulled out of your local browser profile (Chrome by default; Comet/Brave/Edge selectable via `EAC_BROWSER`).

The CLI intentionally stays **domain-agnostic**: it only knows about EAC data and verbs. Company-/personal-specific policy (e.g. *"자기관리비 환급은 영수증 × 70%"*) belongs in your own shell scripts or runbooks.

Expand All @@ -16,21 +16,31 @@ brew install zigbang-smarthome/tap/eac-cli
curl -fsSL https://github.com/zigbang-smarthome/eac-cli/releases/latest/download/install.sh | sh
```

Before first use: open Chrome and log in to `https://eac.zigbang.in` at least once so the session cookie lands in your Chrome cookie store. The CLI reads that cookie each run.
Before first use: open your browser and log in to `https://eac.zigbang.in` at least once so the session cookie lands in your browser's cookie store. The CLI reads that cookie each run.

## Authentication

- EAC uses Google SSO + MS Azure AD SAML — login itself can't be automated. The CLI piggybacks on your normal Chrome session.
- Each run, `eac` copies Chrome's cookie DB to tmpdir, decrypts the `JSESSIONID` for `*.zigbang.in` (AES-128-CBC, key from `"Chrome Safe Storage"` Keychain), and pings EAC to confirm the session is alive.
- **If the session is missing or expired**, the CLI opens Chrome at `https://eac.zigbang.in/unidocu/view.do` and prompts:
1. Finish the Google SSO login in Chrome.
2. **Quit Chrome (Cmd+Q)** so the new `JSESSIONID` is flushed to disk. Chrome's cookie monster keeps cookies in memory and writes to the SQLite store on a delayed batch schedule — without an explicit quit the CLI may keep reading a stale value.
- EAC uses Google SSO + MS Azure AD SAML — login itself can't be automated. The CLI piggybacks on your normal browser session.
- Each run, `eac` copies the browser's cookie DB to tmpdir, decrypts the `JSESSIONID` for `*.zigbang.in` (AES-128-CBC, key from the `"<Browser> Safe Storage"` Keychain), and pings EAC to confirm the session is alive.
- **Browser selection** — set `EAC_BROWSER` to choose which Chromium-family browser to read from (default: `chrome`):

| `EAC_BROWSER` | Browser |
| --- | --- |
| `chrome` (default) | Google Chrome |
| `comet` | Comet (Perplexity) |
| `brave` | Brave |
| `edge` | Microsoft Edge |

All share the same cookie format on macOS; only the cookie path and Keychain service name differ. Example: `EAC_BROWSER=comet eac voucher list`.
- **If the session is missing or expired**, the CLI opens the selected browser at `https://eac.zigbang.in/unidocu/view.do` and prompts:
1. Finish the Google SSO login in the browser.
2. **Quit the browser (Cmd+Q)** so the new `JSESSIONID` is flushed to disk. Chromium keeps cookies in memory and writes to the SQLite store on a delayed batch schedule — without an explicit quit the CLI may keep reading a stale value.
3. Return to the terminal and hit Enter — the CLI re-reads the fresh cookie and continues.
4. Reopen Chrome normally afterwards.
4. Reopen the browser normally afterwards.

Tip: turn on Chrome → Settings → On startup → *"Continue where you left off"* so the session cookie survives the Cmd+Q cycle. EAC re-login becomes infrequent (only when the SAP-side session truly expires).
Tip: turn on the browser's *"Continue where you left off"* startup option so the session cookie survives the Cmd+Q cycle. EAC re-login becomes infrequent (only when the SAP-side session truly expires).
- macOS may prompt for Keychain access the first time (click *Always Allow*).
- Override: set `EAC_JSESSIONID=<value>` to bypass the keychain entirely and use a cookie sourced elsewhere (e.g. extracted from Playwright/CDP-controlled Chrome). Useful when EAC is open in a non-default Chrome profile or in CI scripts.
- Override: set `EAC_JSESSIONID=<value>` to bypass the keychain entirely and use a cookie sourced elsewhere (e.g. extracted from Playwright/CDP-controlled browser). Useful when EAC is open in a non-default profile or in CI scripts.
- Non-TTY environments (CI, piped scripts) error out instead of prompting — pass `EAC_JSESSIONID` explicitly there.

---
Expand Down Expand Up @@ -186,6 +196,7 @@ eac call ZUNIEFI_4207 --prog DRAFT_0010 --data '{"GRONO":"FI20260000023922"}'

- `config show` — 현재 config JSON 프린트
- `config init` — `~/.config/eac/config.json` 없으면 기본값 생성
- `user.gsber` — 사업영역(business area). **비용센터(`user.kostl`)와 일치**시켜야 한다. EAC는 전표의 차변/대변 사업영역이 다르면 거부한다(`Please input only the same 'Business Area'`, ZUNIEFI_1009). 미설정 시 `K200`으로 fallback하므로, 비용센터가 K300 영역(e.g. `343000`)이면 `gsber: "K300"`을 반드시 설정한다.

---

Expand Down
133 changes: 98 additions & 35 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
/**
* macOS Chrome cookie store → JSESSIONID extraction.
* AES-128-CBC decrypt with key derived from "Chrome Safe Storage" keychain password.
* macOS Chromium cookie store → JSESSIONID extraction.
* AES-128-CBC decrypt with key derived from the browser's "<X> Safe Storage" keychain password.
*
* Chromium-family browsers (Chrome, Comet, Brave, Edge) store cookies identically on macOS;
* only the cookie DB path and the keychain service name differ. Select with EAC_BROWSER
* (default: chrome).
*
* EAC uses Google SSO + MS Azure AD SAML chain. The login itself can't be automated
* (2FA/SSO), so when the session is missing/expired we open Chrome at the EAC URL,
* (2FA/SSO), so when the session is missing/expired we open the browser at the EAC URL,
* wait for the user to finish login, then re-read the cookie.
*/

Expand All @@ -15,50 +19,103 @@ import { copyFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { AuthError } from "./errors.ts";

const CHROME_COOKIES = join(homedir(), "Library/Application Support/Google/Chrome/Default/Cookies");
const KEYCHAIN_SERVICE = "Chrome Safe Storage";
const HOST_PATTERN = "%zigbang.in%";
const COOKIE_NAME = "JSESSIONID";

function getChromeMasterKey(): Buffer {
/**
* A Chromium-family browser on macOS. The crypto (PBKDF2 salt=saltysalt/1003/16B →
* AES-128-CBC, 32-byte hash prefix strip) is identical across all of them — only the
* cookie path and keychain service name differ.
*/
interface BrowserProfile {
/** Display name, also the `open -a` target. */
name: string;
/** Cookies SQLite path. */
cookies: string;
/** Keychain "<X> Safe Storage" service holding the master key. */
keychain: string;
}

const BROWSERS: Record<string, BrowserProfile> = {
chrome: {
name: "Google Chrome",
cookies: join(homedir(), "Library/Application Support/Google/Chrome/Default/Cookies"),
keychain: "Chrome Safe Storage",
},
comet: {
name: "Comet",
cookies: join(homedir(), "Library/Application Support/Comet/Default/Cookies"),
keychain: "Comet Safe Storage",
},
brave: {
name: "Brave Browser",
cookies: join(homedir(), "Library/Application Support/BraveSoftware/Brave-Browser/Default/Cookies"),
keychain: "Brave Safe Storage",
},
edge: {
name: "Microsoft Edge",
cookies: join(homedir(), "Library/Application Support/Microsoft Edge/Default/Cookies"),
keychain: "Microsoft Edge Safe Storage",
},
};

/** Browser selected via EAC_BROWSER (default: chrome). Throws on an unknown value. */
function selectedBrowser(): BrowserProfile {
const key = (process.env.EAC_BROWSER ?? "chrome").toLowerCase();
const b = BROWSERS[key];
if (!b) {
throw new AuthError(
`Unknown EAC_BROWSER="${key}". Supported: ${Object.keys(BROWSERS).join(", ")}.`,
);
}
return b;
}

function getMasterKey(browser: BrowserProfile): Buffer {
let out: string;
try {
out = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -g 2>&1`, { encoding: "utf-8" });
out = execSync(`security find-generic-password -s "${browser.keychain}" -g 2>&1`, { encoding: "utf-8" });
} catch (e: any) {
throw new AuthError(`Chrome Safe Storage keychain read failed: ${e?.message ?? e}`);
throw new AuthError(`${browser.keychain} keychain read failed: ${e?.message ?? e}`);
}
const pw = out.match(/password:\s*"([^"]+)"/)?.[1];
if (!pw) throw new AuthError("Chrome Safe Storage keychain password not found");
if (!pw) throw new AuthError(`${browser.keychain} keychain password not found`);
return pbkdf2Sync(pw, "saltysalt", 1003, 16, "sha1");
}

function decryptV10(encrypted: Buffer, key: Buffer): string {
function decryptCookie(encrypted: Buffer, key: Buffer): string {
if (!encrypted?.length) return "";
if (encrypted.subarray(0, 3).toString("utf-8") !== "v10") return encrypted.toString("utf-8");
const prefix = encrypted.subarray(0, 3).toString("utf-8");
// No version prefix → value is stored unencrypted; return as-is.
if (prefix !== "v10" && prefix !== "v11") return encrypted.toString("utf-8");
const iv = Buffer.from(" ".repeat(16), "utf-8");
const d = createDecipheriv("aes-128-cbc", key, iv);
d.setAutoPadding(false);
const out = Buffer.concat([d.update(encrypted.subarray(3)), d.final()]);
const pad = out[out.length - 1]!;
const unpadded = pad > 0 && pad <= 16 ? out.subarray(0, out.length - pad) : out;
// Chrome v10 encrypted values on macOS prefix 32-byte sha256 hash of (host + name); strip it.
// Chromium v10/v11 values on macOS prefix a 32-byte sha256 hash of (host + name); strip it.
return unpadded.subarray(32).toString("utf-8");
}

export function extractJSESSIONID(): string {
export function extractJSESSIONID(browser: BrowserProfile = selectedBrowser()): string {
const tmp = join(tmpdir(), `eac-${Date.now()}.db`);
copyFileSync(CHROME_COOKIES, tmp);
copyFileSync(browser.cookies, tmp);
try {
const key = getChromeMasterKey();
const key = getMasterKey(browser);
const db = new Database(tmp, { readonly: true });
const row = db
.query<{ encrypted_value: Buffer }, [string, string]>(
"SELECT encrypted_value FROM cookies WHERE host_key LIKE ? AND name = ? LIMIT 1",
)
.get(HOST_PATTERN, COOKIE_NAME);
db.close();
if (!row) throw new AuthError("JSESSIONID not found in Chrome cookies — log in to eac.zigbang.in in Chrome");
return decryptV10(Buffer.from(row.encrypted_value), key);
if (!row) {
throw new AuthError(
`JSESSIONID not found in ${browser.name} cookies — log in to eac.zigbang.in in ${browser.name}`,
);
}
return decryptCookie(Buffer.from(row.encrypted_value), key);
} finally {
try { unlinkSync(tmp); } catch {}
}
Expand All @@ -82,9 +139,9 @@ export async function isSessionAlive(jsessionid: string): Promise<boolean> {
}
}

function openChromeAtEac(): void {
// `open -a "Google Chrome" <url>` reuses an existing Chrome window/profile.
spawn("open", ["-a", "Google Chrome", EAC_URL], { stdio: "ignore", detached: true }).unref();
function openBrowserAtEac(browser: BrowserProfile): void {
// `open -a "<Browser>" <url>` reuses an existing window/profile.
spawn("open", ["-a", browser.name, EAC_URL], { stdio: "ignore", detached: true }).unref();
}

async function waitForEnter(): Promise<void> {
Expand All @@ -93,7 +150,7 @@ async function waitForEnter(): Promise<void> {
process.stderr.write("\n");
}

/** Try to extract the cookie; if missing or expired, open Chrome and wait for the user. */
/** Try to extract the cookie; if missing or expired, open the browser and wait for the user. */
export async function ensureSession(): Promise<string> {
// 1) env override always wins.
const envToken = process.env.EAC_JSESSIONID;
Expand All @@ -102,38 +159,44 @@ export async function ensureSession(): Promise<string> {
throw new AuthError("EAC_JSESSIONID is set but the session is not valid (expired or wrong cookie).");
}

// 2) Try Chrome keychain first. Quietly swallow "cookie not present yet" — that's the
// expected state when the user has never logged in to EAC in Chrome.
const browser = selectedBrowser();

// 2) Try the browser keychain first. Quietly swallow "cookie not present yet" — that's the
// expected state when the user has never logged in to EAC in this browser.
let token = "";
try { token = extractJSESSIONID(); } catch { token = ""; }
try { token = extractJSESSIONID(browser); } catch { token = ""; }
if (token && await isSessionAlive(token)) return token;

// 3) Need a (re-)login. Tell the user, open Chrome, wait, then re-read.
// 3) Need a (re-)login. Tell the user, open the browser, wait, then re-read.
if (!process.stdin.isTTY) {
throw new AuthError("EAC session missing/expired and stdin is not a TTY — log in to eac.zigbang.in in Chrome and re-run, or pass EAC_JSESSIONID.");
throw new AuthError(
`EAC session missing/expired and stdin is not a TTY — log in to eac.zigbang.in in ${browser.name} and re-run, or pass EAC_JSESSIONID.`,
);
}
process.stderr.write(token
? "EAC 세션 만료됨. Chrome에서 다시 로그인 필요.\n"
: "EAC 세션 없음. Chrome에서 로그인 필요.\n");
? `EAC 세션 만료됨. ${browser.name}에서 다시 로그인 필요.\n`
: `EAC 세션 없음. ${browser.name}에서 로그인 필요.\n`);
process.stderr.write([
"",
" 순서:",
" 1. 열린 Chrome에서 SSO 로그인을 끝낸다.",
" 2. Chrome을 한 번 종료(Cmd+Q)한다. ← 디스크에 cookie flush",
` 1. 열린 ${browser.name}에서 SSO 로그인을 끝낸다.`,
` 2. ${browser.name}을 한 번 종료(Cmd+Q)한다. ← 디스크에 cookie flush`,
" (안 끄면 새 JSESSIONID가 메모리에만 남아 CLI가 못 읽는다.)",
" 3. 여기로 돌아와 Enter를 누른다.",
" 4. 작업 끝나면 Chrome 다시 켜서 평소대로 사용.",
` 4. 작업 끝나면 ${browser.name} 다시 켜서 평소대로 사용.`,
"",
" 팁: Chrome 설정 > 시작할 때 > '중단한 곳에서 계속하기'를 켜면,",
` 팁: ${browser.name} 설정 > 시작할 때 > '중단한 곳에서 계속하기'를 켜면,`,
" 종료해도 session cookie가 유지돼서 다음번 EAC 재로그인이 줄어든다.",
"",
].join("\n"));
openChromeAtEac();
openBrowserAtEac(browser);
await waitForEnter();

token = extractJSESSIONID();
token = extractJSESSIONID(browser);
if (!await isSessionAlive(token)) {
throw new AuthError("로그인이 확인되지 않았습니다. Chrome에서 eac.zigbang.in에 정상 로그인했는지 확인 후 다시 시도하세요.");
throw new AuthError(
`로그인이 확인되지 않았습니다. ${browser.name}에서 eac.zigbang.in에 정상 로그인했는지 확인 후 다시 시도하세요.`,
);
}
return token;
}
1 change: 1 addition & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const DEFAULT_CONFIG: EacConfig = {
wfIdText: "YG Park (박영걸)",
kostl: "226020",
kostlText: "Device Engineering",
gsber: "K200", // 사업영역 — 비용센터와 일치시킬 것 (e.g. kostl 343000 → "K300")
wfDept: "0000252100",
wfDeptText: "Service Engineering",
},
Expand Down
6 changes: 3 additions & 3 deletions src/lib/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ export async function computeDefaults(
const r = await callNS(ctx, "ZUNIEFI_4003", PROG_LEGACY, {
BUDAT: budat, BLDAT: bldat, BLART: "KE",
LIFNR: user.pernr, LIFNR_TXT: user.pernrName,
BUPLA: "K100", GSBER: "K200", MWSKZ: "T0",
BUPLA: "K100", GSBER: user.gsber ?? "K200", MWSKZ: "T0",
AKONT: "21020103", ZTERM: "V123",
WRBTR: String(amountWon), WRBTR_SLASH: "", WMWST: "0",
BVTYP: "0001", ZFBDT: "", ZFBDT_SLASH: "",
Expand Down Expand Up @@ -529,7 +529,7 @@ export async function createTempDoc(
const commonFi = {
BUDAT: budat, BLDAT: bldat, BLART: "KE",
LIFNR: user.pernr, LIFNR_TXT: user.pernrName,
BUPLA: "K100", GSBER: "K200", MWSKZ: "T0",
BUPLA: "K100", GSBER: user.gsber ?? "K200", MWSKZ: "T0",
AKONT: "21020103", ZTERM: "V123",
WRBTR: String(amountWon), WRBTR_SLASH: "", WMWST: "0",
BVTYP: "0001", ZFBDT: zfbdt, ZFBDT_SLASH: "",
Expand Down Expand Up @@ -929,7 +929,7 @@ export async function createCorpCardVoucher(
CHARGETOTAL: card.AMOUNT, CHARGETOTAL_Slash: "",
WMWST_READ_ONLY: card.TAX,
BUDAT: p.budat, BLDAT: bldat, BLART: "KE",
BUPLA: "K100", GSBER: "K200",
BUPLA: "K100", GSBER: user.gsber ?? "K200",
WRBTR_SLASH: "", WMWST: card.TAX,
ZFBDT_SLASH: "", EMPTY: "",
SGTXT: p.sgtxt,
Expand Down
6 changes: 6 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export interface UserProfile {
wfIdText: string; // Full display like "YG Park (박영걸)"
kostl: string; // Cost center, e.g. "226020"
kostlText: string; // "Device Engineering"
/**
* Business area (사업영역), e.g. "K200" / "K300". MUST match the cost center —
* EAC rejects vouchers whose debit/credit business areas differ (ZUNIEFI_1009).
* Optional for backward compat; falls back to "K200" when absent.
*/
gsber?: string;
wfDept: string; // Department code
wfDeptText: string; // "Service Engineering"
}
Expand Down