LIFFを使った出欠管理のテスト画面で、再ログインを求められるのに、ログインボタンを押しても予定画面へ進めないことがありました。
調べると、サーバーに拒否されたIDトークンを、ブラウザー側がログイン済みと判断したまま繰り返し送っていました。修正では、認証拒否を受けたときだけ外部ブラウザー側のLINEログイン状態を解除し、新しい認証へ進めるようにしました。自動ログインからは即座にリダイレクトせず、ボタン操作を待ちます。
ポイントは、再試行の回数よりも、何をやり直すのかを分けることでした。同じトークンの再送、LINEの再認証、アプリのセッション更新は、それぞれ違う処理です。
ログイン済みなのにサーバーには拒否される
画面では、LIFF SDKを初期化した後、liff.isLoggedIn()がtrueならliff.getIDToken()を取得して、自分のサーバーのログインAPIへ送っていました。サーバーが検証に成功したら、アプリ用のセッションで予定を読み込む構成です。
問題が起きたときも、LIFF側ではログイン済みでした。そのため、ボタンを押すたびに同じ経路へ入り、認証を新しく始める処理に進みませんでした。
この状態から分かるのは、「LIFF側にログイン状態があること」と「自分のサーバーがその認証情報を受け入れること」を同じ判定にしてはいけない、ということです。今回、拒否された理由が有効期限切れか、別の失効理由かまでは断定していません。復帰処理は、自分のログインAPIが返すHTTPステータスを基準にしました。
この記事の前提は、LIFF SDK 2.x、openidスコープ、IDトークンを検証してアプリのセッションを作るサーバーです。SDKの初期化は完了しており、登録済みエンドポイントに合う戻り先URLを用意しているものとします。
なお、ユーザーIDやクライアントで展開したプロフィールを送るだけで本人確認を済ませる方式にはしません。IDトークンをサーバーで検証する構成については、LINEの公式資料を参照してください。
最初にHTTPステータスを残す
修正前の通信処理は、失敗をすべて普通のErrorへ変換していました。呼び出し側にはメッセージしか残らず、認証拒否と一時的な接続障害を分けられませんでした。
そこで、エラーにHTTPステータスを残しました。以下は記事用の最小例です。元の実装に加え、エラー本文がJSONでなくてもHTTPステータスは維持する形にしています。
export async function postJson(url, payload, fetcher = fetch) {
const response = await fetcher(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const error = new Error(`ログイン処理に失敗しました(${response.status})`);
error.status = response.status;
throw error;
}
if (data === null) throw new Error('ログイン応答の形式を確認してください');
return data;
}
ここで使う401は、自分のサーバーのログインAPIが「認証情報を受け入れられない」と返すステータスです。LINE SDKの例外コードや、業務APIの権限不足とは分けます。
一時的な503やネットワークエラーまで再ログインへ変換すると、認証情報が有効でもログアウトさせてしまいます。サーバー側も、上流サービスの一時障害を認証拒否へ一律変換しない契約にしておく必要があります。フロント側が判定できるのは、受け取った応答の範囲だからです。
自動実行とボタン操作で復帰先を変える
次に、ログイン処理を「起動時の自動確認」と「本人がボタンを押したとき」に分けました。
自動確認で拒否された場合は、古い状態を解除してボタン操作を待ちます。認証から戻ったページでも同じ自動処理が動くため、失敗するたびに自動でログイン画面へ送ると、戻っては送る動きを繰り返すからです。
ボタン操作で拒否された場合は、新しいLINE認証へ進めます。automaticを引数にしておけば、呼び出し元の意図を曖昧にせずに済みます。
export function createLoginFlow({
liff, exchangeToken, clearIdentity, applyIdentity, showStatus, redirectUri,
}) {
let busy = false;
function recover(refresh, automatic) {
clearIdentity();
if (liff.isInClient()) {
showStatus('LINEの画面を閉じて、LIFF URLから開き直してください');
return 'reopen';
}
if (refresh && liff.isLoggedIn()) liff.logout();
if (automatic) {
showStatus('ログインボタンを押して認証をやり直してください');
return 'waiting';
}
liff.login({ redirectUri });
return 'redirecting';
}
return {
async login({ automatic = false } = {}) {
if (busy) return 'busy';
busy = true;
try {
if (!liff.isLoggedIn()) return recover(false, automatic);
const jwt = liff.getIDToken();
if (!jwt) return recover(true, automatic);
applyIdentity(await exchangeToken(jwt));
return 'authenticated';
} catch (error) {
if (error.status === 401) {
try {
return recover(true, automatic);
} catch {
showStatus('ログインの再開に失敗しました。時間をおいてお試しください');
return 'error';
}
}
showStatus('接続に失敗しました。時間をおいてお試しください');
return 'error';
} finally {
busy = false;
}
},
};
}
exchangeTokenには、受け取ったjwtでpostJson('/api/login', { idToken: jwt })を呼ぶ関数を渡します。検証とアプリのセッション発行はサーバー側で行い、applyIdentityは成功応答を受けた後だけ実行します。この例は画面の復帰制御を切り出したもので、サーバーの認証実装を置き換えるものではありません。
clearIdentityは画面に残る利用者情報や権限表示を消す処理です。サーバー上のセッションを失効させるAPIとは別に用意します。未保存入力を破棄するアプリでは、画面を離れる前の退避や確認も別途設計します。
起動時は、まず既存のアプリ用セッションを確認します。それが使えず、LINEの認証からやり直す段階でflow.login({ automatic: true })を呼びます。ボタンには次のように引数を固定して登録します。
loginButton.addEventListener('click', () => flow.login());
loginButton.addEventListener('click', flow.login)のように直接渡すと、クリックイベントが引数へ入ります。自動実行かどうかの判定へイベントを混ぜないようにしました。
busyは、ログインAPIの応答待ちに連打されても処理を重ねないためのものです。ページをまたぐ状態ではなく、リダイレクト後のループを止めるのは、あくまで自動実行ではボタン操作を待つ方針です。
LIFFブラウザーでは外部向けログインを呼ばない
外部ブラウザーとLIFFブラウザーで、同じ復帰処理を実行しないことも必要でした。
LIFFブラウザーでは、liff.init()でログイン処理が行われます。公式リファレンスでは、この環境でliff.login()は利用できないとされています。今回の例ではliff.isInClient()で分岐し、閉じてLIFF URLから開き直す案内を出すところで止めています。
外部ブラウザーでは、拒否されたLINEログイン状態をliff.logout()で解除し、本人の操作からliff.login()へ進めます。これはアプリ内のログイン状態を扱う処理で、LINEアプリ全体からのログアウトを意味する説明にはしません。
IDトークンが取れない場合も同じ復帰経路にしていますが、openidスコープや認可設定の不足なら再認証を繰り返しても直りません。毎回同じ状態になる場合は、設定も確認する必要があります。
ログイン画面を抜けた後まで確認する
元のテスト環境では、同じアカウントでLINEのログイン画面を経由して復帰し、予定の読込、回答変更、保存、シートからの読戻し、集計への反映まで確認しました。回答を元に戻して再保存した後も、再読込でログイン状態と回答が保たれていました。
ログインAPIが成功しただけでは、アプリが使える状態に戻ったとは限りません。今回も、画面の表示と保存先の結果を照合するところまでを確認範囲にしました。
一方で、古い認証情報が拒否された理由を調べるために、生のトークンやプロフィールをログへ残すことは避けました。「認証拒否が起きたこと」と「期限切れが原因だったこと」は、証拠が違います。
記事作成時には、元のログイン関連テスト7件を再実行し、すべて成功しました。記事用の最小例でも、次の9件を確認しています。
| 確認した条件 | 期待する動き |
|---|---|
| 自動確認で401 | 状態を解除し、ボタン操作を待つ |
| 戻り先ページでも401 | 自動リダイレクトせず、再び操作を待つ |
| ボタン操作で401 | 外部ブラウザーで新しいログインへ進む |
| IDトークンがない | 空のまま送信しない |
| 503の後に再試行 | LINEログイン状態を保持し、成功時に復帰する |
| 403 | 再認証を自動で始めない |
| LIFFブラウザーで認証拒否 | 外部向けログインを呼ばず、開き直しを案内する |
| 401の本文がJSONでない | HTTPステータスを失わない |
| ログイン中の連打 | 処理中の要求を重ねない |
自動試験はSDKと通信を差し替えて分岐を確認するものです。実画面での復帰確認はデスクトップのテストブラウザーで行っており、Android実機やLINE内のLIFFブラウザーでの操作確認まで含めた結果ではありません。
まとめ
今回の問題では、再ログインの案内は出ていましたが、次に押したボタンでも拒否済みの認証情報を使い続けていました。
HTTPステータスを残し、401と通信障害を分け、自動確認からの復帰では本人の操作を待つようにして、外部ブラウザーで新しい認証へ進めるようになりました。LIFFブラウザーは別の経路として扱っています。
再試行を追加するときは、同じ要求を送るだけなのか、古い状態を解除して新しい認証を始めるのかを分けて考えると、処理を追いやすくなると思います。