Private Docs

Onboarding Resume Model — Active-at-OTP + Cookieless Auto-Session

โมเดลใหม่ของการ resume onboarding: บัญชี Entra active ตั้งแต่ OTP verify, เลิกใช้ cookie __Host-onboarding, resume ด้วย login→open, และ in-tab continuation ผ่าน one-time exchange code (US mint → Sentinel Gateway redeem → session cookie). พร้อม sequence/activity diagram, exchange contract, endpoint ที่เปลี่ยน, edge cases และ pre-prod gate

อัปเดต: 2026-07-03

เอกสารนี้อธิบาย โมเดลใหม่ของการ resume onboarding หลังเลิกใช้ cookie __Host-onboarding — เขียนจากโค้ดจริงที่ implement แล้ว (ไม่ใช่แผน) สำหรับ developer ที่จะรับงานต่อ (deploy + dev E2E + IaC parity)


1. เปลี่ยนอะไร (สรุป)

เดิมใหม่
OTP verify → deactivate Entra ทันที (orphan-prevention) → activate ตอน SubmitOTP verify → บัญชี active เลย (ไม่มี deactivate/activate cycle)
resume ระหว่าง Draft = พึ่ง cookie __Host-onboarding (login ไม่ได้เพราะ Entra ปิด)resume ทุกกรณี = login → open(id) เส้นเดียว (uniform Draft/Submitted/Rework)
FlowAccessGuard authorize ด้วย JWT owner หรือ cookie EditSessionJWT owner match เท่านั้น
in-tab หลัง verify = เดินต่อด้วย cookieauto-session ผ่าน one-time exchange code (ไม่มีหน้า login/OTP รอบ 2)

เหตุผลที่ต้องเลิก deactivate ด้วย: ที่ผ่านมา cookie จำเป็นเพราะช่วง Draft บัญชีถูกปิด login ไม่ได้ — cookie เป็นทางเดียวพิสูจน์ตัวตน พอให้บัญชี active ตั้งแต่ verify → login ได้ตลอด → cookie ไม่จำเป็นอีก

Repos ที่กระทบ (branch feature/onboarding-cookie-removal ทั้งหมด):

  • Backend_UserService (US) — 6 commits: active-at-otp, guard, cookie plumbing, orphan sweep, exchange mint, contract test
  • Backend_SentinelGatewayService (GW) — 60c271c: exchange redeem endpoint
  • sa-onboarding-demo (FE) — f12727d: cookie ออก + exchange call + entry flow ใหม่

2. Sequence — happy path (start → OTP → auto-session → เดินต่อ)

sequenceDiagram
    autonumber
    participant FE as Angular (sa-onboarding-demo)
    participant APIM as APIM
    participant GW as Sentinel Gateway
    participant BE as UserService
    participant Entra as Entra CIAM
    participant Redis as Redis (shared, DB 0)

    rect rgb(230,245,255)
    Note over FE,Redis: Phase 1 — Start + OTP (anonymous, capability = instanceId)
    FE->>BE: POST /onboarding/instances { flowCode, email?, code? }
    BE->>BE: create EditSession (email carrier, server-internal)
    BE-->>FE: 200 { nav }  (ไม่มี editSessionId / ไม่มี Set-Cookie)
    FE->>BE: POST .../OtpVerificationStep/actions/SaveDraft { email }
    BE->>Entra: SignUpStart → OTP
    BE-->>FE: 200 { refCode }
    FE->>BE: POST .../OtpVerificationStep/actions/Next { otp, refCode }
    BE->>Entra: SignUpContinueWithOtp → บัญชี ACTIVE (ไม่ deactivate)
    BE->>BE: CreateUser + BindOwner + rotate EditSession (owner-bound row = TTL clock)
    BE->>Redis: SET onboarding:xchg:{code} = {tokens,oid,email,instanceId,tenantKey} EX 60 (raw conn)
    BE-->>FE: 200 nav.output { status:VERIFIED, userId, exchangeCode }
    end

    rect rgb(255,245,230)
    Note over FE,Redis: Phase 2 — Auto-session (ไม่มี OTP รอบ 2 / ไม่มีหน้า login)
    FE->>APIM: POST /sentinel/auth/onboarding/exchange { code }  (withCredentials)
    APIM->>GW: forward
    GW->>Redis: GETDEL onboarding:xchg:{code} (atomic single-use)
    GW->>GW: IsTenantValid(tenantKey) → AuthSessionService.CreateSessionAsync (scheme = Cookies.{tenantKey})
    GW-->>FE: 200 + Set-Cookie (session cookie: session_id เท่านั้น, tokens อยู่ server-side store)
    FE->>APIM: POST /onboarding/instances/{id}/open  (cookie)
    APIM->>APIM: resolve-session cookie → Bearer JWT
    APIM->>BE: open(id) + JWT
    BE->>BE: FlowAccessGuard: JWT owner == OwnerUserId → ผ่าน
    BE-->>FE: 200 nav (step ถัดจาก OTP)
    end

    rect rgb(245,230,255)
    Note over FE,BE: Resume ทุกกรณี (ปิด tab/เครื่อง — Draft/Submitted เหมือนกัน)
    FE->>Entra: login
    FE->>BE: GET /onboarding/instances (JWT) → เลือกใบ
    FE->>BE: POST /onboarding/instances/{id}/open (JWT) → 200 nav ต่อ
    end

Fallback (auto-session ล้ม): ถ้า exchangeCode ไม่มี (mint ล้ม best-effort) หรือ exchange call error → FE เดินเส้น resumeById เดิม (open(id) → 403 + loginUrl → redirect login) — ไม่ hard-crash


3. Activity — FE entry ที่ bare /onboarding

flowchart TD
    Start([เข้า /onboarding]) --> P{query param?}
    P -- "?id=" --> Open["resumeById → open(id) → 403+loginUrl → login → open"]
    P -- "?code=" --> Invite["startFromInvite ตรง (ไม่ reconcile session)"]
    P -- "?flowCode=" --> Self[resolveFlowAccess เดิม]
    P -- ไม่มี --> Auth{login แล้ว?}
    Auth -- ใช่ --> WS[/corporate-workspace/]
    Auth -- ไม่ --> Email["email-entry STANDARD ใหม่เสมอ<br/>(ตัด getCurrentSession/cookie-resume)"]

4. Exchange-code contract (US mint ↔ GW redeem) — LOCKED

หัวใจของ auto-session — ทั้งสองฝั่งต้องตรงกันเป๊ะ (มี unit test ผูก contract ทั้ง 2 repo)

ด้านค่า
Redis keyliteral onboarding:xchg:{code}raw IConnectionMultiplexer.GetDatabase() (default DB 0) ทั้ง 2 ฝั่ง, ไม่มี service prefix / ไม่ใช่ IDistributedCache
ValuecamelCase JSON: { accessToken, idToken, refreshToken, expiresInSeconds, oid, email, instanceId, tenantKey }
Code256-bit CSPRNG (RandomNumberGenerator.GetBytes(32)) → Base64Url
TTL60 วินาที
Single-useGW StringGetDeleteAsync (Redis GETDEL, atomic)
Mint (US)best-effort ใน OtpVerificationStepHandler.VerifyOtpAsync — store ล้ม = exchangeCode null, verify ยังสำเร็จ (FE fallback login)
Redeem (GW)POST /sentinel/auth/onboarding/exchange [AllowAnonymous] → validate code shape → GETDEL → IsTenantValid(tenantKey)AuthSessionRequest { AuthMethod="onboarding-otp", RememberMe=false, ... }IAuthSessionService.CreateSessionAsync(HttpContext, req)
Token exposuretoken ดิบไม่แตะ browser (ส่งแค่ code); code/token ห้าม log ทั้ง 2 ฝั่ง (yืนยันแล้ว)
Session TTLRememberMe=falseSessionTimeoutHoursShort (session สั้น)

Code map:

  • US port IOnboardingExchangeStore (UserService04.Domain/Ports/Onboarding/) + impl OnboardingExchangeStore (02.Infrastructure/Onboarding/)
  • GW port IOnboardingExchangeReader (SentinelGateway04.Domain/Interfaces/Identity/) + impl OnboardingExchangeReader (02.Infrastructure/Identity/)
  • GW endpoint = AuthController.OnboardingExchange; reuse AuthSessionService.CreateSessionAsync (มีอยู่แล้ว เพิ่งมี caller ตัวแรก)

5. Endpoint / contract ที่เปลี่ยน

Endpoint / fieldเปลี่ยน
GET /onboarding/session (US)ลบทิ้ง (ผูก cookie ล้วน; ไม่มี consumer)
POST /onboarding/instances resultตัด editSessionId + sessionTtlSeconds → เหลือ { nav }
OTP Next step outputเพิ่ม exchangeCode{ status:VERIFIED, userId, exchangeCode }
POST /sentinel/auth/onboarding/exchange (GW)ใหม่ — redeem code → session cookie
Set-Cookie __Host-onboardingหายทั้งหมด (US ไม่ set, FE ไม่เขียน)
FlowAccessGuard.AuthorizeAsyncตัด param editSessionId + cookie branches → 3 ผลลัพธ์: owner-less+identity=capability(instanceId) · owner-less+อื่น=Forbidden · owner-bound=JWT owner match

6. Edge cases

กรณีผล
ปิด tab ก่อน OTP verifyใบ owner-less resume ไม่ได้ (เริ่มใหม่); row + Entra inactive สะสม (cleanup พัก — ดู §7)
ปิด tab หลัง verify (Draft)login → workspace → open → ต่อได้ ✓ (เดิมทำไม่ได้)
auto-session ล้ม (code หมด/exchange error)fallback → open→403→login→open (ไม่เสียใบ)
?code= reload ก่อน verifystart ใหม่ = Draft ซ้ำสะสม (ยอมรับใน dev; ทางแก้ทีหลัง: BE resolve ?code ที่ bind แล้วคืนใบเดิม)
อีเมลเดิมเริ่ม onboarding ซ้ำ (บัญชี active ค้าง)SignUpStart fail “มีบัญชีแล้ว กรุณา login” — ถูกต้องตาม model ใหม่ (returning user ต้อง login→open ไม่ใช่ re-signup); test re-run cold ต้อง clear มือ (ดู §7)

7. ต้องทำก่อน merge / prod (สำคัญ)

Dev E2E (ทำ local ไม่ได้ — ไม่มี GW/APIM local)

รันบน dev (deploy GW→FE→US) แล้วเดิน:

  1. start → OTP → auto-session → เดินต่อ (ไม่เห็นหน้า login)
  2. ปิด tab กลาง Draft → login → open → ต่อได้
  3. tenantKey parity — ยืนยัน _tenantKey (US) == TenantKey ใน GW TenantRegistry (string เดียวกันทุก env) มิฉะนั้น GW คืน 400 ไม่ mint
  4. session refresh parity — เดิน flow จน access token แรกหมดอายุ ยืนยัน session ยังใช้ได้ (token จาก CIAM signup grant refresh ได้จริงไหม)
  5. cookie Domain — ยืนยัน session cookie ที่ GW ออก ถูกส่งกลับตอน open ผ่าน APIM (Domain/host ตรง) ไม่งั้น 403 วน

Pre-prod gate (บังคับ)

  • Re-enable FlowInstanceOrphanCleanupService (uncomment DI ที่ 02.Infrastructure/DependencyInjection.cs) — ตอนนี้ พักไว้: active-at-OTP + cleanup พัก = ใบ verified-แล้ว-ทิ้ง เหลือบัญชี Entra active ค้าง ไม่มีตัวเก็บ (นอกจาก explicit Cancel/Abandon). cleanup harvest ผ่าน EditSession TTL (owner-bound rotate row ยังสร้าง = clock ยังเดิน) → teardown ลบ Entra+User (Graph delete ไม่สน accountEnabled)
  • Same-email test cleanup: cold-restart อีเมลเดิม → ลบ platform User row + Entra object ด้วยมือ (หรือ login เข้า resume ใบเดิม)

IaC parity (CI override appsettings ด้วยค่า IaC)

  • ลบ section OnboardingCookie ใน IaC config ของ US ทุก env ที่มี (appsettings ในโปรเจกต์ลบแล้ว: base/Development/Local/Sit; Uat/Prod ไม่มี)
  • เพิ่ม APIM route POST /sentinel/auth/onboarding/exchange (attribute [EnableRateLimiting("sentinel_policy")] มีแล้ว แต่ app.UseRateLimiter() ยังไม่ wire ใน GW pipeline → rate-limit จริงพึ่ง APIM edge; brute-force กันด้วย 256-bit single-use อยู่แล้ว)

8. Security notes

  • Cross-tenant (SG-04 class): cookie scheme มาจาก payload.tenantKey + gate ด้วย IsTenantValid ก่อน mint — tenant ที่ไม่รู้จัก = 400 ไม่ mint
  • CSRF/login-fixation: endpoint [AllowAnonymous] mint session; credential = code (bearer, single-use). JSON-body POST บังคับ CORS preflight ข้าม origin (กัน simple CSRF); SameSite ของ cookie จัดการโดย AuthSessionService. residual (attacker หลอก victim redeem code ตัวเอง) = ยอมรับได้สำหรับ onboarding flow
  • exchangeCode persistence (residual, low): step output {...,exchangeCode} ถูก persist ลง FlowInstanceStepData (engine contract). token ดิบไม่อยู่ DB — เก็บแค่ code (pointer) ที่ single-use + 60s TTL. future hardening = out-of-band transient field (ไม่ persist code) ถ้าต้อง defense-in-depth ของ refresh token

9. ADR ที่เกี่ยวข้อง

  • ADR-0034 (activate-entra-at-submit) → superseded — ไม่มี deactivate→activate cycle แล้ว (บัญชี active ตั้งแต่ verify; Submitted/Rework/Rejected ยัง login ได้)
  • ADR-0023 (identity-step-contract) → amended — เปลี่ยนเฉพาะ sub-decision “Next → deactivate identity” + cleanup class (ii); single identity step / owner-less start / [AllowAnonymous] / rate-limit ยังคงเดิม
  • ADR-0037 (active-at-otp-cookieless-resume) — ADR ใหม่ documenting โมเดลนี้ (Atlas repo)