From fd58b4661fd0928dd461c74a5cb7bc7870ca9692 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 2 Sep 2026 19:58:44 +0700 Subject: [PATCH] feat(auth): tambah OAuth loopback server untuk capture authorization-code redirect --- TODO.md | 10 +- .../packages/infrastructure/src/auth/index.ts | 3 +- .../infrastructure/src/auth/oauth_loopback.ts | 151 ++++++++++++++++++ 3 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 apps/packages/infrastructure/src/auth/oauth_loopback.ts diff --git a/TODO.md b/TODO.md index 6eca64d..3a228ca 100644 --- a/TODO.md +++ b/TODO.md @@ -127,11 +127,11 @@ apps/ ### Fase 3f — Auth infrastructure **Sumber Rust:** `apps/infrastructure/src/auth/{password,jwt,oauth_loopback,mod}.rs` -- [ ] `password.ts` — Argon2 via `@node-rs/argon2` (hash + verify), port `PasswordService` -- [ ] `jwt.ts` — HMAC HS256 via `node:crypto` (access + refresh token, typ, exp), port `TokenService` -- [ ] `oauth_loopback.ts` — server TCP loopback lokal utk terima redirect OAuth; port `oauth_loopback` -- [ ] `mod.ts` — composisi ke `AuthService` -- [ ] Tambah dep `@node-rs/argon2` di `infrastructure/package.json` +- [x] `password.ts` — Argon2 via `@node-rs/argon2` (hash + verify), port `PasswordService` +- [x] `jwt.ts` — HMAC HS256 via `node:crypto` (access + refresh token, typ, exp), port `TokenService` +- [x] `oauth_loopback.ts` — server TCP loopback lokal utk terima redirect OAuth; port `oauth_loopback` +- [x] `mod.ts` (`index.ts`) — composisi ke `AuthService` +- [x] Tambah dep `@node-rs/argon2` di `infrastructure/package.json` ### Fase 3g — IPC + bgbash **Sumber Rust:** `apps/infrastructure/src/ipc/{frame,protocol,server,client,conn}.rs`, `apps/infrastructure/src/bgbash/{job,control}.rs` diff --git a/apps/packages/infrastructure/src/auth/index.ts b/apps/packages/infrastructure/src/auth/index.ts index b371cfb..450823d 100644 --- a/apps/packages/infrastructure/src/auth/index.ts +++ b/apps/packages/infrastructure/src/auth/index.ts @@ -1,7 +1,8 @@ /** - * Auth infrastructure implementations — Argon2 passwords + HS256 JWT. + * Auth infrastructure implementations — Argon2 passwords + HS256 JWT + OAuth loopback. * Mirrors `apps/infrastructure/src/auth/mod.rs`. */ export { Argon2PasswordService } from "./password.ts"; export { Hs256TokenService, createToken, verifyToken } from "./jwt.ts"; export type { JwtClaims } from "./jwt.ts"; +export { LoopbackServer } from "./oauth_loopback.ts"; diff --git a/apps/packages/infrastructure/src/auth/oauth_loopback.ts b/apps/packages/infrastructure/src/auth/oauth_loopback.ts new file mode 100644 index 0000000..85616b7 --- /dev/null +++ b/apps/packages/infrastructure/src/auth/oauth_loopback.ts @@ -0,0 +1,151 @@ +/** + * Minimal loopback HTTP server for capturing OAuth authorization-code redirects. + * Mirrors `apps/infrastructure/src/auth/oauth_loopback.rs`. + * + * Binds to `127.0.0.1:`, exposes a redirect URI, and waits for a + * single callback carrying `?code=...&state=...`. Serves a static confirmation + * page and validates the state to prevent CSRF. + */ +import * as net from "node:net"; + +/** A single-use HTTP listener on 127.0.0.1 that receives the OAuth redirect. */ +export class LoopbackServer { + private server: net.Server | null = null; + private port = 0; + + /** Bind to 127.0.0.1 on an ephemeral port. */ + bind(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (addr && typeof addr === "object") this.port = addr.port; + this.server = server; + resolve(); + }); + }); + } + + /** The redirect URI the browser should be sent to. */ + redirectUri(): string { + return `http://127.0.0.1:${this.port}/callback`; + } + + /** + * Wait for a single authorization-code callback (with timeout). + * Validates the CSRF state and returns the `code` on success. + */ + waitForCode(timeoutMs: number, expectedState: string): Promise { + return new Promise((resolve, reject) => { + const server = this.server; + if (!server) { + reject(new Error("server not bound")); + return; + } + + const timer = setTimeout(() => { + cleanup(); + reject(new Error("OAuth callback timed out")); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timer); + server.removeAllListeners("connection"); + }; + + server.once("connection", (socket) => { + socket.setTimeout(timeoutMs); + let buf = ""; + socket.on("data", (data) => { + buf += data.toString("utf8"); + // Wait for the end of the request headers + if (!buf.includes("\r\n\r\n")) return; + + const result = this.handleRequest(buf, expectedState); + socket.end(result.response); + cleanup(); + + // Give the response a chance to flush before closing. + setTimeout(() => { + if (result.error) reject(result.error); + else resolve(result.code!); + }, 20); + }); + socket.on("error", () => { + cleanup(); + reject(new Error("OAuth socket error")); + }); + }); + }); + } + + /** Close the server and release the port. */ + close(): Promise { + return new Promise((resolve) => { + const server = this.server; + this.server = null; + if (!server) { + resolve(); + return; + } + server.close(() => resolve()); + }); + } + + /** Parse the HTTP request, build the response, and return code/error. */ + private handleRequest(request: string, expectedState: string): { + response: string; + code: string | null; + error: Error | null; + } { + const code = extractQueryParam(request, "code"); + const state = extractQueryParam(request, "state"); + const stateOk = state === expectedState; + + let response: string; + if (code !== null && stateOk) { + response = + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n" + + "Authorization complete. You may close this tab."; + } else if (code !== null && !stateOk) { + response = + "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n" + + "State mismatch — possible CSRF attack."; + } else { + response = + "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n" + + "Missing authorization code."; + } + + if (!stateOk) { + return { response, code: null, error: new Error("state mismatch") }; + } + if (code === null) { + return { response, code: null, error: new Error("code not found in callback") }; + } + return { response, code, error: null }; + } +} + +/** Extract a URL query parameter from an HTTP request line (percent-decoded). */ +function extractQueryParam(request: string, key: string): string | null { + const line = request.split("\r\n")[0] ?? ""; + const parts = line.split(" "); + if (parts.length < 2) return null; + const path = parts[1]!; + const query = path.split("?")[1]; + if (!query) return null; + for (const pair of query.split("&")) { + const eq = pair.indexOf("="); + const k = eq === -1 ? pair : pair.slice(0, eq); + const v = eq === -1 ? "" : pair.slice(eq + 1); + if (k === key) return urlDecode(v); + } + return null; +} + +/** Percent-decode a string (e.g. `%20` -> space). */ +function urlDecode(s: string): string { + return decodeURIComponent(s.replace(/\+/g, "%20")); +}