0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

今回は、LIFFのIDトークンの有効期限(1時間)が切れてしまった場合の対処法についてまとめてみました。
少しでも参考になれば幸いです。

背景:
LIFFアプリを実装した際に、IDトークンを利用してAPI認証を行っていたのですが、
アプリを開いた状態で1時間以上放置(スリープ)したスマホで、すべてのAPI操作が失敗していることに気がついたため、エッジケースではありますが、対策を練ることにしました。

IDトークンとは

LIFF(LINE Front-end Framework)のIDトークンとは、LINEログインしたユーザーの本人情報を表すJSON Web Token(JWT)のことです。

LIFFでは以下のように取得できます。

const idToken = liff.getIDToken()

取得した文字列は、xxxxx.yyyyy.zzzzzのようなJWT形式になっています。

このIDトークンは、アプリのバックエンド(サーバー)が「LINEでログインした正当なユーザー」であることを確認するために使用されます。
LINEで使用できる固有のIDにlineUserIdがありますが、こちらは他人になりすましてアクセス(改ざん)される可能性があります。

IDトークンのライフサイクル

IDトークンはLINEログイン時に発行され、有効期限まで利用できます。
有効期限はLINE公式で1時間と設定されており、変更することはできません。
期限切れになると、新しいIDトークンを取得するためにログインセッションの更新が必要です。

① LIFF起動
      │
      ▼
② liff.init()
      │
      ▼
③ LINEログイン
      │
      ▼
④ IDトークン発行
      │
      ▼
⑤ API呼び出しで利用
      │
      ▼
⑥ 有効期限(exp)まで繰り返し利用
      │
      ▼
⑦ 有効期限切れ
      │
      ▼
⑧ セッション更新・再ログイン
      │
      ▼
⑨ 新しいIDトークン発行

以下の方法では更新できない

liff.init() ❌
LIFF SDKの初期化を行うだけです。保持されている期限切れのトークンを読み込みます。

liff.login() ❌
LIFFアプリを開いた状態で有効期限が切れる→すでにログイン済みのため何も起こりません。

ページのリロード ❌
liff.init()が再実行されますが①の結果になります。

SDKは「保存済みトークンが無い時だけ書き込む」仕様のため、sessionStorageに残った失効トークンが上書きされず、期限切れトークンは残り続けます。

対策方法

有効期限が切れた場合は、サーバーからの401エラーをトリガーに、自動でLIFF URLを再オープンさせます。

  1. liff.logout()でLIFFストア(失効トークン)を一掃します(これがないと再注入トークンがSDKガードに阻まれてしまい、保存されません)
  2. 現在のパスをhttps://liff.line.me/{liffId}{path}で開き直し、LINEアプリにLIFFを再起動させ新トークンを再注入させます('実質再アクセス'をアプリ主導で強制する)
  3. ユーザーが開いていた元のパスに戻るので、以降のAPI操作は成功します

以下、実装例になります(Nuxt/TypeScriptを使用しています)

app/composables/useApi.ts
export function useApi() {
  const { $liff } = useNuxtApp()
  // ID Token 失効時の復旧(LIFF URL 再オープン)。サーバー応答を正トリガーにする。
  const { markAccepted, recoverOnAuthFailure } = useIdTokenRecovery()

  return $fetch.create({
    baseURL: '/api',

    // ----------------------------------------------------------
    // 全リクエスト共通の onRequest フック
    // ----------------------------------------------------------
    onRequest({ options }) {
      // LIFF が未初期化 / 未ログインの場合は ID Token は取れないので
      // ヘッダーを付与しない。サーバー側で 401 が返る。
      if (!$liff?.isLoggedIn?.()) return
      const idToken = $liff.getIDToken()
      if (!idToken) return
      // 既存ヘッダーを保持したまま Authorization を上書きする。
      const headers = new Headers(options.headers as HeadersInit | undefined)
      headers.set('Authorization', `Bearer ${idToken}`)
      options.headers = headers
    },

    // ----------------------------------------------------------
    // 応答フック — ID Token 失効の復旧トリガー(サーバー権威)
    // ----------------------------------------------------------
    // 2xx: そのトークンはサーバー時計で有効と確認された → 再オープンの one-shot
    //      フラグを解除し、後続の再失効でも再度復旧できるようにする。
    onResponse({ response }) {
      if (response.ok) markAccepted()
    },
    // 401: ID Token の失効/無効がサーバー時計で確定 → LIFF URL 再オープンで
    //      新トークンを取り直す(LIFF ブラウザのみ・2xx を受けるまで 1 回)。
    //
    // 「送信したトークンをサーバーが失効/無効と判定した」401 のみを復旧トリガーに
    // する。サーバーは 401 の理由を message で区別しており(server/middleware/auth.ts)、
    // トークン失効/不正は 'invalid id token'、ヘッダー未送信は 'missing bearer token'
    // を返す。復旧は logout + 全画面リダイレクトという破壊的動作なので、肯定条件
    // (fail-closed)で 'invalid id token' のみに限定する。これにより、未送信起因の
    // 401(起動直後でまだ未ログイン等・onRequest の未ログイン early-return)や、
    // 想定外の 401(プロキシ生成・将来の別認証・エラー形式変更)で誤発火しない。
    // message 文字列変更時は復旧が静かに止まるが、失敗方向は「破壊的動作をしない側」
    // なので許容する。安定したエラーコード導入はサーバー側の将来課題。
    onResponseError({ response }) {
      if (response?.status === 401 && response?._data?.message === 'invalid id token') {
        recoverOnAuthFailure()
      }
    },
  })
}
app/composables/useIdTokenRecovery.ts
// ============================================================
// useIdTokenRecovery() — ID Token 失効時の LIFF URL 再オープン復旧
// ------------------------------------------------------------
// 課題:
//   LIFF の ID Token は発行から約1時間で失効するが SDK は自動更新しない。
//   しかも SPA では liff.init() を再実行(window.location.reload())しても
//   トークンは更新されない。@liff/init のクライアント経路は
//     id_token && !getIDToken() && setIDToken(id_token)
//   のように「保存済みトークンが無い時だけ」書き込むため、sessionStorage に
//   残る失効トークンは上書きされず、期限切れトークンを送り続けて 401 になる
//   (=スタンプ取り逃しの原因)。isLoggedIn() は accessToken の有無しか見ず
//   ID Token 期限を見ないため、true のままガードも素通りする。
//
// 方針(LIFF URL 再オープン):
//   サーバーが 401 を返した=トークン失効/無効が確定した時に復旧する。
//     1. liff.logout() で LIFF ストア(sessionStorage の失効トークン含む)を一掃する。
//        ※ これを挟まないと、再注入された新トークンが上記ガードに阻まれ保存されない。
//     2. 現在のパスへ LIFF URL(https://liff.line.me/{liffId}{path})で開き直す。
//        LINE アプリが LIFF を再起動し新しい ID Token を再注入する。ストアは空なので
//        SDK の「未保存なら書き込む」ガードを通り、新トークンが保存される。
//   元のパスへ戻すため、取り逃した acquire 等はページ再マウントで再発火して復旧する。
//
// なぜ reload / liff.login() ではないか:
//   - reload: 上記のとおり init が失効トークンを据え置くため更新されない。
//   - liff.login(): in-app browser では isLoggedIn()===true のため init の code 交換
//     (新トークン保存経路)が発火せず、やはり更新されない。
//
// ループ・多重対策:
//   - 唯一の時刻権威はサーバー。401 のみを復旧トリガーにし、クライアント時計に依存しない。
//   - sessionStorage の one-shot フラグで、2xx を受けるまで再オープンは 1 回に絞る
//     (恒久的な 401 で無限に開き直すのを防ぐ)。2xx 受信(markAccepted)で解除し、
//     セッションが長寿命でも後続の再失効に対して再度復旧できるようにする。
//   - フラグを永続化できない(ストレージ利用不能な WebView)場合は再オープンしない
//     (持ち越せずループするため)。LIFF は storage 前提のため稀。
//   - LIFF ブラウザ(isInClient)以外は対象外。外部/LINE内ブラウザは
//     OpenInLineDialog ゲートに委ねる。
// ============================================================

/** 「再オープン試行済み(まだ 2xx を確認できていない)」を持ち越す sessionStorage キー。 */
const REOPEN_TRIED_KEY = 'idtoken-reopen-tried'

export function useIdTokenRecovery() {
  const { $liff } = useNuxtApp()
  const config = useRuntimeConfig()

  /** sessionStorage は WebView 設定で例外を投げるため安全に読む。 */
  function reopenTried(): boolean {
    try {
      return sessionStorage.getItem(REOPEN_TRIED_KEY) === '1'
    } catch {
      return false
    }
  }

  /** 試行済みフラグを立てる。永続化できたか(=再オープンしてよいか)を返す。 */
  function markReopenTried(): boolean {
    try {
      sessionStorage.setItem(REOPEN_TRIED_KEY, '1')
      return true
    } catch {
      return false
    }
  }

  /**
   * サーバーが 2xx を返した=そのトークンは有効だとサーバー時計で確認された、という
   * authoritative シグナル。one-shot フラグを解除し、次に失効した際の復旧を再び可能にする。
   */
  function markAccepted(): void {
    try {
      sessionStorage.removeItem(REOPEN_TRIED_KEY)
    } catch {
      // 解除できなくても機能は継続する(次に 2xx を確認できた時に再度消しにいく)。
    }
  }

  /**
   * サーバーが 401 を返した時の復旧。LIFF ストアを一掃し LIFF URL で開き直す。
   * LIFF ブラウザのみ・2xx を受けるまで 1 回だけに絞る。
   */
  function recoverOnAuthFailure(): void {
    if (!import.meta.client) return
    const liffId = config.public.liffId
    if (!liffId) return
    // 外部ブラウザは OpenInLineDialog ゲートに委ねる(再オープンしても LINE アプリ起動になり不適)。
    if (!$liff?.isInClient?.()) return
    // 既に開き直し済み(まだ 2xx 未確認)→ これ以上は開かない(恒久 401 でのループ防止)。
    if (reopenTried()) return
    // フラグを持ち越せない環境では開かない(持ち越せずループするため送信/ゲートに委ねる)。
    if (!markReopenTried()) return

    // 失効トークンを含む LIFF ストアを一掃する。
    // これをしないと再注入トークンが init の「未保存なら書き込む」ガードに阻まれ保存されない。
    try {
      $liff.logout?.()
    } catch {
      // logout に失敗しても再オープンは続行する(新規起動側で復旧を試みる)。
    }

    // 現在のパス(クエリ含む・hash 除く)へ LIFF URL で開き直す。
    // hash には失効した LIFF トークンが載る場合があるため引き継がない。
    const path = window.location.pathname + window.location.search
    window.location.href = `https://liff.line.me/${liffId}${path}`
  }

  return { markAccepted, recoverOnAuthFailure }
}

その他の対策方法

以下、ネットで見つけることのできた他の方の対策記事になります。
いろいろな方法があり、とても参考になりました。

  • liff sdkが使用しているlocalStorageの値を編集する

  • 有効期限が切れたタイミングでアプリを再度開いてもらうようにユーザーを促す

今回は以上になります!
最後までお読みいただきありがとうございました。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?