Private Docs

FX Online — Report Access Token (Anonymous Post-Submit)

Design: anonymous applicant ดู application report ได้หลัง submit ผ่าน HMAC signed short-lived token — ไม่ต้อง login, ไม่ expose instanceId เป็น secret, stateless

อัปเดต: 2026-06-30

Anonymous applicant ดู/download application report ได้ทันทีหลัง submit onboarding — โดยไม่ต้อง login — ผ่าน signed short-lived token ที่ผูกกับ instanceId และมี TTL 30 นาที


ปัญหา

Flow เดิม (broken)

user submit
  → ExecuteStepActionCommand: ClearCookie = true
  → cookie ถูก clear ใน HTTP response
  → FE navigate → /application-result?ref=FX-...&id={instanceId}
  → FE เรียก GET /onboarding/instances/{id}/application-report
  → 401 ❌  (OnboardingReportController มี [Authorize] class-level)

ทำไม cookie ไม่ได้:
SameSite=Lax บน SIT/UAT/Prod — fetch cross-site (pages.devexim.go.th) ไม่ส่ง cookie เลย
และ clear cookie หลัง submit เป็น intentional design ไม่ใช่ bug

ทำไม GUID ใน URL ไม่พอ:

ค่าลักษณะปัญหา
ref=FX-202606-00046sequentialenumerable → ไม่ใช่ secret
instanceIdใน URLbrowser history, referrer header, access log

Solution: Signed Short-Lived Report Token

หลักการ: issue a secret at submit time, require it at read time

submit → server ออก ReportAccessToken
         └─ FE เก็บใน memory (ไม่ใส่ URL)
         └─ FE เรียก GET /application-report + header: X-Report-Token: <token>
              └─ Backend verify signature + expiry + instanceId → serve

Token Format

Base64Url(
  expiresAtUnixSeconds [8 bytes]        ← big-endian, ดึงออกโดยไม่ต้องทำ crypto ก่อน
  HMAC-SHA256(secret, instanceId[16] || expiresAt[8]) [32 bytes]
)
= 40 bytes raw → 54 Base64Url chars

Security properties:

Propertyทำได้อย่างไร
instanceId bindingHMAC input รวม instanceId bytes — token ของ instance A ใช้กับ B ไม่ได้
ExpiryexpiresAt embed อยู่ใน token, decode ออกมา check ก่อนทำ HMAC
Timing-safeCryptographicOperations.FixedTimeEquals ป้องกัน timing attack
Statelessไม่มี Redis/DB lookup — verify ด้วย HMAC เท่านั้น
ไม่ leaktoken ส่งทาง X-Report-Token header ไม่อยู่ใน URL

Authorization Matrix

CallerJWTX-Report-Tokenผลลัพธ์
Anonymous post-submit✅ valid + not expired✅ 200
Anonymous post-submit✅ expired❌ 403 “ลิงก์หมดอายุแล้ว”
Anonymous post-submit❌ 403
Logged-in owner✅ owner✅ 200
Logged-in non-owner✅ non-owner❌ 403
Admin✅ admin role✅ 200 (admin controller แยก)

Implementation

ไฟล์ที่สร้าง/แก้ (9 ไฟล์ — ไม่มี schema change)

#ไฟล์ActionLayer
1UserService04.Domain/Configuration/ReportTokenSettings.csNEWDomain
2UserService04.Domain/Ports/Security/IReportTokenService.csNEWDomain
3UserService02.Infrastructure/Security/ReportTokenService.csNEWInfrastructure
4UserService01.API/Program.csMODIFY +2 linesAPI
5appsettings.json / appsettings.Development.jsonMODIFY +sectionConfig
6ExecuteStepActionCommand.cs (Result record)MODIFY +1 fieldApplication
7ExecuteStepActionCommand.cs (Handler)MODIFY token issuanceApplication
8GetApplicationReportQuery.csMODIFY +anonymous pathApplication
9OnboardingReportController.csMODIFY [AllowAnonymous] + header readAPI

:::details Domain layer (ไฟล์ใหม่)

ReportTokenSettings.cs

public sealed class ReportTokenSettings
{
    public string SecretBase64 { get; init; } = "";
    public int TtlMinutes { get; init; } = 30;
}

IReportTokenService.cs

public interface IReportTokenService
{
    string Generate(Guid instanceId, DateTimeOffset expiresAt);
    ReportTokenValidationResult Validate(string token, Guid instanceId);
}

public sealed record ReportTokenValidationResult(bool IsValid, bool IsExpired);

:::

:::details Infrastructure — ReportTokenService

Generate:

expiresAtBytes[8] = Unix seconds (big-endian)
hmac = HMAC-SHA256(secret, instanceId_bytes || expiresAt_bytes)
token = Base64Url(expiresAtBytes || hmac)

Validate:

  1. Base64Url decode → check length == 40
  2. Extract expiresAt → ถ้าเลย UtcNowIsExpired=true, IsValid=false
  3. Compute expected HMAC → CryptographicOperations.FixedTimeEquals(expected, actual) :::

:::details Application layer — token issuance ใน handler

ใน ExecuteStepActionCommand Handler เพิ่มที่ submit block:

if (cmd.ActionCode is StepActionCode.Submit or StepActionCode.Resubmit)
{
    if (active is not null) await editSessionRepo.DeactivateAsync(active.SessionId, ct);
    clearCookie = true;
    // เพิ่ม: ออก report token พร้อม submit
    var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_reportTokenSettings.TtlMinutes);
    reportAccessToken = _reportTokenService.Generate(cmd.InstanceId, expiresAt);
}

Result record เพิ่ม optional field:

public sealed record ExecuteStepActionResult(
    StepNavigationDto Nav,
    string? CookieSessionId,
    int CookieTtlSeconds,
    bool ClearCookie = false,
    string? ReportAccessToken = null);   // ← เพิ่ม

:::

:::details Application layer — anonymous path ใน GetApplicationReportQuery

Handler แยก 3 path ตาม caller:

if (q.IsAdmin)
{
    // admin path — ไม่เปลี่ยน
}
else if (q.ReportToken is not null)
{
    // anonymous post-submit path
    var validation = _reportTokenService.Validate(q.ReportToken, q.InstanceId);
    if (!validation.IsValid)
        return Result.Failure<ApplicationReportDto>(
            validation.IsExpired
                ? Error.Forbidden("Report.TokenExpired", "ลิงก์หมดอายุแล้ว กรุณาสมัครใหม่หรือเข้าสู่ระบบ")
                : Error.Forbidden("Report.TokenInvalid", "ไม่มีสิทธิ์เข้าถึงรายงานนี้"));
}
else
{
    // JWT owner path — restore owner-check (เดิม comment ออกใน TESTING PHASE)
    if (q.RequestingUserId is null || instance.OwnerUserId != q.RequestingUserId)
        return Result.Failure<ApplicationReportDto>(
            Error.Forbidden("Application.Forbidden", "ไม่มีสิทธิ์เข้าถึงใบสมัครนี้"));
}

:::

:::details Controller — [AllowAnonymous] + read header

[AllowAnonymous]   // override class-level [Authorize] สำหรับ anonymous post-submit
[HttpGet("onboarding/instances/{id:guid}/application-report")]
public async Task<IActionResult> GetOwnerReport(Guid id, CancellationToken ct)
{
    var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
    Guid.TryParse(userIdStr, out var userId);
    var reportToken = Request.Headers["X-Report-Token"].FirstOrDefault();

    return ApiMatch(await mediator.Send(
        new GetApplicationReportQuery(
            id,
            userId == Guid.Empty ? null : userId,
            IsAdmin: false,
            ReportToken: reportToken),
        ct));
}

:::


IaC & Config

appsettings.json (base — ทุก env inherit):

"ReportToken": {
  "SecretBase64": "",
  "TtlMinutes": 30
}

Key Vault secret name pattern: {env}--user-service--ReportToken--SecretBase64
ค่าเป็น Base64 ของ random 32 bytes — ต้องต่างกันทุก env

EnvSecret
DEVdev--user-service--ReportToken--SecretBase64
SITsit--user-service--ReportToken--SecretBase64
UATuat--user-service--ReportToken--SecretBase64
PRODprod--user-service--ReportToken--SecretBase64 (เข้าผ่าน prod account)

Frontend (ไม่อยู่ใน Backend PR)

FE ต้องทำ 2 อย่าง:

  1. อ่าน reportAccessToken จาก submit response body แล้ว เก็บใน memory (ไม่ใส่ URL/localStorage)
  2. ส่ง เป็น X-Report-Token header ตอนเรียก /application-report

Artifacts (ณ 2026-06-30)

RepoPR / BranchTargetสถานะ
Backend_UserServicePR #984 feature/sprint9/SA-1557-report-tokendevelopmentopen
Backend_IacPR #982 feature/user-service-report-tokendevelopmentopen
sa-onboarding-demobranch feature/report-access-tokenmainPR ยังไม่สร้าง