Skip to main content

LINE Account Link Verification

Overview

/verify-line-login is a standalone page that checks whether the currently signed-in LINE account has been linked to a specified OA. The OA is identified by a webhook key passed in the URL.

This page is noticeably older than the rest of the project. Two things set it apart:

  • It identifies the OA by webhook key rather than the [hash] route segment every other feature uses.
  • It calls APIs through a different set of base URLs (the admin and line services) rather than the client API base URL used elsewhere.

It works best as a diagnostic tool for checking link status, not as a page ordinary users reach through a normal flow.

Business Flow

  1. Open /verify-line-login with the webhook key in the query string.
  2. The page stores the current full URL as its return destination, so the user comes back here after LINE sign-in.
  3. It calls the admin API to exchange the webhook key for a LIFF ID, with a deliberately short cache lifetime of 10 seconds.
  4. It checks whether the webhook key on the page matches the one held in the store. If it does not, the page signs out first and then stores the new key — this prevents a stale profile from a previous OA lingering and producing a wrong verification result.
  5. It stores the LIFF ID and signs in through LIFF to obtain the LINE user ID.
  6. It calls the line API to check whether that user ID is linked to this OA. The endpoint returns a simple true or false, and the request is not cached at all so the result is always current.
  7. The outcome renders on screen, treated as a pass only when the verification result is true and no error occurred while resolving the LIFF ID.

Key Screens & Components

  • Main page (src/app/verify-line-login/page.tsx) — reads the webhook key from the query string and passes it to the container.
  • Container (src/components/verify-line-login/verify-line-login.container.tsx) — orchestrates the whole sequence and renders the result.
  • LIFF ID hook (hooks/use-fetch-liff-id.ts) — exchanges the webhook key for a LIFF ID with a short cache lifetime.
  • Verification hook (hooks/use-verify-line-login.ts) — checks the link status with caching disabled.
  • Service (src/service/verify-line-login.service.ts) — consolidates both API calls in one place.

Endpoints Used

MethodBase URLPath
GETadmin API/line/liff-id?webhook-key=
GETline API/integration/verify-line-login?webhook-key=&line-user-id=

Dependencies

  • LIFF authentication — uses the project's shared hook directly (see liff-authentication).
  • App store — this page is the only place in the project that uses the webhook key field, a trace of the fact that this feature predates the hash-based approach.
  • It sits outside the [hash] route and does not use the client API base URL like other features, so it is worth aligning with current conventions if development continues.

Backend Details (Client API)

This page calls the admin and line APIs rather than client-api directly. But the question it asks — "is this LINE account linked to this OA?" — is the same question client-api must answer on every single request. This section explains how client-api decides it, which is the context that makes the link result matter.

client-api has no session of its own

client-api has no JWT of its own, no session cookie, and no Authorization header. All authentication comes from the LIFF token the web page attaches as x-liff-token (a LINE ID token) and/or x-liff-access-token (a LINE access token), which the service then verifies against the LINE Platform itself.

Channel binding — the core of the security model

A valid token alone only proves that some LINE account exists; it does not prove that the account belongs to this tenant. Anyone can create their own LINE Login channel and mint tokens from it. So a safe flow must first bind the token to the LINE Login channel of the OA resolved from the :hash in the path, and only then let the token mean anything.

This is the same reasoning behind why this page clears the session when the webhook key changes: an identity left over from a previous OA is meaningless for a new one, and reusing it would produce a wrong verification result.

The three verification paths that actually exist in client-api

PathUsed byChannel-bound?
VerifyAndGetLineUserpublic menus, public content, tracking links, the form systemYes (verifies the ID token against the OA's channel)
The channel-binding ladderbulletin board, loyalty cardThe strictest binding
Direct access-token verificationappointment booking, friend trackingNo (preserving legacy behaviour)
  • The first path verifies against the primary channel first, then retries with the form channel when the OA has configured its form LIFF on a different channel. No active OA → 401 "Invalid channel"; an OA with LINE Login unconfigured → 401 with a message saying so.
  • The ladder path tries the ID token against each channel in the binding, and if that fails moves to the access-token route, where it always introspects first and requires the returned client id to be in the binding. If it is not, the request is rejected immediately without ever fetching the profile — and an empty client id does not mean "unrestricted"; it never passes.
  • Every ladder failure returns 401 with the same message (invalid or expired token), so "wrong token" and "wrong channel" are indistinguishable.

Guest auto-provisioning

When verification succeeds but no line_user row exists for that (user, OA) pair, the system creates a guest row immediately: the display name comes from the profile claim (falling back to a generic Guest label), the language is Thai, the follow status is set as followed, and the user type is guest. This is why many features work on a user's very first LIFF open with no registration step.

Access checks for audience-restricted content

Audience-restricted content uses a separate function that differs from the above in two deliberate ways: it has no form-channel fallback (primary channel only), and it answers 403 rather than 401, because this is a permission question rather than an identity one. The rules:

  • Content with no audience → everyone passes (public).
  • Content with an audience but a user with none → 403.
  • Both present → passes if at least one audience id overlaps.
  • A user's audience_ids may be either a list of numbers or a list of objects carrying an id key. An object element missing that key is discarded, not coerced to 0, which would otherwise collide by accident with audience id 0.

Graceful degradation

Some endpoints deliberately swallow verification failures instead of erroring: public menus and content-link listings show only their public items, click tracking links record the click anonymously, and the "has this form already been submitted?" endpoint answers no. These are all conscious choices that a screen must not break because identification failed.