feat(auth): tambah OAuth loopback server untuk capture authorization-code redirect
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user