LIFF Token Verification
Overview
client-api has no JWT of its own, no session cookie, and no Authorization header. All authentication comes from the LIFF token that client-web attaches as the x-liff-token header (a LINE ID token) and/or x-liff-access-token (a LINE access token); this service verifies those tokens against the LINE Platform itself.
The security hinges on channel binding. A valid token alone only proves that some LINE account exists — it does not prove that the account belongs to the tenant being accessed, because anyone can create their own LINE Login channel and mint tokens from it. Every flow must therefore bind the token to the LINE Login channel of the OA resolved from the :hash or :token in the path first.
Business Flow
Path 1 — liff.Service
Used by menu-builder, public content, tracking redirects, and the form features, via VerifyAndGetLineUser(liffToken, lineOaID).
- Load the
line_oarow matchingstatus='active' AND deleted_date IS NULL. No row means 401 withInvalid channel. - If
line_login_info.channelIdis missing, return 401LINE Login not configured for this channel. - Verify the ID token against the primary channel first. If that fails and
formLiffIdyields a channel different from the primary (taking the substring before the first-), retry with the form channel; otherwise return the primary channel's error. - Look up
line_userby the(user_id, line_oa_id)pair. If none exists, auto-provision a guest immediately:display_namefrom thenameclaim orGuest User,language='th',follow='yes',user_type='guest',last_activity_status='active'. - Return
{userId, lineUser}.
VerifyContentAccess(liffToken, contentLineOaID, contentAudienceIDs) serves audience-restricted content. It differs from the above in two deliberate ways: there is no form-channel fallback (primary only), and failures are 403 rather than 401. It then checks audience overlap through CheckAudienceAccess:
- Content with no audience is public and passes for everyone.
- Content with an audience but a user without one returns 403
You do not have access to this content. - When both are present, access is granted if at least one id overlaps (an OR overlap).
line_user.audience_idsaccepts both[1,2]and[{"id":1}]forms; object elements without anidkey are discarded rather than coerced to 0.
Path 2 — the channel-binding ladder
Used by bulletin and loyalty via resolveUserID(ctx, verifier, channelBinding, liffToken, accessToken), which lives in both bulletin/accessctx.go and loyalty/auth.go — duplicated on purpose so the domains never import each other.
- With neither token present, return 401
No authentication token provided. - If the OA has no primary channel, return 401 (fail closed — there is nothing to bind to).
- If
x-liff-tokenis present, callVerifyIDTokenagainst each channel in the binding, primary first, then form. - If that still fails, try the access-token path with
accessTokenand thenliffToken(some clients send an access token in thex-liff-tokenheader). Introspect first viaGET /oauth2/v2.1/verify?access_token=...and require thatclient_idappears in the binding — if not, reject immediately. Only after that does it call/v2/profile. - Every failure returns 401 with the same message,
Invalid or expired LIFF token, so a bad token cannot be distinguished from a bad channel.
An empty client_id is never treated as "no restriction" and never passes.
Path 3 — direct access-token verification
- appointment: if
liffTokenis present, callverifyAccessTokenwithaccessTokenorliffToken, catching intoverifyAccessToken(liffToken); if onlyaccessTokenis present, verify that; otherwise 401. This path has no channel binding, matching the original source. - friend-track: if
x-liff-tokenis present, callVerifyIDTokenagainst the OA's primary channel directly (a channel is required, otherwise 401); otherwise fall back tox-liff-access-tokenthroughVerifyAccessToken.
Graceful degradation
Some endpoints deliberately swallow verification failures instead of raising an error: menu-builder and content-link listings show only public items, tracking redirects record the click anonymously, and form-builder/:hash/is-submitted simply returns false.
Key Files & Functions
This feature exposes no routes of its own; it is a shared service every domain calls.
| File | Key functions |
|---|---|
internal/liff/liff.go | New(verifier, oaRepo, userRepo), VerifyAndGetLineUser, VerifyContentAccess, CheckAudienceAccess, resolveOrCreateUser, formChannelID |
internal/linehttp/linehttp.go | VerifyIDToken(idToken, channelID) calling POST /oauth2/v2.1/verify; VerifyAccessToken calling GET https://api.line.me/v2/profile (URL hardcoded as in the source); VerifyAccessTokenChannel calling GET /oauth2/v2.1/verify?access_token= |
internal/bulletin/accessctx.go | resolveUserID, ChannelBinding, channelBindingOf, verifyIDTokenBound, verifyAccessTokenBound |
internal/loyalty/auth.go | A copy of the same ladder for the loyalty domain |
internal/lineoa/repository.go | FindActiveByID, FindByHash |
internal/lineuser/repository.go | FindByUserIDAndOA, InsertGuest, UpdateProfileByUserID |
Relevant headers: x-liff-token and x-liff-access-token.
Connections to Other Services
- The
line_oatable (jsonb columnline_login_infoholdingchannelId,lineLiffId,formLiffId) and theline_usertable (audience_ids,user_type,status,custom_attribute). - The LINE Platform:
/oauth2/v2.1/verify(both the POST that verifies an id_token and the GET that introspects an access_token) and/v2/profile. The timeout comes fromLINE_API_TIMEOUTand the base URL fromLINE_API_ENDPOINT_URL. - Consumed by nearly every feature, including Loading a Form's Structure, Form Answer Submission, menu-builder, the content viewers, tracking redirects, bulletin, loyalty, appointment, and friend-track.
- On the client-web side, the counterparts are the
liff-authenticationfeature (which runsliff.init()and login, then forwards the token) andverify-line-login.