📦 EqualifyEverything / equalify-iris

📄 auth.ts · 100 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100import { Router } from "express";
import { randomBytes } from "node:crypto";
import type { IrisConfig } from "../config.ts";
import { authorizeUrl, exchangeCode, startDeviceFlow, pollDeviceFlow } from "../auth/github.ts";
import { sendError } from "./errors.ts";

export function authRouter(cfg: IrisConfig): Router {
  const r = Router();
  const callbackUrl = `${cfg.server.base_url}/v1/auth/github/callback`;
  // Short-lived OAuth state values issued by /start, checked at /callback.
  const states = new Map<string, number>();
  const STATE_TTL = 10 * 60 * 1000;

  const cleanStates = () => {
    const now = Date.now();
    for (const [s, exp] of states) if (exp < now) states.delete(s);
  };

  // Web flow: redirect to the GitHub consent screen (requests `repo` scope).
  // Unlike the device flow, the web flow's code exchange needs the client
  // secret, so both are required here.
  r.get("/github/start", (_req, res) => {
    if (!cfg.github.client_id || !cfg.github.client_secret) {
      sendError(res, 500, "github_not_configured", "Web OAuth flow requires both client_id and client_secret. Use the device flow if only a client_id is configured.");
      return;
    }
    const state = randomBytes(16).toString("hex");
    states.set(state, Date.now() + STATE_TTL);
    res.redirect(authorizeUrl(cfg.github.client_id, callbackUrl, state, cfg.github.oauth_base_url));
  });

  // Web flow: exchange the code for a token and return it to the client.
  r.get("/github/callback", async (req, res) => {
    cleanStates();
    const code = req.query.code as string | undefined;
    const state = req.query.state as string | undefined;
    if (!code) {
      sendError(res, 400, "invalid_request", "Missing code");
      return;
    }
    if (!state || !states.has(state)) {
      sendError(res, 400, "invalid_state", "Missing or unknown OAuth state");
      return;
    }
    states.delete(state);
    if (!cfg.github.client_secret) {
      sendError(res, 500, "github_not_configured", "Web OAuth flow requires client_secret");
      return;
    }
    try {
      const token = await exchangeCode(cfg.github.client_id, cfg.github.client_secret, code, callbackUrl, cfg.github.oauth_base_url);
      res.json({ access_token: token, token_type: "bearer" });
    } catch (e) {
      sendError(res, 400, "oauth_failed", (e as Error).message);
    }
  });

  // Device flow (CLI): begin and return the user code + verification URI.
  r.post("/github/device", async (_req, res) => {
    if (!cfg.github.client_id) {
      sendError(res, 500, "github_not_configured", "GITHUB_CLIENT_ID is not set");
      return;
    }
    try {
      const d = await startDeviceFlow(cfg.github.client_id, cfg.github.oauth_base_url);
      res.json({
        device_code: d.device_code,
        user_code: d.user_code,
        verification_uri: d.verification_uri,
        expires_in: d.expires_in,
        interval: d.interval,
      });
    } catch (e) {
      sendError(res, 502, "oauth_failed", (e as Error).message);
    }
  });

  // Device flow (CLI): poll for approval; returns the token once approved.
  r.post("/github/device/poll", async (req, res) => {
    const deviceCode = (req.body as { device_code?: string } | undefined)?.device_code;
    if (!deviceCode) {
      sendError(res, 400, "invalid_request", "Missing device_code");
      return;
    }
    try {
      const result = await pollDeviceFlow(cfg.github.client_id, deviceCode, cfg.github.oauth_base_url);
      if (result.status === "approved") {
        res.json({ access_token: result.access_token, token_type: "bearer" });
      } else {
        // 202: still pending (authorization_pending / slow_down / etc.)
        res.status(202).json({ status: "pending", error: result.error });
      }
    } catch (e) {
      sendError(res, 502, "oauth_failed", (e as Error).message);
    }
  });

  return r;
}