From e50a907931e668398c845d3e7d36cc084f3789bd Mon Sep 17 00:00:00 2001 From: semih Date: Thu, 17 Sep 2026 00:59:36 +0300 Subject: [PATCH] =?UTF-8?q?refactor(pl24):=20oturum=20modeli=20=E2=80=94?= =?UTF-8?q?=20tek=20login,=20=C3=A7erez=20tabanl=C4=B1=20authorize=20yenil?= =?UTF-8?q?eme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faz 1 / adım 1 (analiz: /home/s/ss/plv2.md §4.1, bulgular auth-02/03/05/06/09/11/15, p4legacy-07, consumers-04). SORUN: PL24'ün base JWT'si 600 saniye yaşıyor ve kod bunu "oturum" sayıyordu (isTokenValid → login). Talep varsa her ~9 dakikada bir squeezeOut:true ile YENİDEN LOGIN atılıyordu (günde ~144 login/hesap). PL24 hesap başına tek oturum veriyor; bu desen hem kendi oturumumuzu sürekli düşürüyor hem de iki hesap banından önceki otomasyon imzasını oluşturuyordu. Portalın kendisi bir kez login olup PL24TOKEN çerezini saklıyor ve yalnız authorize'ı yeniliyor. DEĞİŞİKLİK: - Oturum = PL24TOKEN çerezi (süresiz). `ensureSession()` sahibi; bellekte tutulur, Redis'te `pl24:auth:session:` ile 24 saat paylaşılır (api/worker aynı oturumu devralır). Süre kontrolü YOK — oturum yalnız sunucu reddederse düşer. - Servis token'ı = authorize ile mint edilen 600 s'lik JWT; yalnız `Cookie: PL24TOKEN` ile istenir (Bearer gerekmiyor — canlı doğrulandı). - `session_status:"gone"` ve authorize 401/403 artık okunuyor: oturum düşürülür, tek sefer yeniden login + retry. - Single-flight: süreç içi in-flight promise + Redis kilidi (`pl24:auth:login-lock:`), kilidi alamayan paylaşılan oturumu bekler. Eski prewarm iki paralel login atıyordu ve biri her boot'ta HTTP 500 alıyordu. - Login sözleşmesi: portal ucu `/auth/ext/api/1.1/login` ({account,user,password,squeezeOut} → {loginStatus,sessionToken} + Set-Cookie); RFC7807 problem gövdesi tiplendi. Önce squeezeOut:false denenir, yalnız "session-limit-exceeded"/USER_ALREADY_LOGGED_IN'de squeezeOut:true ile tekrar. `PL24_LOGIN_API=legacy` eski uca döner (bir sürüm geri çekilme bayrağı). - Hata sınıflandırma: account-not-active / user-not-active / authentication / 2FA-required → ilk hatada 6 saatlik devre kesici (eskiden ölü hesap 5 dakikada bir sonsuza dek deneniyordu: prod'da 376 ardışık 401). Ağ/timeout → 5 dakika. - Saatlik login tavanı (hesap başına 6) — sağlıklı günde 0-1 login beklenir. - P4 (.action) sayfaları için authorize kaldırıldı: yalnız çerez gönderiliyor (canlı doğrulandı). Ford/PSA'daki 18 "ısıtma" authorize çağrısı ensureSession'a indirildi; demo-sayfa/401 çaresi artık servis token temizleme değil oturum yenileme. - Gerçek Chrome User-Agent sabiti (eski değer hiçbir tarayıcıda yok). - authorize'a `pl24-wmidata` eklendi (portal WMI decode'u için ön hazırlık). - Ölü kod silindi: getAccessToken*/getSessionCookie* base-JWT yolu, hasService, getAvailableServices, AUTH_TOKEN_TTL. Test: pl24-auth.service.spec.ts yeniden yazıldı (19 test: tek login, single-flight, Redis devralma, cookie-only authorize, gone/401 yeniden login, 412 squeezeOut, P4 cookie-only header, kalıcı hata kesicisi, saatlik tavan). Tüm PL24 + tüketici paketleri: 188 test geçti. tsc + biome temiz. Co-Authored-By: Claude Opus 5 (1M context) --- .../pl24/pl24-auth.service.spec.ts | 325 +++++- .../integrations/pl24/pl24-auth.service.ts | 952 ++++++++++-------- .../pl24/pl24-ford-legacy.service.ts | 51 +- .../src/integrations/pl24/pl24-psa.service.ts | 3 +- .../src/integrations/pl24/pl24.constants.ts | 22 +- apps/api/src/integrations/pl24/pl24.types.ts | 52 + 6 files changed, 898 insertions(+), 507 deletions(-) diff --git a/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts b/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts index bb39f76..4f9d378 100644 --- a/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts +++ b/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts @@ -1,32 +1,101 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PL24AuthService } from "./pl24-auth.service"; -// PL24_TR_DISABLED köprüsü + login devre kesici: ağ yok, login yolları global -// fetch stub'ıyla gözlenir; private durum repo genelindeki `as unknown as` kalıbıyla okunur. +// Oturum modeli (1 login → PL24TOKEN çerezi → yalnız authorize ile yenileme), +// PL24_TR_DISABLED köprüsü ve login devre kesici. Ağ yok: global fetch stub'ı; +// private durum repo genelindeki `as unknown as` kalıbıyla okunur. -const makeService = (cfgOverrides: Record = {}) => { +type RedisStub = { + store: Map; + getJson: (k: string) => Promise; + setJson: (k: string, v: unknown) => Promise; + del: (k: string) => Promise; + setNx: (k: string, v: string, ttl: number) => Promise; + incr: (k: string) => Promise; + expire: (k: string, ttl: number) => Promise; +}; + +const makeRedis = (opts: { lockTaken?: boolean } = {}): RedisStub => { + const store = new Map(); + const counters = new Map(); + return { + store, + getJson: async (k: string) => store.get(k) ?? null, + setJson: async (k: string, v: unknown) => { + store.set(k, v); + }, + del: async (k: string) => { + store.delete(k); + }, + setNx: async () => !opts.lockTaken, + incr: async (k: string) => { + const n = (counters.get(k) ?? 0) + 1; + counters.set(k, n); + return n; + }, + expire: async () => {}, + }; +}; + +const makeService = (cfgOverrides: Record = {}, redis = makeRedis()) => { const cfg: Record = { - "pl24.companyCode": "tr-903645", + "pl24.companyCode": "tr-000000", "pl24.username": "admin", "pl24.password": "pw-tr", - "pl24.companyCode2": "de-708171", + "pl24.companyCode2": "de-000000", "pl24.username2": "admin", "pl24.password2": "pw-de", "pl24.proxyDe": "http://u:p@127.0.0.1:9", ...cfgOverrides, }; const configService = { get: (k: string, d?: unknown) => cfg[k] ?? d } as never; - const redis = { getJson: async () => null, setJson: async () => {} } as never; - return new PL24AuthService(configService, redis); + return { svc: new PL24AuthService(configService, redis as never), redis }; }; type Exposed = { effectiveAccount(account: "tr" | "de"): "tr" | "de"; - login(forceNew?: boolean): Promise; - login2(forceNew?: boolean): Promise; - tokenData2: unknown; + login(account: "tr" | "de"): Promise; + ensureSession(account: "tr" | "de"): Promise<{ sessionToken: string }>; + sessions: Record; + serviceTokens: Record<"tr" | "de", Map>; }; +/** Portal login yanıtı: {loginStatus, sessionToken} + Set-Cookie PL24TOKEN. */ +const loginOk = (token = "sess-token-1") => ({ + ok: true, + status: 200, + statusText: "OK", + headers: { + get: (h: string) => (h.toLowerCase() === "set-cookie" ? `PL24TOKEN=${token}; Path=/` : null), + getSetCookie: () => [`PL24TOKEN=${token}; Path=/; Secure`], + }, + json: async () => ({ loginStatus: "OK", sessionToken: token }), +}); + +const jwt = (payload: Record) => + `h.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.s`; + +const authorizeOk = (scope = "vw_parts pl24-usage", sessionStatus = "alive") => ({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => null, getSetCookie: () => [] }, + json: async () => ({ + access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 600, sid: "sess-token-1" }), + expires_in: 600, + scope, + session_status: sessionStatus, + }), +}); + +const problem = (status: number, type: string) => ({ + ok: false, + status, + statusText: "Precondition Failed", + headers: { get: () => null, getSetCookie: () => [] }, + json: async () => ({ type, title: type, detail: type }), +}); + const savedFlag = process.env.PL24_TR_DISABLED; beforeEach(() => { @@ -41,40 +110,200 @@ afterEach(() => { describe("PL24AuthService — PL24_TR_DISABLED tr→de köprüsü", () => { it("bayrak kapalıyken hesaplar olduğu gibi kalır", () => { - const p = makeService() as unknown as Exposed; + const { svc } = makeService(); + const p = svc as unknown as Exposed; expect(p.effectiveAccount("tr")).toBe("tr"); expect(p.effectiveAccount("de")).toBe("de"); }); it("bayrak açıkken tr → de'ye maplenir, de değişmez", () => { process.env.PL24_TR_DISABLED = "true"; - const p = makeService() as unknown as Exposed; + const { svc } = makeService(); + const p = svc as unknown as Exposed; expect(p.effectiveAccount("tr")).toBe("de"); expect(p.effectiveAccount("de")).toBe("de"); }); it("de hesabı tanımlı değilse mapleme yapılmaz (duvara yönlendirme yok)", () => { process.env.PL24_TR_DISABLED = "true"; - const p = makeService({ "pl24.companyCode2": "" }) as unknown as Exposed; - expect(p.effectiveAccount("tr")).toBe("tr"); + const { svc } = makeService({ "pl24.companyCode2": "" }); + expect((svc as unknown as Exposed).effectiveAccount("tr")).toBe("tr"); }); it("bayrak açıkken tr login'i ağa çıkmadan reddedilir", async () => { process.env.PL24_TR_DISABLED = "true"; const fetchSpy = vi.fn(); vi.stubGlobal("fetch", fetchSpy); - const p = makeService() as unknown as Exposed; - await expect(p.login()).rejects.toThrow(/PL24_TR_DISABLED/); + const { svc } = makeService(); + await expect((svc as unknown as Exposed).login("tr")).rejects.toThrow(/PL24_TR_DISABLED/); expect(fetchSpy).not.toHaveBeenCalled(); }); - it("legacy clearTokens (tr) bayrak altında de token'larını temizler", () => { + it("legacy clearTokens (tr) bayrak altında de servis token'larını temizler", () => { process.env.PL24_TR_DISABLED = "true"; - const svc = makeService(); + const { svc } = makeService(); const p = svc as unknown as Exposed; - p.tokenData2 = { accessToken: "x", sessionCookie: "c", expiresAt: new Date(), services: [] }; + p.serviceTokens.de.set("vw_parts", { token: "x", expiresAt: Date.now() + 60_000 }); svc.clearTokens(); - expect(p.tokenData2).toBeNull(); + expect(p.serviceTokens.de.size).toBe(0); + }); +}); + +describe("PL24AuthService — oturum modeli", () => { + it("bir kez login eder, sonraki çağrılar aynı oturumu kullanır (yeniden login yok)", async () => { + const fetchSpy = vi.fn().mockResolvedValue(loginOk()); + vi.stubGlobal("fetch", fetchSpy); + const { svc } = makeService(); + + const c1 = await svc.getSessionCookieForAccount("de"); + const c2 = await svc.getSessionCookieForAccount("de"); + + expect(c1).toBe("PL24TOKEN=sess-token-1"); + expect(c2).toBe(c1); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("eşzamanlı çağrılar tek login paylaşır (single-flight)", async () => { + const fetchSpy = vi.fn().mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 10)); + return loginOk(); + }); + vi.stubGlobal("fetch", fetchSpy); + const { svc } = makeService(); + + await Promise.all([ + svc.getSessionCookieForAccount("de"), + svc.getSessionCookieForAccount("de"), + svc.getSessionCookieForAccount("de"), + ]); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("başka bir süreç oturumu tutuyorsa Redis'ten devralır, login etmez", async () => { + const redis = makeRedis(); + redis.store.set("pl24:auth:session:de", { + sessionToken: "shared-token", + loginAt: Date.now(), + lastOkAt: Date.now(), + }); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { svc } = makeService({}, redis); + + expect(await svc.getSessionCookieForAccount("de")).toBe("PL24TOKEN=shared-token"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("login sadece çerez mint eder; servis token'ı authorize'dan gelir (Bearer'sız)", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string) => { + if (String(url).includes("/login")) return loginOk(); + return authorizeOk(); + }); + vi.stubGlobal("fetch", fetchSpy); + const { svc } = makeService(); + + await svc.authorizeServiceForAccount("vw_parts", "de"); + + const authorizeCall = fetchSpy.mock.calls.find((c) => String(c[0]).includes("/authorize")); + expect(authorizeCall).toBeTruthy(); + const headers = (authorizeCall?.[1] as { headers: Record }).headers; + expect(headers.Cookie).toBe("PL24TOKEN=sess-token-1"); + expect(headers.Authorization).toBeUndefined(); + expect(headers["User-Agent"]).toMatch(/Chrome\/\d/); + }); + + it("servis token'ı süresi dolmadan yeniden authorize edilmez", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string) => { + if (String(url).includes("/login")) return loginOk(); + return authorizeOk(); + }); + vi.stubGlobal("fetch", fetchSpy); + const { svc } = makeService(); + + await svc.authorizeServiceForAccount("vw_parts", "de"); + await svc.authorizeServiceForAccount("vw_parts", "de"); + + const authorizeCalls = fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/authorize")); + expect(authorizeCalls).toHaveLength(1); + }); + + it("session_status=gone → oturumu düşürür, bir kez yeniden login edip devam eder", async () => { + let authorizeCalls = 0; + const fetchSpy = vi.fn().mockImplementation(async (url: string) => { + if (String(url).includes("/login")) return loginOk(`sess-${authorizeCalls}`); + authorizeCalls += 1; + return authorizeCalls === 1 ? authorizeOk("vw_parts", "gone") : authorizeOk(); + }); + vi.stubGlobal("fetch", fetchSpy); + const { svc } = makeService(); + + const token = await svc.authorizeServiceForAccount("vw_parts", "de"); + + expect(token).toBeTruthy(); + expect(authorizeCalls).toBe(2); + expect(fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/login"))).toHaveLength(2); + }); + + it("authorize 401 → oturumu düşürür ve tek sefer yeniden dener", async () => { + let authorizeCalls = 0; + const fetchSpy = vi.fn().mockImplementation(async (url: string) => { + if (String(url).includes("/login")) return loginOk(); + authorizeCalls += 1; + if (authorizeCalls === 1) { + return { + ok: false, + status: 401, + statusText: "Unauthorized", + headers: { get: () => null, getSetCookie: () => [] }, + json: async () => ({}), + }; + } + return authorizeOk(); + }); + vi.stubGlobal("fetch", fetchSpy); + const { svc } = makeService(); + + await expect(svc.authorizeServiceForAccount("vw_parts", "de")).resolves.toBeTruthy(); + expect(authorizeCalls).toBe(2); + }); + + it("oturum sınırı aşıldı (412) → tek sefer squeezeOut ile tekrar dener", async () => { + const bodies: string[] = []; + let loginCalls = 0; + const fetchSpy = vi.fn().mockImplementation(async (url: string, opts: { body: string }) => { + if (!String(url).includes("/login")) return authorizeOk(); + bodies.push(opts.body); + loginCalls += 1; + return loginCalls === 1 + ? problem(412, "urn:login:session-limit-exceeded") + : loginOk("after-squeeze"); + }); + vi.stubGlobal("fetch", fetchSpy); + const { svc } = makeService(); + + expect(await svc.getSessionCookieForAccount("de")).toBe("PL24TOKEN=after-squeeze"); + expect(JSON.parse(bodies[0]).squeezeOut).toBe(false); + expect(JSON.parse(bodies[1]).squeezeOut).toBe(true); + }); + + it("P4 header'ı yalnız çerez taşır (authorize isteği atmaz)", async () => { + const fetchSpy = vi.fn().mockResolvedValue(loginOk()); + vi.stubGlobal("fetch", fetchSpy); + const { svc } = makeService(); + + const headers = await svc.buildFordLegacyHeadersForAccount("opel_parts", "de"); + + expect(headers.Cookie).toBe("PL24TOKEN=sess-token-1"); + expect(headers.Authorization).toBeUndefined(); + expect(fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/authorize"))).toHaveLength(0); + }); + + it("Ford hintstoken değeri oturum token'ıdır", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginOk("hint-1"))); + const { svc } = makeService(); + await svc.getSessionCookieForAccount("de"); + expect(svc.getPL24TokenValueForAccount("de")).toBe("hint-1"); }); }); @@ -82,46 +311,58 @@ describe("PL24AuthService — login devre kesici", () => { it("3 ardışık başarısız login'den sonra açılır ve yeni ağ denemesini keser", async () => { const fetchSpy = vi.fn().mockRejectedValue(new Error("network down")); vi.stubGlobal("fetch", fetchSpy); - const p = makeService() as unknown as Exposed; + const { svc } = makeService(); + const p = svc as unknown as Exposed; for (let i = 0; i < 3; i++) { - await expect(p.login2()).rejects.toThrow(/giris hatasi/); + await expect(p.login("de")).rejects.toThrow(/giris hatasi/); } + // Ağ hatasında squeezeOut denemesi yapılmaz → login başına tek fetch. expect(fetchSpy).toHaveBeenCalledTimes(3); - // 4. deneme: kesici açık — fetch'e hiç gidilmez. - await expect(p.login2()).rejects.toThrow(/breaker open/); + await expect(p.login("de")).rejects.toThrow(/breaker open/); expect(fetchSpy).toHaveBeenCalledTimes(3); }); + it("hesap kapalı (account-not-active) → ilk hatada uzun süreli kesici", async () => { + const fetchSpy = vi.fn().mockResolvedValue(problem(400, "urn:login:account-not-active")); + vi.stubGlobal("fetch", fetchSpy); + const { svc } = makeService(); + const p = svc as unknown as Exposed; + + await expect(p.login("de")).rejects.toThrow(/giris hatasi/); + await expect(p.login("de")).rejects.toThrow(/breaker open/); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + it("başarılı login kesici sayacını sıfırlar", async () => { let failFirst = 2; - const okResponse = { - ok: true, - headers: { get: () => null }, - json: async () => ({ - token: { - // exp uzak gelecekte olan imzasız-görünümlü JWT payload'ı - access_token: `h.${Buffer.from( - JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, services: ["x"] }), - ).toString("base64url")}.s`, - }, - }), - }; const fetchSpy = vi.fn().mockImplementation(async () => { if (failFirst > 0) { failFirst -= 1; throw new Error("network down"); } - return okResponse; + return loginOk(); }); vi.stubGlobal("fetch", fetchSpy); - const p = makeService() as unknown as Exposed; + const { svc } = makeService(); + const p = svc as unknown as Exposed; - await expect(p.login2()).rejects.toThrow(); - await expect(p.login2()).rejects.toThrow(); - await expect(p.login2(true)).resolves.toBeTruthy(); // 3. deneme başarılı → sayaç sıfır - await expect(p.login2(true)).resolves.toBeTruthy(); // kesici açılmadı, akış normal + await expect(p.login("de")).rejects.toThrow(); + await expect(p.login("de")).rejects.toThrow(); + await expect(p.login("de")).resolves.toBeTruthy(); + await expect(p.login("de")).resolves.toBeTruthy(); expect(fetchSpy).toHaveBeenCalledTimes(4); }); + + it("saatlik login tavanı aşılırsa yeni login denenmez", async () => { + const fetchSpy = vi.fn().mockResolvedValue(loginOk()); + vi.stubGlobal("fetch", fetchSpy); + const { svc } = makeService(); + const p = svc as unknown as Exposed; + + for (let i = 0; i < 6; i++) await p.login("de"); + await expect(p.login("de")).rejects.toThrow(/login rate limit/); + expect(fetchSpy).toHaveBeenCalledTimes(6); + }); }); diff --git a/apps/api/src/integrations/pl24/pl24-auth.service.ts b/apps/api/src/integrations/pl24/pl24-auth.service.ts index 91915fe..a5222a2 100644 --- a/apps/api/src/integrations/pl24/pl24-auth.service.ts +++ b/apps/api/src/integrations/pl24/pl24-auth.service.ts @@ -1,49 +1,86 @@ /** * PartsLink24 Authentication Service * - * Handles JWT authentication, token refresh, and session management - * for the partslink24.com API. Supports two accounts: - * - 'tr' (tr-903645): direct connection, primary VAG account - * - 'de' (de-708171): DataImpulse Germany proxy, Fiat + EUR prices + * SESSION MODEL (rewritten 2026-09-17, see /home/s/ss/plv2.md §4.1) + * ---------------------------------------------------------------- + * PL24 allows ONE session per account and its JWTs live only 600s. The old code + * treated the 600s base JWT as the session, so it re-ran `login` with + * squeezeOut:true every ~9 minutes (~144 logins/day) — the automation signature + * that preceded both account bans. PL24's own portal instead: + * + * 1. logs in once → Set-Cookie PL24TOKEN= (no expiry) + * 2. POST /auth/.../authorize with ONLY that cookie → fresh 600s service JWT + * 3. repeats step 2 before each expiry; logs in again only when the server + * says the session is gone (`session_status: "gone"` or HTTP 401). + * + * We now do the same: `ensureSession()` owns the cookie (single-flight, shared + * across processes via Redis) and `authorizeServiceForAccount()` mints service + * tokens from it. Catalog JSON calls need only the Bearer; P4 `.action` pages + * need only the cookie (verified live). + * + * Two accounts are still modelled ('tr' / 'de'); PL24_TR_DISABLED transparently + * maps tr → de while the tr account is inactive on PL24's side. */ import { Injectable, Logger, type OnModuleInit, UnauthorizedException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { RedisService } from "../../redis/redis.service"; -import { PL24_ENDPOINTS } from "./pl24.constants"; +import { PL24_DEFAULTS, PL24_ENDPOINTS, PL24_USER_AGENT } from "./pl24.constants"; import { PL24AuthorizeRequest, PL24AuthorizeResponse, PL24JWTPayload, + PL24LoginProblem, PL24LoginRequest, + PL24LoginRequestV2, PL24LoginResponse, - PL24TokenData, + PL24LoginResponseV2, + PL24Session, + PL24_LOGIN_PROBLEM, } from "./pl24.types"; +type PL24Account = "tr" | "de"; + +interface ServiceToken { + token: string; + /** epoch ms */ + expiresAt: number; +} + +/** Login failures that will not fix themselves — back off for hours, not minutes. */ +const PERMANENT_LOGIN_PROBLEMS: string[] = [ + PL24_LOGIN_PROBLEM.ACCOUNT_NOT_ACTIVE, + PL24_LOGIN_PROBLEM.USER_NOT_ACTIVE, + PL24_LOGIN_PROBLEM.AUTHENTICATION_FAILED, + PL24_LOGIN_PROBLEM.ACCOUNT_PENDING, + PL24_LOGIN_PROBLEM.TWO_FA_REQUIRED, +]; + @Injectable() export class PL24AuthService implements OnModuleInit { private readonly logger = new Logger(PL24AuthService.name); - // ── Account 1 (tr-903645) ────────────────────────────────────────────────── - private tokenData: PL24TokenData | null = null; - private serviceTokens = new Map(); - - // ── Account 2 (de-708171) ────────────────────────────────────────────────── - private tokenData2: PL24TokenData | null = null; - private serviceTokens2 = new Map(); + // ── Session + service tokens, per account ────────────────────────────────── + private sessions: Partial> = {}; + private serviceTokens: Record> = { + tr: new Map(), + de: new Map(), + }; + /** In-process single-flight: concurrent callers share one login. */ + private loginInFlight: Partial>> = {}; private proxyAgent: any = null; // undici.ProxyAgent, lazy-init - // ── tr-outage bridge + login circuit breaker ─────────────────────────────── - // PL24_TR_DISABLED=true: the tr account is inactive on PL24's side ("The - // account is not active", 2026-07-24) — every tr request is transparently - // served by the de account until the flag is removed. The breaker stops the - // pre-outage failure mode where a dead account was retried ~6x/min for weeks. + // ── Login circuit breaker ────────────────────────────────────────────────── private static readonly BREAKER_THRESHOLD = 3; private static readonly BREAKER_COOLDOWN_MS = 5 * 60_000; - private readonly loginBreaker = { + /** A dead/disabled account must not be retried every 5 minutes for weeks. */ + private static readonly BREAKER_PERMANENT_MS = 6 * 60 * 60_000; + private readonly loginBreaker: Record = { tr: { fails: 0, openUntil: 0 }, de: { fails: 0, openUntil: 0 }, }; + /** Hard ceiling on logins per account per hour (PL24 counts sessions, not requests). */ + private static readonly MAX_LOGINS_PER_HOUR = 6; // ── Config ───────────────────────────────────────────────────────────────── private readonly baseUrl: string; @@ -86,62 +123,50 @@ export class PL24AuthService implements OnModuleInit { "PL24_TR_DISABLED=true — routing ALL PL24 traffic (auth + proxy egress) to the de account", ); } - // In-memory auth tokens are cleared on every (re)deploy, so the first VIN - // decode afterwards otherwise pays the ~10s PL24 login + service-authorize - // handshake on the request path. Warm it in the background so users never do. - // Fire-and-forget — never block or fail boot; set PL24_PREWARM=false to disable. + // A (re)deploy clears the in-memory caches, so the first VIN decode would + // otherwise pay the login handshake on the request path. Warm it in the + // background — never block or fail boot. PL24_PREWARM=false disables it. if (process.env.PL24_PREWARM === "false") return; - if (!this.companyCode || !this.username || !this.password) return; + if (!this.hasCredentials("tr") && !this.hasCredentials("de")) return; void this.prewarm().catch((err) => this.logger.warn(`PL24 auth pre-warm error: ${(err as Error).message}`), ); } /** - * Pre-fill the in-memory auth caches: the base account logins (the shared, - * dominant cost) plus the common legacy + top Turkish-market service tokens. - * Idempotent — the underlying methods cache, so this just populates the cache. - * Uses allSettled throughout: a slow/down PL24 degrades gracefully (logged). + * Warm the session (one login at most) plus a single service token. The old + * prewarm fired two parallel logins and eight authorizes; with one session per + * account the parallel logins squeezed each other out (observed live: one of + * the two returned HTTP 500 on every boot). */ async prewarm(): Promise { const t0 = Date.now(); - // 1) Base account JWT + session cookie. login() for "tr" (tokenData) and "de" - // (tokenData2) are independent; the session cookie is warmed as a side effect. - const baseResults = await Promise.allSettled([ - this.getAccessTokenForAccount("tr"), - this.companyCode2 ? this.getAccessTokenForAccount("de") : Promise.resolve(""), - ]); - const baseOk = baseResults.filter((r) => r.status === "fulfilled").length; - - // 2) Common legacy (Ford-legacy JWT flow) + top Turkish P5 service tokens. - // Only if the tr login succeeded, so we don't stampede concurrent logins. - const services = [ - "opel_parts", - "fordt_parts", - "hyundai_parts", - "nissan_parts", - "volvo_parts", - "vw_parts", - "renault_parts", - "toyota_parts", - ]; - let svcOk = 0; - if (baseResults[0].status === "fulfilled") { - const svcResults = await Promise.allSettled( - services.map((svc) => this.authorizeServiceForAccount(svc, "tr")), - ); - svcOk = svcResults.filter((r) => r.status === "fulfilled").length; + const account = this.effectiveAccount("tr"); + if (!this.hasCredentials(account)) { + this.logger.log("PL24 auth pre-warm skipped (no credentials for the active account)"); + return; + } + let sessionOk = false; + let tokenOk = false; + try { + await this.ensureSession(account); + sessionOk = true; + // One authorize proves the session is really alive and seeds the shared + // common-services scope; per-brand tokens are minted lazily on first use. + await this.authorizeServiceForAccount("vw_parts", account); + tokenOk = true; + } catch (err) { + this.logger.warn(`PL24 auth pre-warm: ${(err as Error).message}`); } - this.logger.log( - `PL24 auth pre-warm done in ${Date.now() - t0}ms (logins ${baseOk}/2, services ${svcOk}/${services.length}${ - baseResults[0].status === "fulfilled" ? "" : " — services skipped: tr login failed" - })`, + `PL24 auth pre-warm done in ${Date.now() - t0}ms (account=${account}, session=${ + sessionOk ? "ok" : "failed" + }, serviceToken=${tokenOk ? "ok" : "failed"})`, ); } // ═══════════════════════════════════════════════════════════════════════════ - // ── Public: per-account API ────────────────────────────────────────────── + // ── Account helpers ────────────────────────────────────────────────────── // ═══════════════════════════════════════════════════════════════════════════ private trDisabled(): boolean { @@ -149,90 +174,334 @@ export class PL24AuthService implements OnModuleInit { } /** Map tr → de while PL24_TR_DISABLED is set (and de is configured). */ - private effectiveAccount(account: "tr" | "de"): "tr" | "de" { + private effectiveAccount(account: PL24Account): PL24Account { return account === "tr" && this.trDisabled() && this.companyCode2 ? "de" : account; } - private breakerGate(account: "tr" | "de"): void { - const b = this.loginBreaker[account]; - if (Date.now() < b.openUntil) { + private hasCredentials(account: PL24Account): boolean { + return account === "de" + ? Boolean(this.companyCode2 && this.username2 && this.password2) + : Boolean(this.companyCode && this.username && this.password); + } + + private credentials(account: PL24Account): { account: string; user: string; password: string } { + return account === "de" + ? { account: this.companyCode2, user: this.username2, password: this.password2 } + : { account: this.companyCode, user: this.username, password: this.password }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ── Session (the PL24TOKEN cookie) ─────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════════════ + + private sessionKey(account: PL24Account): string { + return `${PL24_DEFAULTS.CACHE_PREFIX}auth:session:${account}`; + } + + private loginLockKey(account: PL24Account): string { + return `${PL24_DEFAULTS.CACHE_PREFIX}auth:login-lock:${account}`; + } + + private loginRateKey(account: PL24Account): string { + return `${PL24_DEFAULTS.CACHE_PREFIX}auth:login-count:${account}:${new Date().toISOString().slice(0, 13)}`; + } + + /** + * Return a live session for the account, logging in only if we have none. + * Order: memory → Redis (another process may hold it) → login (single-flight). + */ + async ensureSession(requested: PL24Account): Promise { + const account = this.effectiveAccount(requested); + + const inMemory = this.sessions[account]; + if (inMemory) return inMemory; + + const shared = await this.hydrateSessionFromRedis(account); + if (shared) { + this.sessions[account] = shared; + return shared; + } + + const inFlight = this.loginInFlight[account]; + if (inFlight) return inFlight; + + const promise = this.loginWithLock(account).finally(() => { + delete this.loginInFlight[account]; + }); + this.loginInFlight[account] = promise; + return promise; + } + + /** Cookie header value for the account's session. */ + async getSessionCookieForAccount(requested: PL24Account): Promise { + const session = await this.ensureSession(requested); + return `PL24TOKEN=${session.sessionToken}`; + } + + /** PL24TOKEN value (Ford legacy passes it as the `hintstoken` query param). */ + getPL24TokenValueForAccount(requested: PL24Account): string | null { + return this.sessions[this.effectiveAccount(requested)]?.sessionToken ?? null; + } + + /** Forget the session (and its service tokens) — next call logs in again. */ + async dropSessionForAccount(requested: PL24Account, reason: string): Promise { + const account = this.effectiveAccount(requested); + delete this.sessions[account]; + this.serviceTokens[account].clear(); + await this.redis.del(this.sessionKey(account)).catch(() => {}); + this.logger.warn(`PL24 ${account} session dropped (${reason})`); + } + + /** + * Drop only the minted service tokens. Used on a first 401: the cheap fix is a + * fresh authorize, not a new login (which would squeeze our own session out). + */ + clearTokensForAccount(requested: PL24Account): void { + const account = this.effectiveAccount(requested); + this.serviceTokens[account].clear(); + this.logger.log(`PL24 ${account} service tokens cleared (session kept)`); + } + + private async hydrateSessionFromRedis(account: PL24Account): Promise { + try { + const raw = await this.redis.getJson(this.sessionKey(account)); + if (!raw?.sessionToken) return null; + this.logger.log(`PL24 ${account} session hydrated from Redis (login skipped)`); + return raw; + } catch { + return null; + } + } + + private async persistSession(account: PL24Account, session: PL24Session): Promise { + await this.redis + .setJson(this.sessionKey(account), session, PL24_DEFAULTS.SESSION_TTL_S) + .catch(() => {}); + } + + /** Refresh the shared session's TTL after any successful upstream call. */ + private async touchSession(account: PL24Account): Promise { + const session = this.sessions[account]; + if (!session) return; + session.lastOkAt = Date.now(); + await this.redis.expire(this.sessionKey(account), PL24_DEFAULTS.SESSION_TTL_S).catch(() => {}); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ── Login ──────────────────────────────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Cross-process single flight. PL24 kills the previous session on every login, + * so two processes logging in at once leave one of them holding a dead cookie + * (and look like a bot). The Redis lock makes the loser wait for the winner's + * session instead of racing it. + */ + private async loginWithLock(account: PL24Account): Promise { + const gotLock = await this.redis + .setNx(this.loginLockKey(account), String(process.pid), 30) + .catch(() => true); + + if (!gotLock) { + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 500)); + const shared = await this.hydrateSessionFromRedis(account); + if (shared) { + this.sessions[account] = shared; + return shared; + } + } + this.logger.warn(`PL24 ${account} login lock wait timed out — logging in anyway`); + } + + try { + return await this.login(account); + } finally { + if (gotLock) await this.redis.del(this.loginLockKey(account)).catch(() => {}); + } + } + + private async login(account: PL24Account): Promise { + if (!this.hasCredentials(account)) { + throw new UnauthorizedException(`PL24 account (${account}) credentials not configured`); + } + if (account === "tr" && this.trDisabled()) { + // effectiveAccount normally maps tr → de; this guards stray callers so the + // inactive account is never hit while the flag is set. throw new UnauthorizedException( - `PL24 ${account} login breaker open (${b.fails} consecutive failures) — ` + - `retrying after ${new Date(b.openUntil).toISOString()}`, + "PL24 tr login skipped: PL24_TR_DISABLED=true (account inactive on PL24 side)", ); } - } + this.breakerGate(account); + await this.loginRateGate(account); - private breakerFail(account: "tr" | "de"): void { - const b = this.loginBreaker[account]; - b.fails += 1; - if (b.fails >= PL24AuthService.BREAKER_THRESHOLD && Date.now() >= b.openUntil) { - b.openUntil = Date.now() + PL24AuthService.BREAKER_COOLDOWN_MS; + // Ask politely first: squeezeOut:false tells us whether somebody else (our + // other process, or a human in the browser) holds the session. + let result = await this.attemptLogin(account, false); + if (result.sessionLimitExceeded) { this.logger.warn( - `PL24 ${account} login breaker OPEN after ${b.fails} consecutive failures — ` + - `pausing login attempts for ${PL24AuthService.BREAKER_COOLDOWN_MS / 60_000}m`, + `PL24 ${account}: session limit exceeded (another session is active) — retrying with squeezeOut`, ); + result = await this.attemptLogin(account, true); } + + if (!result.sessionToken) { + this.breakerFail(account, result.permanent); + const detail = result.error || "Token alinamadi"; + this.logger.error(`PL24 login (${account}) failed: ${detail}`); + throw new UnauthorizedException(`PL24 (${account}) giris hatasi: ${detail}`); + } + + const session: PL24Session = { + sessionToken: result.sessionToken, + loginAt: Date.now(), + lastOkAt: Date.now(), + }; + this.sessions[account] = session; + await this.persistSession(account, session); + this.breakerOk(account); + this.logger.log(`PL24 login (${account}) successful — session established`); + return session; } - private breakerOk(account: "tr" | "de"): void { - const b = this.loginBreaker[account]; - if (b.fails >= PL24AuthService.BREAKER_THRESHOLD) { - this.logger.log(`PL24 ${account} login breaker reset (login succeeded)`); - } - b.fails = 0; - b.openUntil = 0; - } + private async attemptLogin( + account: PL24Account, + squeezeOut: boolean, + ): Promise<{ + sessionToken?: string; + sessionLimitExceeded?: boolean; + permanent?: boolean; + error?: string; + }> { + const legacy = process.env.PL24_LOGIN_API === "legacy"; + const creds = this.credentials(account); + this.logger.log( + `Logging in to PL24 (${account}${squeezeOut ? ", squeezeOut" : ""}${legacy ? ", legacy api" : ""})...`, + ); - /** Return access token for the given account. */ - async getAccessTokenForAccount(requestedAccount: "tr" | "de"): Promise { - const account = this.effectiveAccount(requestedAccount); - if (account === "de") { - if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) { - await this.login2(); + const body: PL24LoginRequestV2 | PL24LoginRequest = legacy + ? { + authentication: { account: creds.account, user: creds.user, pwd: creds.password }, + device: { id: "0", os: "Windows 10", offset: "0", lang: "en-US", "os-version": "0" }, + "app-version": "", + squeezeOut, + } + : { account: creds.account, user: creds.user, password: creds.password, squeezeOut }; + + try { + const dispatcher = await this.getProxyAgent4Account(account); + const fetchOpts: RequestInit & { dispatcher?: any } = { + method: "POST", + redirect: "manual", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "Cache-Control": "no-cache", + "User-Agent": PL24_USER_AGENT, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(this.timeout), + }; + if (dispatcher) fetchOpts.dispatcher = dispatcher; + + const url = `${this.baseUrl}${legacy ? PL24_ENDPOINTS.LOGIN_LEGACY : PL24_ENDPOINTS.LOGIN}`; + const response = await fetch(url, fetchOpts); + const cookieToken = this.extractSessionToken(response); + + // 400/412 carry an RFC7807 problem body naming the reason. + if (response.status === 400 || response.status === 412) { + const problem = (await response.json().catch(() => ({}))) as PL24LoginProblem; + if (problem.type === PL24_LOGIN_PROBLEM.SESSION_LIMIT_EXCEEDED) { + return { sessionLimitExceeded: true }; + } + return { + permanent: problem.type ? PERMANENT_LOGIN_PROBLEMS.includes(problem.type) : false, + error: problem.detail || problem.title || problem.type || `HTTP ${response.status}`, + }; } - if (!this.tokenData2) throw new Error("PL24 de account login failed"); - return this.tokenData2.accessToken; - } - if (!this.tokenData || !this.isTokenValid(this.tokenData)) { - await this.login(); - } - if (!this.tokenData) throw new Error("PL24 tr account login failed"); - return this.tokenData.accessToken; - } - /** Return session cookie for the given account. */ - async getSessionCookieForAccount(requestedAccount: "tr" | "de"): Promise { - const account = this.effectiveAccount(requestedAccount); - if (account === "de") { - if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) { - await this.login2(); + if (!response.ok && response.status !== 301 && response.status !== 302) { + return { + permanent: response.status === 401 || response.status === 403, + error: `HTTP ${response.status}: ${response.statusText}`, + }; } - if (!this.tokenData2) throw new Error("PL24 de account login failed"); - return this.tokenData2.sessionCookie; + + // The portal endpoint answers {loginStatus, sessionToken}; the legacy one + // {status, token:{access_token}} whose JWT `sid` IS the cookie value. + const data = (await response.json().catch(() => ({}))) as PL24LoginResponseV2 & + PL24LoginResponse; + + if (data.status === "USER_ALREADY_LOGGED_IN") { + return { sessionLimitExceeded: true }; + } + + const sessionToken = + cookieToken || + data.sessionToken || + (data.token?.access_token ? this.decodeJWT(data.token.access_token).sid : undefined); + + if (!sessionToken) { + return { + permanent: data.status === "INVALID_CREDENTIALS", + error: data.message || data.status || data.loginStatus || "no session token in response", + }; + } + return { sessionToken }; + } catch (error) { + const err = error as Error; + if (err.name === "TimeoutError") return { error: "giris zaman asimina ugradi" }; + return { error: err.message }; } - if (!this.tokenData || !this.isTokenValid(this.tokenData)) { - await this.login(); - } - if (!this.tokenData) throw new Error("PL24 tr account login failed"); - return this.tokenData.sessionCookie; } - /** Authorize a service catalog for the given account and return the service token. */ - async authorizeServiceForAccount( - serviceName: string, - requestedAccount: "tr" | "de", - ): Promise { - const account = this.effectiveAccount(requestedAccount); - const cache = account === "de" ? this.serviceTokens2 : this.serviceTokens; + /** PL24TOKEN from Set-Cookie (both login endpoints set it). */ + private extractSessionToken(response: Response): string | undefined { + const headers = response.headers as Headers & { getSetCookie?: () => string[] }; + const all = headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""]; + for (const raw of all) { + const match = raw?.match(/PL24TOKEN=([^;]+)/); + if (match) return match[1]; + } + return undefined; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ── Service tokens (authorize) ─────────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Mint (or reuse) a 600s service JWT for a catalog. Uses ONLY the session + * cookie — no Bearer, no login — exactly like the portal does. + */ + async authorizeServiceForAccount(serviceName: string, requested: PL24Account): Promise { + const account = this.effectiveAccount(requested); + const cache = this.serviceTokens[account]; const cached = cache.get(serviceName); - if (cached && cached.expiresAt > new Date()) { + if (cached && cached.expiresAt - PL24_DEFAULTS.SERVICE_TOKEN_SKEW_MS > Date.now()) { return cached.token; } - const mainToken = await this.getAccessTokenForAccount(account); - const sessionCookie = await this.getSessionCookieForAccount(account); + try { + const token = await this.requestServiceToken(serviceName, account, true); + cache.set(serviceName, { token, expiresAt: this.decodeJWT(token).exp * 1000 }); + await this.touchSession(account); + return token; + } catch (error) { + const err = error as Error; + this.logger.error(`Service authorization error (${account}): ${err.message}`); + throw err instanceof UnauthorizedException + ? err + : new UnauthorizedException(`Servis yetkilendirme hatasi: ${err.message}`); + } + } + private async requestServiceToken( + serviceName: string, + account: PL24Account, + allowRelogin: boolean, + ): Promise { + const cookie = await this.getSessionCookieForAccount(account); this.logger.log(`Authorizing service ${serviceName} for account ${account}`); const authorizeRequest: PL24AuthorizeRequest = { @@ -246,243 +515,121 @@ export class PL24AuthService implements OnModuleInit { "orderBook", "pl24-usage", "pl24-tls-pilot", + // Portal-level WMI/VIN resolution (`/pl24-wmi/ext/api/2.0/decode`) is + // 403 without this grant. + "pl24-wmidata", serviceName, ], serviceCategoryNames: ["pl24-shop-universal", "pl24-shop-tools"], withLogin: true, }; - try { - const dispatcher = await this.getProxyAgent4Account(account); - const fetchOpts: RequestInit & { dispatcher?: any } = { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - Authorization: `Bearer ${mainToken}`, - Cookie: sessionCookie, - }, - body: JSON.stringify(authorizeRequest), - signal: AbortSignal.timeout(this.timeout), - }; - if (dispatcher) fetchOpts.dispatcher = dispatcher; + const dispatcher = await this.getProxyAgent4Account(account); + const fetchOpts: RequestInit & { dispatcher?: any } = { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Cookie: cookie, + "User-Agent": PL24_USER_AGENT, + }, + body: JSON.stringify(authorizeRequest), + signal: AbortSignal.timeout(this.timeout), + }; + if (dispatcher) fetchOpts.dispatcher = dispatcher; - const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.AUTHORIZE}`, fetchOpts); + const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.AUTHORIZE}`, fetchOpts); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = (await response.json()) as PL24AuthorizeResponse; - const accessToken = data.access_token || data.token?.access_token; - if (!accessToken) { - throw new Error("No service token in response"); - } - - const payload = this.decodeJWT(accessToken); - cache.set(serviceName, { - token: accessToken, - expiresAt: new Date(payload.exp * 1000), - }); - - this.logger.log(`Service ${serviceName} authorized for account ${account}`); - return accessToken; - } catch (error) { - const err = error as Error; - this.logger.error(`Service authorization error (${account}): ${err.message}`); - throw new UnauthorizedException(`Servis yetkilendirme hatasi: ${err.message}`); + // A dead session shows up here, not at login time. + if (response.status === 401 || response.status === 403) { + if (!allowRelogin) throw new UnauthorizedException(`HTTP ${response.status} on authorize`); + await this.dropSessionForAccount(account, `authorize HTTP ${response.status}`); + return this.requestServiceToken(serviceName, account, false); } + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = (await response.json()) as PL24AuthorizeResponse; + + if (data.session_status === "gone") { + if (!allowRelogin) throw new UnauthorizedException("PL24 session gone"); + await this.dropSessionForAccount(account, "session_status=gone"); + return this.requestServiceToken(serviceName, account, false); + } + + const accessToken = data.access_token || data.token?.access_token; + if (!accessToken) throw new Error("No service token in response"); + + const scope = (data.scope || "").split(" ").filter(Boolean); + if (scope.length && !scope.includes(serviceName)) { + // Not licensed: PL24 silently drops unknown/unlicensed names from scope. + this.logger.warn(`PL24 ${account}: service ${serviceName} not in granted scope`); + } + this.logger.log(`Service ${serviceName} authorized for account ${account}`); + return accessToken; } - /** Build standard JSON API auth headers for the given account. */ + // ═══════════════════════════════════════════════════════════════════════════ + // ── Headers ────────────────────────────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════════════ + + /** JSON API headers: service JWT as Bearer (+ cookie, harmless and cheap). */ async buildAuthHeadersForAccount( - account: "tr" | "de", + account: PL24Account, serviceName?: string, includeContentType = false, ): Promise> { - const token = serviceName - ? await this.authorizeServiceForAccount(serviceName, account) - : await this.getAccessTokenForAccount(account); - - const sessionCookie = await this.getSessionCookieForAccount(account); - + const cookie = await this.getSessionCookieForAccount(account); const headers: Record = { - Authorization: `Bearer ${token}`, - Cookie: sessionCookie, + Cookie: cookie, Accept: "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept-Language": "tr,en-US;q=0.9,en;q=0.8", + "User-Agent": PL24_USER_AGENT, }; - - if (includeContentType) { - headers["Content-Type"] = "application/json"; + if (serviceName) { + headers.Authorization = `Bearer ${await this.authorizeServiceForAccount(serviceName, account)}`; } - + if (includeContentType) headers["Content-Type"] = "application/json"; return headers; } - /** Build Ford-legacy HTML page auth headers for the given account. */ + /** + * P4 (.action) pages authenticate off the session cookie alone — verified + * live against Ford/Opel/Hyundai with no Bearer at all. Skipping the authorize + * here removes a service-token mint from every legacy page fetch. + */ async buildFordLegacyHeadersForAccount( - serviceName: string, - account: "tr" | "de", + _serviceName: string, + account: PL24Account, ): Promise> { - const token = await this.authorizeServiceForAccount(serviceName, account); - const sessionCookie = await this.getSessionCookieForAccount(account); - + const cookie = await this.getSessionCookieForAccount(account); return { - Authorization: `Bearer ${token}`, - Cookie: sessionCookie, + Cookie: cookie, Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept-Language": "tr,en-US;q=0.9,en;q=0.8", + "User-Agent": PL24_USER_AGENT, }; } - /** Return PL24TOKEN cookie value for the given account (used as Ford hintstoken param). */ - getPL24TokenValueForAccount(requestedAccount: "tr" | "de"): string | null { - const account = this.effectiveAccount(requestedAccount); - const data = account === "de" ? this.tokenData2 : this.tokenData; - if (!data?.sessionCookie) return null; - const match = data.sessionCookie.match(/PL24TOKEN=([^;]+)/); - return match?.[1] || null; - } - /** Return ProxyAgent for account 'de', null for 'tr'. */ - async getProxyAgent4Account(account: "tr" | "de"): Promise { + async getProxyAgent4Account(account: PL24Account): Promise { if (this.effectiveAccount(account) !== "de") return null; return this.getProxyAgent(); } - /** Clear in-memory tokens for the given account. */ - clearTokensForAccount(requestedAccount: "tr" | "de"): void { - const account = this.effectiveAccount(requestedAccount); - if (account === "de") { - this.tokenData2 = null; - this.serviceTokens2.clear(); - this.logger.log("PL24 account 2 (de) tokens cleared"); - } else { - this.tokenData = null; - this.serviceTokens.clear(); - this.logger.log("PL24 account 1 (tr) tokens cleared"); - } - } - // ═══════════════════════════════════════════════════════════════════════════ - // ── Public: legacy API (backwards-compatible, always 'tr') ────────────── + // ── Legacy wrappers (always 'tr', which maps to the live account) ──────── // ═══════════════════════════════════════════════════════════════════════════ - async login(forceNew = false): Promise { - if (!forceNew && this.tokenData && this.isTokenValid(this.tokenData)) { - return this.tokenData; - } - if (!forceNew) { - const cached = await this.hydrateAccountFromRedis("tr"); - if (cached) { - this.tokenData = cached; - return this.tokenData; - } - } - - if (this.trDisabled()) { - // Callers normally never reach here (effectiveAccount maps tr → de); this - // guards stray/legacy paths so the dead account is never hit while flagged. - throw new UnauthorizedException( - "PL24 tr login skipped: PL24_TR_DISABLED=true (account inactive on PL24 side)", - ); - } - this.breakerGate("tr"); - - this.logger.log("Logging in to PL24 (account 1 tr)..."); - - const loginRequest: PL24LoginRequest = { - authentication: { - account: this.companyCode, - user: this.username, - pwd: this.password, - }, - device: { id: "0", os: "Windows 10", offset: "0", lang: "en-US", "os-version": "0" }, - "app-version": "", - squeezeOut: true, - }; - - try { - const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.LOGIN}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - }, - body: JSON.stringify(loginRequest), - signal: AbortSignal.timeout(this.timeout), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = (await response.json()) as PL24LoginResponse; - - if (data.status === "USER_ALREADY_LOGGED_IN") { - this.logger.warn("User already logged in, session squeezed out"); - } - - if (!data.token?.access_token) { - this.logger.error( - `PL24 login failed: ${data.status} - ${data.message || "No token returned"}`, - ); - throw new UnauthorizedException( - `PL24 giris basarisiz: ${data.message || data.status || "Token alinamadi"}`, - ); - } - - const setCookie = response.headers.get("set-cookie"); - const sessionCookie = this.extractSessionCookie(setCookie); - const payload = this.decodeJWT(data.token.access_token); - - this.tokenData = { - accessToken: data.token.access_token, - refreshToken: data.refreshToken || "", - sessionCookie, - expiresAt: new Date(payload.exp * 1000), - services: payload.services || [], - }; - await this.persistAccountToRedis("tr", this.tokenData); - - this.logger.log( - `PL24 login (tr) successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`, - ); - this.breakerOk("tr"); - return this.tokenData; - } catch (error) { - this.breakerFail("tr"); - const err = error as Error; - if (err.name === "TimeoutError") { - throw new UnauthorizedException("PL24 giris zaman asimina ugradi"); - } - this.logger.error(`PL24 login error: ${err.message}`); - throw new UnauthorizedException(`PL24 giris hatasi: ${err.message}`); - } - } - async authorizeService(serviceName: string): Promise { return this.authorizeServiceForAccount(serviceName, "tr"); } - async getAccessToken(): Promise { - return this.getAccessTokenForAccount("tr"); - } - async getSessionCookie(): Promise { return this.getSessionCookieForAccount("tr"); } - getAvailableServices(): string[] { - return this.tokenData?.services || []; - } - - hasService(serviceName: string): boolean { - return this.tokenData?.services.includes(serviceName) || false; - } - clearTokens(): void { this.clearTokensForAccount("tr"); } @@ -503,99 +650,69 @@ export class PL24AuthService implements OnModuleInit { } // ═══════════════════════════════════════════════════════════════════════════ - // ── Private helpers ────────────────────────────────────────────────────── + // ── Breaker + rate gate ────────────────────────────────────────────────── // ═══════════════════════════════════════════════════════════════════════════ - /** Login with account 2 (de-708171) via DataImpulse DE proxy. */ - private async login2(forceNew = false): Promise { - if (!forceNew && this.tokenData2 && this.isTokenValid(this.tokenData2)) { - return this.tokenData2; - } - if (!forceNew) { - const cached = await this.hydrateAccountFromRedis("de"); - if (cached) { - this.tokenData2 = cached; - return this.tokenData2; - } - } - - if (!this.companyCode2 || !this.username2 || !this.password2) { - throw new UnauthorizedException("PL24 account 2 (de) credentials not configured"); - } - - this.breakerGate("de"); - - this.logger.log("Logging in to PL24 (account 2 de)..."); - - const loginRequest: PL24LoginRequest = { - authentication: { - account: this.companyCode2, - user: this.username2, - pwd: this.password2, - }, - device: { id: "0", os: "Windows 10", offset: "0", lang: "en-US", "os-version": "0" }, - "app-version": "", - squeezeOut: true, - }; - - try { - const dispatcher = await this.getProxyAgent(); - const fetchOpts: RequestInit & { dispatcher?: any } = { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - }, - body: JSON.stringify(loginRequest), - signal: AbortSignal.timeout(this.timeout), - }; - if (dispatcher) fetchOpts.dispatcher = dispatcher; - - const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.LOGIN}`, fetchOpts); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = (await response.json()) as PL24LoginResponse; - - if (!data.token?.access_token) { - this.logger.error(`PL24 login (de) failed: ${data.status} - ${data.message || "No token"}`); - throw new UnauthorizedException( - `PL24 (de) giris basarisiz: ${data.message || data.status || "Token alinamadi"}`, - ); - } - - const setCookie = response.headers.get("set-cookie"); - const sessionCookie = this.extractSessionCookie(setCookie); - const payload = this.decodeJWT(data.token.access_token); - - this.tokenData2 = { - accessToken: data.token.access_token, - refreshToken: data.refreshToken || "", - sessionCookie, - expiresAt: new Date(payload.exp * 1000), - services: payload.services || [], - }; - await this.persistAccountToRedis("de", this.tokenData2); - - this.logger.log( - `PL24 login (de) successful. Token expires at ${this.tokenData2.expiresAt.toISOString()}`, + private breakerGate(account: PL24Account): void { + const b = this.loginBreaker[account]; + if (Date.now() < b.openUntil) { + throw new UnauthorizedException( + `PL24 ${account} login breaker open (${b.fails} consecutive failures) — ` + + `retrying after ${new Date(b.openUntil).toISOString()}`, ); - this.breakerOk("de"); - return this.tokenData2; - } catch (error) { - this.breakerFail("de"); - const err = error as Error; - if (err.name === "TimeoutError") { - throw new UnauthorizedException("PL24 (de) giris zaman asimina ugradi"); - } - this.logger.error(`PL24 login (de) error: ${err.message}`); - throw new UnauthorizedException(`PL24 (de) giris hatasi: ${err.message}`); } } + private breakerFail(account: PL24Account, permanent = false): void { + const b = this.loginBreaker[account]; + b.fails += 1; + const cooldown = permanent + ? PL24AuthService.BREAKER_PERMANENT_MS + : PL24AuthService.BREAKER_COOLDOWN_MS; + const threshold = permanent ? 1 : PL24AuthService.BREAKER_THRESHOLD; + if (b.fails >= threshold && Date.now() >= b.openUntil) { + b.openUntil = Date.now() + cooldown; + this.logger.warn( + `PL24 ${account} login breaker OPEN after ${b.fails} failure(s)${ + permanent ? " (account-level rejection)" : "" + } — pausing login attempts for ${Math.round(cooldown / 60_000)}m`, + ); + } + } + + private breakerOk(account: PL24Account): void { + const b = this.loginBreaker[account]; + if (b.fails >= PL24AuthService.BREAKER_THRESHOLD) { + this.logger.log(`PL24 ${account} login breaker reset (login succeeded)`); + } + b.fails = 0; + b.openUntil = 0; + } + + /** + * Hourly login ceiling. With the session model a healthy day needs 0–1 logins; + * anything near this cap means we are fighting for the session again. + */ + private async loginRateGate(account: PL24Account): Promise { + try { + const key = this.loginRateKey(account); + const n = await this.redis.incr(key); + if (n === 1) await this.redis.expire(key, 3600); + if (n > PL24AuthService.MAX_LOGINS_PER_HOUR) { + throw new UnauthorizedException( + `PL24 ${account}: login rate limit reached (${n} this hour) — refusing to log in again`, + ); + } + } catch (err) { + if (err instanceof UnauthorizedException) throw err; + // Redis down → do not block auth on telemetry. + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ── Misc helpers ───────────────────────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════════════ + /** Lazy-init ProxyAgent for the de account. */ private async getProxyAgent(): Promise { if (this.proxyAgent) return this.proxyAgent; @@ -611,38 +728,6 @@ export class PL24AuthService implements OnModuleInit { return this.proxyAgent; } - // ── Redis-backed account-token cache (survives restarts; shared api+worker) ── - // Best-effort: any Redis error falls through to a normal login, so PL24 auth - // never depends on Redis being up. Sharing the token across processes also - // avoids the squeezeOut session-fight (PL24 allows one session per account). - private acctKey(account: "tr" | "de"): string { - return `pl24:auth:acct:${account}`; - } - - private async hydrateAccountFromRedis(account: "tr" | "de"): Promise { - try { - const raw = await this.redis.getJson(this.acctKey(account)); - if (!raw) return null; - const data: PL24TokenData = { ...raw, expiresAt: new Date(raw.expiresAt) }; - if (!this.isTokenValid(data)) return null; - this.logger.log(`PL24 ${account} token hydrated from Redis (login skipped)`); - return data; - } catch { - return null; - } - } - - private async persistAccountToRedis(account: "tr" | "de", data: PL24TokenData): Promise { - const ttlSeconds = Math.floor((data.expiresAt.getTime() - Date.now()) / 1000); - if (ttlSeconds <= 0) return; - await this.redis.setJson(this.acctKey(account), data, ttlSeconds).catch(() => {}); - } - - private isTokenValid(token: PL24TokenData): boolean { - const bufferMs = 60 * 1000; - return token.expiresAt.getTime() - bufferMs > Date.now(); - } - private decodeJWT(token: string): PL24JWTPayload { try { const parts = token.split("."); @@ -654,11 +739,4 @@ export class PL24AuthService implements OnModuleInit { throw new Error("Invalid JWT token"); } } - - private extractSessionCookie(setCookie: string | null): string { - if (!setCookie) return ""; - const match = setCookie.match(/PL24TOKEN=([^;]+)/); - if (match) return `PL24TOKEN=${match[1]}`; - return setCookie.split(";")[0]; - } } diff --git a/apps/api/src/integrations/pl24/pl24-ford-legacy.service.ts b/apps/api/src/integrations/pl24/pl24-ford-legacy.service.ts index 7a861ed..e6c53e4 100644 --- a/apps/api/src/integrations/pl24/pl24-ford-legacy.service.ts +++ b/apps/api/src/integrations/pl24/pl24-ford-legacy.service.ts @@ -116,7 +116,7 @@ export class PL24FordLegacyService { } // Ford / Hyundai-Kia / Nissan / Opel / Volvo devam eder... - await this.authService.authorizeServiceForAccount(serviceName, account); + await this.authService.ensureSession(account); const html = await this.fetchVinGroupPage(vin, serviceName, account); if (!html) return null; @@ -124,8 +124,8 @@ export class PL24FordLegacyService { const support = this.extractScriptVariable(html, "PL24_SUPPORT"); if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") { this.logger.warn(`P4 legacy: demo mode for ${serviceName}, retrying with fresh auth`); - this.authService.clearTokensForAccount(account); - await this.authService.authorizeServiceForAccount(serviceName, account); + await this.authService.dropSessionForAccount(account, "P4 page 401"); + await this.authService.ensureSession(account); const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account); if (!retryHtml) return null; @@ -1110,7 +1110,7 @@ export class PL24FordLegacyService { this.logger.log(`Ford: fetching vehicle list for ${serviceName}`); try { - await this.authService.authorizeService(serviceName); + await this.authService.ensureSession("tr"); const config = getServiceConfig(serviceName); const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`; @@ -1126,8 +1126,8 @@ export class PL24FordLegacyService { let support = this.extractScriptVariable(html, "PL24_SUPPORT"); if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") { this.logger.warn(`Ford: demo mode for ${serviceName}, retrying with fresh auth`); - this.authService.clearTokens(); - await this.authService.authorizeService(serviceName); + await this.authService.dropSessionForAccount("tr", "P4 page 401"); + await this.authService.ensureSession("tr"); const retryHtml = await this.fetchP4Page(vinGroupUrl, serviceName, true); if (!retryHtml) return []; const retrySupport = this.extractScriptVariable(retryHtml, "PL24_SUPPORT"); @@ -1221,7 +1221,7 @@ export class PL24FordLegacyService { this.logger.log(`HyundaiKia: fetching vehicle list for ${serviceName}`); try { - await this.authService.authorizeService(serviceName); + await this.authService.ensureSession("tr"); const config = getServiceConfig(serviceName); const basePath = config @@ -1306,7 +1306,7 @@ export class PL24FordLegacyService { this.logger.log(`Nissan: fetching vehicle list for ${serviceName}`); try { - await this.authService.authorizeService(serviceName); + await this.authService.ensureSession("tr"); const config = getServiceConfig(serviceName); const basePath = config ? `${config.basePath}/${serviceName}` : `/nissan/${serviceName}`; @@ -1377,7 +1377,7 @@ export class PL24FordLegacyService { this.logger.log(`Opel: fetching vehicle list for ${serviceName}`); try { - await this.authService.authorizeService(serviceName); + await this.authService.ensureSession("tr"); const config = getServiceConfig(serviceName); const basePath = config ? `${config.basePath}/${serviceName}` : `/opel/${serviceName}`; @@ -1452,7 +1452,7 @@ export class PL24FordLegacyService { this.logger.log(`Volvo: fetching vehicle list for ${serviceName}`); try { - await this.authService.authorizeService(serviceName); + await this.authService.ensureSession("tr"); const config = getServiceConfig(serviceName); const basePath = config ? `${config.basePath}/${serviceName}` : `/volvo/${serviceName}`; @@ -1556,7 +1556,7 @@ export class PL24FordLegacyService { this.logger.log(`Volvo: fetching model config for ${serviceName} mdl=${mdlId}`); - await this.authService.authorizeService(serviceName); + await this.authService.ensureSession("tr"); try { const config = getServiceConfig(serviceName); @@ -1626,7 +1626,7 @@ export class PL24FordLegacyService { this.logger.log(`Ford: fetching model config for ${serviceName} family=${familyId}`); - await this.authService.authorizeService(serviceName); + await this.authService.ensureSession("tr"); const config = getServiceConfig(serviceName); const arch = config?.architecture; @@ -1785,7 +1785,7 @@ export class PL24FordLegacyService { : `Ford: fetching main groups for ${serviceName} family=${familyId} year=${modelYear} engine=${engine} gearbox=${gearbox}`, ); - await this.authService.authorizeService(serviceName); + await this.authService.ensureSession("tr"); try { const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`; @@ -1957,7 +1957,7 @@ export class PL24FordLegacyService { cacheKey: string, account: "tr" | "de", ): Promise { - await this.authService.authorizeServiceForAccount(serviceName, account); + await this.authService.ensureSession(account); const html = await this.fetchVinGroupPage(vin, serviceName, account); if (!html) return null; @@ -1965,8 +1965,8 @@ export class PL24FordLegacyService { const support = this.extractScriptVariable(html, "PL24_SUPPORT"); if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") { this.logger.warn(`Fiat: demo mode for ${serviceName}, retrying with fresh auth`); - this.authService.clearTokensForAccount(account); - await this.authService.authorizeServiceForAccount(serviceName, account); + await this.authService.dropSessionForAccount(account, "P4 demo page (session not accepted)"); + await this.authService.ensureSession(account); const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account); if (!retryHtml) return null; @@ -2893,7 +2893,7 @@ export class PL24FordLegacyService { private async initPsaSession( serviceName: string, ): Promise<{ jsessionId: string; mode: string; upds: string } | null> { - await this.authService.authorizeService(serviceName); + await this.authService.ensureSession("tr"); const headers = await this.authService.buildFordLegacyHeaders(serviceName); try { @@ -3328,7 +3328,7 @@ export class PL24FordLegacyService { const isDeOnly = LEGACY_DE_SERVICES.has(serviceName); const account = isDeOnly ? "de" : accountParam; if (isDeOnly) { - await this.authService.authorizeServiceForAccount(serviceName, "de"); + await this.authService.ensureSession("de"); } // Some catalogs (Volvo vin-group.action) store hrefs relative to the catalog // directory (e.g. "vin-group.action?group1=…"). Prefix the service basePath @@ -3358,8 +3358,8 @@ export class PL24FordLegacyService { if (response.status === 401) { this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`); - this.authService.clearTokensForAccount(account); - await this.authService.authorizeServiceForAccount(serviceName, account); + await this.authService.dropSessionForAccount(account, "P4 page 401"); + await this.authService.ensureSession(account); const newHeaders = await this.authService.buildFordLegacyHeadersForAccount( serviceName, account, @@ -3392,8 +3392,11 @@ export class PL24FordLegacyService { const support = this.extractScriptVariable(html, "PL24_SUPPORT"); if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") { this.logger.warn(`Ford legacy: demo page for ${serviceName}, re-authing + retry`); - this.authService.clearTokensForAccount(account); - await this.authService.authorizeServiceForAccount(serviceName, account); + await this.authService.dropSessionForAccount( + account, + "P4 demo page (session not accepted)", + ); + await this.authService.ensureSession(account); return this.fetchP4Page(url, serviceName, isFullUrl, account, true); } } @@ -3438,8 +3441,8 @@ export class PL24FordLegacyService { if (response.status === 401) { this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`); - this.authService.clearTokensForAccount(account); - await this.authService.authorizeServiceForAccount(serviceName, account); + await this.authService.dropSessionForAccount(account, "P4 page 401"); + await this.authService.ensureSession(account); const newHeaders = await this.authService.buildFordLegacyHeadersForAccount( serviceName, account, diff --git a/apps/api/src/integrations/pl24/pl24-psa.service.ts b/apps/api/src/integrations/pl24/pl24-psa.service.ts index b3ad64b..099b04f 100644 --- a/apps/api/src/integrations/pl24/pl24-psa.service.ts +++ b/apps/api/src/integrations/pl24/pl24-psa.service.ts @@ -455,7 +455,8 @@ export class PL24PsaService { * entry.action → startup=true (302) → 302 with mode+upds. Returns JSESSIONID/mode/upds. */ private async initPsaSession(serviceName: string): Promise { - await this.authService.authorizeService(serviceName); + // P4 PSA pages authenticate off the session cookie alone — no service token. + await this.authService.ensureSession("tr"); const headers = await this.authService.buildFordLegacyHeaders(serviceName); const dispatcher = await this.psaDispatcher(); const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } => diff --git a/apps/api/src/integrations/pl24/pl24.constants.ts b/apps/api/src/integrations/pl24/pl24.constants.ts index 8c80d52..e1654ee 100644 --- a/apps/api/src/integrations/pl24/pl24.constants.ts +++ b/apps/api/src/integrations/pl24/pl24.constants.ts @@ -1,13 +1,29 @@ export const PL24_DEFAULTS = { - AUTH_TOKEN_TTL: 3600, // 1 hour in seconds + /** Service JWTs live 600s (measured); refresh this many ms before expiry. */ + SERVICE_TOKEN_SKEW_MS: 60_000, + /** PL24TOKEN is a session cookie with no expiry — we keep our copy for a day. */ + SESSION_TTL_S: 86_400, CACHE_PREFIX: "pl24:", REQUEST_TIMEOUT: 30000, MAX_RETRIES: 3, } as const; +/** + * Real Chrome UA. The old value ("…AppleWebKit/537.36" with no Chrome/Safari + * token) matches no real browser and is a cheap automation tell. + */ +export const PL24_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; + export const PL24_ENDPOINTS = { - // Auth - LOGIN: "/pl24-appgtw/ext/api/1.0/login", + // Auth — LOGIN is the portal/landing endpoint (returns {loginStatus, sessionToken} + // + Set-Cookie PL24TOKEN). LOGIN_LEGACY is the in-SPA one our old code used; it + // still works and returns a 600s base JWT we no longer need. PL24_LOGIN_API=legacy + // switches back for one release. + LOGIN: "/auth/ext/api/1.1/login", + LOGIN_LEGACY: "/pl24-appgtw/ext/api/1.0/login", + LOGOUT_LEGACY: "/pl24-appgtw/ext/api/1.0/logout", + SESSION: "/auth/ext/api/1.1/session", AUTHORIZE: "/auth/ext/api/1.1/authorize", // Catalog diff --git a/apps/api/src/integrations/pl24/pl24.types.ts b/apps/api/src/integrations/pl24/pl24.types.ts index 23836af..427df35 100644 --- a/apps/api/src/integrations/pl24/pl24.types.ts +++ b/apps/api/src/integrations/pl24/pl24.types.ts @@ -53,6 +53,58 @@ export interface PL24JWTPayload { alo: string; } +/** Portal login (`/auth/ext/api/1.1/login`) request + response. */ +export interface PL24LoginRequestV2 { + account: string; + user: string; + password: string; + squeezeOut: boolean; + /** Two-factor confirmation code, when PL24 asks for one. */ + code?: string; +} + +export interface PL24LoginResponseV2 { + loginStatus?: "OK" | string; + sessionToken?: string; +} + +/** + * RFC7807 body PL24 returns on a refused login (HTTP 400/412). `type` carries + * the reason; the UI bundle enumerates these. + */ +export interface PL24LoginProblem { + type?: string; + title?: string; + detail?: string; + /** For two-fa-required: "AUTHENTICATOR" | "EMAIL". */ + method?: string; + code?: string; + token?: string; + completionToken?: string; +} + +export const PL24_LOGIN_PROBLEM = { + SESSION_LIMIT_EXCEEDED: "urn:login:session-limit-exceeded", + TWO_FA_REQUIRED: "urn:login:two-factor-authentication-required", + TWO_FA_INVALID: "urn:login:two-factor-authentication-invalid-code", + ACCOUNT_PENDING: "urn:login:account-pending", + PRECONDITION_FAILED: "urn:login:precondition-failed", + ACCOUNT_NOT_ACTIVE: "urn:login:account-not-active", + USER_NOT_ACTIVE: "urn:login:user-not-active", + AUTHENTICATION_FAILED: "urn:login:authentication", +} as const; + +/** + * One PL24 session = the PL24TOKEN cookie. It has no expiry of its own; it dies + * when PL24 squeezes it out (another login on the same account) or is revoked, + * which surfaces as authorize → 401 or `session_status: "gone"`. + */ +export interface PL24Session { + sessionToken: string; + loginAt: number; + lastOkAt: number; +} + export interface PL24TokenData { accessToken: string; refreshToken: string;