feat(auth): tambah OAuth loopback server untuk capture authorization-code redirect
This commit is contained in:
@@ -127,11 +127,11 @@ apps/
|
|||||||
### Fase 3f — Auth infrastructure
|
### Fase 3f — Auth infrastructure
|
||||||
**Sumber Rust:** `apps/infrastructure/src/auth/{password,jwt,oauth_loopback,mod}.rs`
|
**Sumber Rust:** `apps/infrastructure/src/auth/{password,jwt,oauth_loopback,mod}.rs`
|
||||||
|
|
||||||
- [ ] `password.ts` — Argon2 via `@node-rs/argon2` (hash + verify), port `PasswordService`
|
- [x] `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`
|
- [x] `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`
|
- [x] `oauth_loopback.ts` — server TCP loopback lokal utk terima redirect OAuth; port `oauth_loopback`
|
||||||
- [ ] `mod.ts` — composisi ke `AuthService`
|
- [x] `mod.ts` (`index.ts`) — composisi ke `AuthService`
|
||||||
- [ ] Tambah dep `@node-rs/argon2` di `infrastructure/package.json`
|
- [x] Tambah dep `@node-rs/argon2` di `infrastructure/package.json`
|
||||||
|
|
||||||
### Fase 3g — IPC + bgbash
|
### Fase 3g — IPC + bgbash
|
||||||
**Sumber Rust:** `apps/infrastructure/src/ipc/{frame,protocol,server,client,conn}.rs`, `apps/infrastructure/src/bgbash/{job,control}.rs`
|
**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`.
|
* Mirrors `apps/infrastructure/src/auth/mod.rs`.
|
||||||
*/
|
*/
|
||||||
export { Argon2PasswordService } from "./password.ts";
|
export { Argon2PasswordService } from "./password.ts";
|
||||||
export { Hs256TokenService, createToken, verifyToken } from "./jwt.ts";
|
export { Hs256TokenService, createToken, verifyToken } from "./jwt.ts";
|
||||||
export type { JwtClaims } 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"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user