feat(qoder): port Kiro-style provider integration with COSY signing
Replaces the Qoder placeholder with a real free-tier provider: - Device-flow OAuth: PKCE + nonce generated locally, user authorizes at qoder.com/device/selectAccounts, poll openapi.qoder.sh until token - COSY signing (RSA-1024 + AES-128-CBC + MD5) for chat / model-list - WAF-bypass body encoding (custom-alphabet base64 + thirds rearrange) - Live model_config catalog from /algo/api/v2/model/list, cached 1h - 11 models registered (auto/ultimate/performance/efficient/lite + 6 frontier *model ids) - Usage fetcher for openapi.qoder.sh/api/v2/quota/usage - Dashboard live-models resolver, provider test, OAuth modal hookup - 24 unit tests covering encoder, PKCE, COSY headers, sigPath stripping
This commit is contained in:
+83
-70
@@ -600,80 +600,93 @@ const PROVIDERS = {
|
||||
|
||||
qoder: {
|
||||
config: QODER_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
flowType: "device_code",
|
||||
// Qoder uses a custom device flow: PKCE + nonce + machine_id are generated
|
||||
// locally, the user lands on qoder.com/device/selectAccounts in the
|
||||
// browser, and we poll openapi.qoder.sh until a `dt-...` token appears.
|
||||
requestDeviceCode: async (config) => {
|
||||
const { initiateDeviceFlow } = await import("@/lib/qoder/auth");
|
||||
const flow = initiateDeviceFlow();
|
||||
// Match the device_code shape the rest of the OAuthModal expects
|
||||
// (device_code, user_code, verification_uri[_complete], interval).
|
||||
// The poll endpoint identifies us by nonce+verifier, not by a
|
||||
// server-issued device_code, so we plumb our own values through:
|
||||
// device_code = nonce (modal forwards as deviceCode on poll)
|
||||
// codeVerifier = our PKCE verifier (route forwards as codeVerifier)
|
||||
return {
|
||||
device_code: flow.nonce,
|
||||
user_code: flow.nonce.slice(0, 8).toUpperCase(),
|
||||
verification_uri: config.loginUrl,
|
||||
verification_uri_complete: flow.verificationUriComplete,
|
||||
expires_in: 300,
|
||||
interval: 2,
|
||||
codeVerifier: flow.codeVerifier,
|
||||
_qoderNonce: flow.nonce,
|
||||
_qoderMachineId: flow.machineId,
|
||||
};
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
|
||||
const { pollDeviceToken, fetchUserInfo } = await import("@/lib/qoder/auth");
|
||||
const nonce = deviceCode || extraData?._qoderNonce;
|
||||
const verifier = codeVerifier || extraData?._qoderVerifier;
|
||||
if (!nonce || !verifier) {
|
||||
return {
|
||||
ok: false,
|
||||
data: { error: "invalid_request", error_description: "Missing nonce/verifier" },
|
||||
};
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await pollDeviceToken({ nonce, codeVerifier: verifier });
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
data: { error: "poll_failed", error_description: err.message },
|
||||
};
|
||||
}
|
||||
if (result.status === "pending") {
|
||||
return { ok: false, data: { error: "authorization_pending" } };
|
||||
}
|
||||
// Best-effort profile lookup so we have a name/email to display.
|
||||
const userInfo = await fetchUserInfo(result.accessToken);
|
||||
// expireTime is a Unix-ms timestamp from parseExpiry, which already
|
||||
// falls back to "now + 30 days" when the upstream omits expiry. Floor
|
||||
// to a sane minimum (1 day) so a stale or skewed upstream timestamp
|
||||
// doesn't truncate the stored token below something useful.
|
||||
const minSeconds = 24 * 60 * 60;
|
||||
const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000);
|
||||
const expiresIn = Math.max(minSeconds, remainingSeconds);
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
access_token: result.accessToken,
|
||||
refresh_token: result.refreshToken,
|
||||
expires_in: expiresIn,
|
||||
_qoderUserId: result.userId,
|
||||
_qoderMachineId: extraData?._qoderMachineId || "",
|
||||
_qoderName: userInfo.name,
|
||||
_qoderEmail: userInfo.email,
|
||||
_qoderOrganizationId: userInfo.organizationId,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Fetch user info (MUST succeed to get API key)
|
||||
const userInfoRes = await fetch(
|
||||
`${QODER_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
|
||||
{ headers: { Accept: "application/json" } }
|
||||
);
|
||||
|
||||
if (!userInfoRes.ok) {
|
||||
const errorText = await userInfoRes.text();
|
||||
throw new Error(`Failed to fetch user info: ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await userInfoRes.json();
|
||||
if (!result.success) {
|
||||
throw new Error(`User info request failed: ${result.message || "Unknown error"}`);
|
||||
}
|
||||
|
||||
const userInfo = result.data || {};
|
||||
|
||||
if (!userInfo.apiKey || userInfo.apiKey.trim() === "") {
|
||||
throw new Error("Empty API key returned from Qoder");
|
||||
}
|
||||
|
||||
const email = userInfo.email?.trim() || userInfo.phone?.trim();
|
||||
if (!email) {
|
||||
throw new Error("Missing account email/phone in user info");
|
||||
}
|
||||
|
||||
return { userInfo };
|
||||
mapTokens: (tokens) => {
|
||||
const email = (tokens._qoderEmail || "").trim() || null;
|
||||
const displayName = (tokens._qoderName || "").trim() || null;
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || null,
|
||||
expiresIn: tokens.expires_in,
|
||||
email,
|
||||
displayName,
|
||||
providerSpecificData: {
|
||||
authMethod: "device",
|
||||
userId: tokens._qoderUserId || "",
|
||||
machineId: tokens._qoderMachineId || "",
|
||||
organizationId: tokens._qoderOrganizationId || "",
|
||||
},
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
apiKey: extra?.userInfo?.apiKey,
|
||||
email: extra?.userInfo?.email || extra?.userInfo?.phone,
|
||||
displayName: extra?.userInfo?.nickname || extra?.userInfo?.name,
|
||||
}),
|
||||
},
|
||||
|
||||
qwen: {
|
||||
|
||||
Reference in New Issue
Block a user