Skip to main content

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).

  1. Load the line_oa row matching status='active' AND deleted_date IS NULL. No row means 401 with Invalid channel.
  2. If line_login_info.channelId is missing, return 401 LINE Login not configured for this channel.
  3. Verify the ID token against the primary channel first. If that fails and formLiffId yields a channel different from the primary (taking the substring before the first -), retry with the form channel; otherwise return the primary channel's error.
  4. Look up line_user by the (user_id, line_oa_id) pair. If none exists, auto-provision a guest immediately: display_name from the name claim or Guest User, language='th', follow='yes', user_type='guest', last_activity_status='active'.
  5. 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_ids accepts both [1,2] and [{"id":1}] forms; object elements without an id key 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.

  1. With neither token present, return 401 No authentication token provided.
  2. If the OA has no primary channel, return 401 (fail closed — there is nothing to bind to).
  3. If x-liff-token is present, call VerifyIDToken against each channel in the binding, primary first, then form.
  4. If that still fails, try the access-token path with accessToken and then liffToken (some clients send an access token in the x-liff-token header). Introspect first via GET /oauth2/v2.1/verify?access_token=... and require that client_id appears in the binding — if not, reject immediately. Only after that does it call /v2/profile.
  5. 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 liffToken is present, call verifyAccessToken with accessToken or liffToken, catching into verifyAccessToken(liffToken); if only accessToken is present, verify that; otherwise 401. This path has no channel binding, matching the original source.
  • friend-track: if x-liff-token is present, call VerifyIDToken against the OA's primary channel directly (a channel is required, otherwise 401); otherwise fall back to x-liff-access-token through VerifyAccessToken.

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.

FileKey functions
internal/liff/liff.goNew(verifier, oaRepo, userRepo), VerifyAndGetLineUser, VerifyContentAccess, CheckAudienceAccess, resolveOrCreateUser, formChannelID
internal/linehttp/linehttp.goVerifyIDToken(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.goresolveUserID, ChannelBinding, channelBindingOf, verifyIDTokenBound, verifyAccessTokenBound
internal/loyalty/auth.goA copy of the same ladder for the loyalty domain
internal/lineoa/repository.goFindActiveByID, FindByHash
internal/lineuser/repository.goFindByUserIDAndOA, InsertGuest, UpdateProfileByUserID

Relevant headers: x-liff-token and x-liff-access-token.

Connections to Other Services

  • The line_oa table (jsonb column line_login_info holding channelId, lineLiffId, formLiffId) and the line_user table (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 from LINE_API_TIMEOUT and the base URL from LINE_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-authentication feature (which runs liff.init() and login, then forwards the token) and verify-line-login.