|
1 | 1 | import { createHash, randomBytes } from "node:crypto"; |
| 2 | +import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; |
2 | 3 | import type { OAuthCredentials } from "../llm/oauth.js"; |
3 | 4 | import { normalizeOptionalString } from "../shared/string-coerce.js"; |
4 | 5 |
|
@@ -81,9 +82,17 @@ export function parseOAuthCallbackInput( |
81 | 82 | return { code, state }; |
82 | 83 | } |
83 | 84 |
|
84 | | -function coerceExpiresAt(expiresInSeconds: number, now: number): number { |
85 | | - const value = now + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - DEFAULT_EXPIRES_BUFFER_MS; |
86 | | - return Math.max(value, now + 30_000); |
| 85 | +function resolveChutesExpiresAt(value: unknown, now: number): number | undefined { |
| 86 | + const expiresInSeconds = parseStrictPositiveInteger(value); |
| 87 | + if (expiresInSeconds === undefined) { |
| 88 | + return undefined; |
| 89 | + } |
| 90 | + const lifetimeMs = expiresInSeconds * 1000; |
| 91 | + const expiresAt = now + lifetimeMs - DEFAULT_EXPIRES_BUFFER_MS; |
| 92 | + if (!Number.isSafeInteger(lifetimeMs) || !Number.isSafeInteger(expiresAt)) { |
| 93 | + return undefined; |
| 94 | + } |
| 95 | + return Math.max(expiresAt, now + 30_000); |
87 | 96 | } |
88 | 97 |
|
89 | 98 | async function fetchChutesUserInfo(params: { |
@@ -144,21 +153,24 @@ export async function exchangeChutesCodeForTokens(params: { |
144 | 153 |
|
145 | 154 | const access = data.access_token?.trim(); |
146 | 155 | const refresh = data.refresh_token?.trim(); |
147 | | - const expiresIn = data.expires_in ?? 0; |
| 156 | + const expires = resolveChutesExpiresAt(data.expires_in, now); |
148 | 157 |
|
149 | 158 | if (!access) { |
150 | 159 | throw new Error("Chutes token exchange returned no access_token"); |
151 | 160 | } |
152 | 161 | if (!refresh) { |
153 | 162 | throw new Error("Chutes token exchange returned no refresh_token"); |
154 | 163 | } |
| 164 | + if (expires === undefined) { |
| 165 | + throw new Error("Chutes token exchange returned invalid expires_in"); |
| 166 | + } |
155 | 167 |
|
156 | 168 | const info = await fetchChutesUserInfo({ accessToken: access, fetchFn }); |
157 | 169 |
|
158 | 170 | return { |
159 | 171 | access, |
160 | 172 | refresh, |
161 | | - expires: coerceExpiresAt(expiresIn, now), |
| 173 | + expires, |
162 | 174 | email: info?.username, |
163 | 175 | accountId: info?.sub, |
164 | 176 | clientId: params.app.clientId, |
@@ -210,18 +222,21 @@ export async function refreshChutesTokens(params: { |
210 | 222 | }; |
211 | 223 | const access = data.access_token?.trim(); |
212 | 224 | const newRefresh = data.refresh_token?.trim(); |
213 | | - const expiresIn = data.expires_in ?? 0; |
| 225 | + const expires = resolveChutesExpiresAt(data.expires_in, now); |
214 | 226 |
|
215 | 227 | if (!access) { |
216 | 228 | throw new Error("Chutes token refresh returned no access_token"); |
217 | 229 | } |
| 230 | + if (expires === undefined) { |
| 231 | + throw new Error("Chutes token refresh returned invalid expires_in"); |
| 232 | + } |
218 | 233 |
|
219 | 234 | return { |
220 | 235 | ...params.credential, |
221 | 236 | access, |
222 | 237 | // RFC 6749 section 6: new refresh token is optional; if present, replace old. |
223 | 238 | refresh: newRefresh || refreshToken, |
224 | | - expires: coerceExpiresAt(expiresIn, now), |
| 239 | + expires, |
225 | 240 | clientId, |
226 | 241 | } as unknown as ChutesStoredOAuth; |
227 | 242 | } |
0 commit comments