feat(rpartstore): Renault/Dacia için RPartStore VIN-decode fallback kaynağı
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
rpartstore.renault.com (Renault Group bayi portalı) yeni decode kaynağı olarak eklendi. Portal REST değil STOMP 1.2 over WebSocket konuşuyor; login Okta OIE (IDX JSON API + PKCE), tarayıcı gerekmiyor. Sıralama Renault/Dacia için: pcat + PL24 + emex yarışı → rpartstore → (bayrağı açıksa) Vinpin son çare. - integrations/rpartstore: STOMP codec, Okta login, oturum (trace-id ile çok-mesajlı cevap eşleme), istemci (Redis'te 1 saatlik token), Renault/Dacia yönlendirme, RPartStore → PL24 catalog_vehicles model eşleyici (roman rakam, karoseri niteleyici, yanlış-pazar cezası). - rpartstore_decodes tablosu (migration 0037) + rpartstore-decode BullMQ kuyruğu; worker concurrency 1 + 1 iş / 6 s limiter (portal 2 arama / 10 s). - Günlük sert kota RPARTSTORE_DAILY_CAP (varsayılan 10, İstanbul günü): işlemci göndermeden önce Redis INCR ile rezervasyon yapar, aşan VIN'ler `capped` olur ve 24 saat sonra tekrar denenir; API kota doluysa kuyruğa hiç almaz. Kısa vadeli rate-limit cevabında retryAfter kadar bekleyip bir kez tekrar dener. - VehiclesService.tryRpartstoreFallback: Vinpin fallback ile aynı sözleşme (decoding/catalogVehicle cevapları, tek başarısızlık log satırı). - Env: RPARTSTORE_ENABLED/USER/PASS/DAILY_CAP/BROKER_URL/APP_VERSION (compose api+worker). Vinpin koda dokunulmadan pasif kalır (VINPIN_ENABLED). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
238
apps/api/src/integrations/rpartstore/rpartstore.auth.ts
Normal file
238
apps/api/src/integrations/rpartstore/rpartstore.auth.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Browser-free Okta (OIE / IDX) login for rpartstore.renault.com.
|
||||
*
|
||||
* Flow (verified live 2026-09-25, ~1.8 s, no MFA / captcha):
|
||||
* 1. GET {issuer}/v1/authorize?...PKCE... → hosted page HTML containing `stateToken`
|
||||
* 2. POST /idp/idx/introspect {stateToken} → stateHandle
|
||||
* 3. POST /idp/idx/identify {identifier, stateHandle}
|
||||
* 4. POST /idp/idx/challenge/answer {credentials:{passcode}, stateHandle} → success.href
|
||||
* 5. GET success.href (follow redirects manually) → …/idp-redirect?code=…&state=…
|
||||
* 6. POST {issuer}/v1/token (authorization_code + code_verifier) → access_token (1 h, no refresh_token)
|
||||
*/
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
export interface RpartstoreAuthConfig {
|
||||
username: string;
|
||||
password: string;
|
||||
issuer?: string;
|
||||
clientId?: string;
|
||||
redirectUri?: string;
|
||||
scope?: string;
|
||||
/** Injectable for tests. */
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface RpartstoreToken {
|
||||
accessToken: string;
|
||||
/** Epoch ms. */
|
||||
expiresAt: number;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export class RpartstoreAuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly step: string,
|
||||
readonly detail?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RpartstoreAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
issuer: "https://sso.renault.com/oauth2/aus133y6mks4ptDss417",
|
||||
clientId: "irn-72795_ope_pkce_4hcafvxlbcil",
|
||||
redirectUri: "https://rpartstore.renault.com/idp-redirect",
|
||||
scope: "openid alliance_profile apis.default",
|
||||
};
|
||||
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
|
||||
const ION = "application/ion+json; okta-version=1.0.0";
|
||||
|
||||
const b64url = (buf: Buffer): string => buf.toString("base64url");
|
||||
|
||||
/** Tiny cookie jar: Okta needs `idx`/`JSESSIONID` cookies across the IDX steps. */
|
||||
class CookieJar {
|
||||
private readonly jar = new Map<string, string>();
|
||||
|
||||
absorb(res: Response): void {
|
||||
const setCookies: string[] =
|
||||
typeof (res.headers as { getSetCookie?: () => string[] }).getSetCookie === "function"
|
||||
? (res.headers as unknown as { getSetCookie: () => string[] }).getSetCookie()
|
||||
: [];
|
||||
for (const sc of setCookies) {
|
||||
const kv = sc.split(";")[0];
|
||||
const i = kv.indexOf("=");
|
||||
if (i > 0) this.jar.set(kv.slice(0, i).trim(), kv.slice(i + 1).trim());
|
||||
}
|
||||
}
|
||||
|
||||
header(): string {
|
||||
return Array.from(this.jar.entries())
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeJwtExpiry(accessToken: string): { exp: number; sub: string } {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
|
||||
) as {
|
||||
exp?: number;
|
||||
sub?: string;
|
||||
};
|
||||
if (!payload.exp) throw new RpartstoreAuthError("access token has no exp claim", "token");
|
||||
return { exp: payload.exp, sub: payload.sub ?? "" };
|
||||
}
|
||||
|
||||
export async function loginRpartstore(cfg: RpartstoreAuthConfig): Promise<RpartstoreToken> {
|
||||
const issuer = cfg.issuer ?? DEFAULTS.issuer;
|
||||
const clientId = cfg.clientId ?? DEFAULTS.clientId;
|
||||
const redirectUri = cfg.redirectUri ?? DEFAULTS.redirectUri;
|
||||
const scope = cfg.scope ?? DEFAULTS.scope;
|
||||
const doFetch = cfg.fetchImpl ?? fetch;
|
||||
const jar = new CookieJar();
|
||||
|
||||
const request = async (url: string, init: RequestInit = {}): Promise<Response> => {
|
||||
const res = await doFetch(url, {
|
||||
redirect: "manual",
|
||||
...init,
|
||||
headers: {
|
||||
"User-Agent": USER_AGENT,
|
||||
Cookie: jar.header(),
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
jar.absorb(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
const verifier = b64url(randomBytes(48));
|
||||
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
||||
const state = randomBytes(16).toString("hex");
|
||||
const nonce = randomBytes(16).toString("hex");
|
||||
|
||||
// 1. hosted authorize page → stateToken
|
||||
const authorize = new URL(`${issuer}/v1/authorize`);
|
||||
for (const [k, v] of Object.entries({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope,
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
response_mode: "query",
|
||||
})) {
|
||||
authorize.searchParams.set(k, v);
|
||||
}
|
||||
const authorizeRes = await request(authorize.toString());
|
||||
const html = await authorizeRes.text();
|
||||
const stateTokenMatch =
|
||||
html.match(/stateToken\s*=\s*'([^']+)'/) ?? html.match(/"stateToken":"([^"]+)"/);
|
||||
if (authorizeRes.status !== 200 || !stateTokenMatch) {
|
||||
throw new RpartstoreAuthError(
|
||||
`authorize page did not expose a stateToken (HTTP ${authorizeRes.status})`,
|
||||
"authorize",
|
||||
);
|
||||
}
|
||||
const stateToken = stateTokenMatch[1].replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
);
|
||||
|
||||
const idx = async (
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
step: string,
|
||||
): Promise<Record<string, any>> => {
|
||||
const res = await request(`https://sso.renault.com/idp/idx/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": ION, Accept: ION, Origin: "https://sso.renault.com" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = (await res.json().catch(() => ({}))) as Record<string, any>;
|
||||
if (!res.ok) {
|
||||
throw new RpartstoreAuthError(
|
||||
`Okta ${step} failed (HTTP ${res.status})`,
|
||||
step,
|
||||
json.messages ?? json,
|
||||
);
|
||||
}
|
||||
return json;
|
||||
};
|
||||
|
||||
// 2-4. IDX remediation
|
||||
const introspected = await idx("introspect", { stateToken }, "introspect");
|
||||
const identified = await idx(
|
||||
"identify",
|
||||
{ identifier: cfg.username, stateHandle: introspected.stateHandle },
|
||||
"identify",
|
||||
);
|
||||
const answered = await idx(
|
||||
"challenge/answer",
|
||||
{
|
||||
credentials: { passcode: cfg.password },
|
||||
stateHandle: identified.stateHandle ?? introspected.stateHandle,
|
||||
},
|
||||
"challenge",
|
||||
);
|
||||
const successHref: string | undefined = answered.success?.href;
|
||||
if (!successHref) {
|
||||
throw new RpartstoreAuthError(
|
||||
"Okta did not return a success redirect (wrong password, locked account or MFA now required)",
|
||||
"challenge",
|
||||
answered.messages,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. follow the redirect chain until the app callback carries ?code=
|
||||
let next = successHref;
|
||||
let code: string | undefined;
|
||||
for (let hop = 0; hop < 6 && !code; hop += 1) {
|
||||
const res = await request(next);
|
||||
const location = res.headers.get("location");
|
||||
if (!location) break;
|
||||
const target = new URL(location, next);
|
||||
const gotCode = target.searchParams.get("code");
|
||||
if (gotCode) {
|
||||
if (target.searchParams.get("state") !== state)
|
||||
throw new RpartstoreAuthError("OAuth state mismatch", "redirect");
|
||||
code = gotCode;
|
||||
}
|
||||
next = target.toString();
|
||||
}
|
||||
if (!code)
|
||||
throw new RpartstoreAuthError("redirect chain ended without an authorization code", "redirect");
|
||||
|
||||
// 6. PKCE token exchange
|
||||
const tokenRes = await request(`${issuer}/v1/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Origin: "https://rpartstore.renault.com",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
client_id: clientId,
|
||||
}).toString(),
|
||||
});
|
||||
const token = (await tokenRes.json().catch(() => ({}))) as {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
};
|
||||
if (!tokenRes.ok || !token.access_token) {
|
||||
throw new RpartstoreAuthError(
|
||||
`token exchange failed (HTTP ${tokenRes.status}): ${token.error_description ?? token.error ?? ""}`,
|
||||
"token",
|
||||
);
|
||||
}
|
||||
const { exp, sub } = decodeJwtExpiry(token.access_token);
|
||||
return { accessToken: token.access_token, expiresAt: exp * 1000, subject: sub };
|
||||
}
|
||||
Reference in New Issue
Block a user