feat(auth): tambah OAuth loopback server untuk capture authorization-code redirect

This commit is contained in:
asepharyana
2026-09-02 22:50:38 +07:00
parent 7e8a160b0e
commit fd58b4661f
3 changed files with 158 additions and 6 deletions
+5 -5
View File
@@ -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`
@@ -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";
@@ -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:<ephemeral>`, 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<void> {
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<string> {
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<void> {
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"));
}