File size: 7,309 Bytes
88c4c60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import {
  QODER_DEVICE_TOKEN_URL,
  QODER_LOGIN_URL,
  QODER_USERINFO_URL,
} from "../../qoder/constants.js";
import crypto from "crypto";
import { v4 as uuidv4 } from "uuid";

/**
 * Qoder OAuth Service
 * Implements the device-token flow:
 *   1. Generate PKCE pair + nonce + machine_id locally.
 *   2. Open https://qoder.com/device/selectAccounts?challenge=...&nonce=...
 *      in the user's browser.
 *   3. Poll openapi.qoder.sh/api/v1/deviceToken/poll until the user authorizes
 *      and the upstream returns a `dt-...` access token.
 *
 * Tokens live ~30 days; refresh is a no-op (the upstream refresh endpoint
 * returns 403 for our flow). Users re-run login when expired.
 *
 * Mirrors the structure of KiroService β€” the COSY signing / WAF-bypass body
 * encoding / chat protocol live separately in src/lib/qoder/ because they're
 * used by every signed request, not just OAuth.
 */

// Timeout for OAuth helper calls. The OAuth modal polls every 2s for up to
// 5 minutes; an individual request that stalls beyond this is treated as a
// failed poll attempt and the next poll iteration retries.
const FETCH_TIMEOUT_MS = 15_000;

function base64Url(buf) {
  return buf
    .toString("base64")
    .replace(/=/g, "")
    .replace(/\+/g, "-")
    .replace(/\//g, "_");
}

/**
 * Wrap fetch with an AbortController-based timeout. Without this, a stalled
 * upstream socket hangs on Node's default keepalive timeout (minutes) and
 * abandoned polls accumulate hung sockets.
 */
async function fetchWithTimeout(url, init = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort("timeout"), FETCH_TIMEOUT_MS);
  try {
    return await fetch(url, { ...init, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

export class QoderService {
  /**
   * Generate a PKCE verifier + S256 challenge pair.
   * Uses 32 random bytes (matches qodercli/Veria).
   */
  generatePkcePair() {
    const verifier = base64Url(crypto.randomBytes(32));
    const challenge = base64Url(crypto.createHash("sha256").update(verifier).digest());
    return { verifier, challenge };
  }

  /**
   * Initiate the device flow. Returns the URL to open in a browser plus the
   * verifier/nonce/machineId we'll need to poll and to sign future requests.
   */
  initiateDeviceFlow() {
    const { verifier, challenge } = this.generatePkcePair();
    const nonce = uuidv4();
    const machineId = uuidv4();

    const params = new URLSearchParams({
      challenge,
      challenge_method: "S256",
      machine_id: machineId,
      nonce,
    });

    return {
      verificationUriComplete: `${QODER_LOGIN_URL}?${params.toString()}`,
      codeVerifier: verifier,
      nonce,
      machineId,
    };
  }

  /**
   * Single poll attempt. Returns one of:
   *   { status: "pending" }       β€” keep polling
   *   { status: "ok", token, ... } β€” user authorized, tokens captured
   *   throws Error                 β€” terminal failure
   *
   * Upstream returns 202/404 while waiting; 200 with a JSON body when done.
   */
  async pollDeviceToken({ nonce, codeVerifier }) {
    if (!nonce || !codeVerifier) {
      throw new Error("pollDeviceToken: missing nonce or code verifier");
    }
    const url = `${QODER_DEVICE_TOKEN_URL}?nonce=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(codeVerifier)}&challenge_method=S256`;

    const response = await fetchWithTimeout(url, {
      method: "GET",
      headers: {
        Accept: "application/json",
        "User-Agent": "Go-http-client/2.0",
      },
    });

    // Pending β€” server has registered the device code but the user hasn't
    // finished the browser flow yet. Both 202 and 404 mean "keep polling".
    if (response.status === 202 || response.status === 404) {
      return { status: "pending" };
    }

    const text = await response.text();

    if (!response.ok) {
      let message = `Qoder device token poll failed: HTTP ${response.status}`;
      try {
        const body = JSON.parse(text);
        if (body.message) message = `Qoder device token poll failed: ${body.message}`;
      } catch {}
      throw new Error(message);
    }

    let body;
    try {
      body = JSON.parse(text);
    } catch (err) {
      throw new Error(`Qoder device token poll: invalid JSON response (${err.message})`);
    }

    // Defensive: 200 + empty token means the upstream changed shape.
    if (!body.token) {
      throw new Error("Qoder device token poll returned 200 but no token");
    }

    const expireMs = QoderService.parseExpiry(body.expires_at, body.expires_in);

    return {
      status: "ok",
      accessToken: body.token,
      refreshToken: body.refresh_token || "",
      userId: body.user_id || "",
      expireTime: expireMs,
      rawResponse: body,
    };
  }

  /**
   * Fetch profile info for the freshly-issued token. Best-effort β€” failures
   * shouldn't block login; returning empty strings is fine.
   */
  async fetchUserInfo(accessToken) {
    try {
      const response = await fetchWithTimeout(QODER_USERINFO_URL, {
        method: "GET",
        headers: {
          Authorization: `Bearer ${accessToken}`,
          Accept: "application/json",
          "User-Agent": "Go-http-client/2.0",
        },
      });
      if (!response.ok) return { name: "", email: "" };
      const body = await response.json();
      return {
        name: (body.name || body.username || "").trim(),
        email: (body.email || "").trim(),
        organizationId: (body.organization_id || "").trim(),
      };
    } catch {
      return { name: "", email: "" };
    }
  }

  /**
   * Convert the upstream's expiry hint into a Unix-millisecond timestamp.
   * Accepts:
   *   - numeric (ms-epoch): returned as-is
   *   - numeric string of ms-epoch: e.g. "1781594470000"
   *   - RFC3339 string: e.g. "2026-06-16T07:15:04Z"
   *   - seconds-from-now via expiresInSeconds (>= 0)
   * Falls back to "now + 30 days" when both are missing.
   *
   * Order matters: try numeric (string or number) before Date.parse, since
   * Date.parse accepts short numeric strings like "2026" as years and would
   * otherwise return a misleading year-2026 timestamp instead of falling
   * through to the integer branch.
   *
   * Static so callers (and tests) can use it without instantiating.
   */
  static parseExpiry(expiresAt, expiresInSeconds) {
    if (typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > 0) {
      return expiresAt;
    }
    const trimmed = typeof expiresAt === "string" ? expiresAt.trim() : "";
    if (trimmed) {
      // Pure numeric string β†’ ms-epoch (don't let Date.parse swallow short
      // numerics as years).
      if (/^\d+$/.test(trimmed)) {
        const ms = Number.parseInt(trimmed, 10);
        if (Number.isFinite(ms) && ms > 0) return ms;
      }
      const parsed = Date.parse(trimmed);
      if (!Number.isNaN(parsed)) return parsed;
    }
    // expiresInSeconds === 0 means "already expired"; honor that by returning
    // the current time rather than fabricating a 30-day default.
    if (typeof expiresInSeconds === "number" && Number.isFinite(expiresInSeconds) && expiresInSeconds >= 0) {
      return Date.now() + expiresInSeconds * 1000;
    }
    return Date.now() + 30 * 24 * 60 * 60 * 1000;
  }
}