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 ตอน Submit | OTP 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 EditSession | JWT owner match เท่านั้น |
| in-tab หลัง verify = เดินต่อด้วย cookie | auto-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 testBackend_SentinelGatewayService(GW) —60c271c: exchange redeem endpointsa-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 key | literal onboarding:xchg:{code} — raw IConnectionMultiplexer.GetDatabase() (default DB 0) ทั้ง 2 ฝั่ง, ไม่มี service prefix / ไม่ใช่ IDistributedCache |
| Value | camelCase JSON: { accessToken, idToken, refreshToken, expiresInSeconds, oid, email, instanceId, tenantKey } |
| Code | 256-bit CSPRNG (RandomNumberGenerator.GetBytes(32)) → Base64Url |
| TTL | 60 วินาที |
| Single-use | GW 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 exposure | token ดิบไม่แตะ browser (ส่งแค่ code); code/token ห้าม log ทั้ง 2 ฝั่ง (yืนยันแล้ว) |
| Session TTL | RememberMe=false → SessionTimeoutHoursShort (session สั้น) |
Code map:
- US port
IOnboardingExchangeStore(UserService04.Domain/Ports/Onboarding/) + implOnboardingExchangeStore(02.Infrastructure/Onboarding/) - GW port
IOnboardingExchangeReader(SentinelGateway04.Domain/Interfaces/Identity/) + implOnboardingExchangeReader(02.Infrastructure/Identity/) - GW endpoint =
AuthController.OnboardingExchange; reuseAuthSessionService.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 ก่อน verify | start ใหม่ = 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) แล้วเดิน:
- start → OTP → auto-session → เดินต่อ (ไม่เห็นหน้า login)
- ปิด tab กลาง Draft → login →
open→ ต่อได้ - tenantKey parity — ยืนยัน
_tenantKey(US) ==TenantKeyใน GWTenantRegistry(string เดียวกันทุก env) มิฉะนั้น GW คืน 400 ไม่ mint - session refresh parity — เดิน flow จน access token แรกหมดอายุ ยืนยัน session ยังใช้ได้ (token จาก CIAM signup grant refresh ได้จริงไหม)
- 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)