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>
239 lines
7.6 KiB
TypeScript
239 lines
7.6 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|