/** * 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(); 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 { 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 => { const res = await doFetch(url, { redirect: "manual", ...init, headers: { "User-Agent": USER_AGENT, Cookie: jar.header(), ...(init.headers as Record | 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, step: string, ): Promise> => { 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; 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 }; }