LINE LIFF の ID トークンを Firebase Custom Token に変換する認証フロー実装

LINE ミニアプリ開発記 第 3 回

#LIFF#Firebase#認証#Cloud Functions#TypeScript

LINE LIFF を使って何かを作るとき、「LINE のユーザー識別を Firebase 側のセッションにつなぐ」工程は最初に作るわりに毎回ググりながら書くやつです。LIFF SDK が getIDToken() をくれるので、これを Cloud Functions の片側で検証 して、検証 OK なら Firebase Auth Custom Token を発行してクライアントに返す、という流れになります。

本記事は LIFF + Firebase Custom Token の認証フローを、クライアント側・サーバー側の コピペで動くコード と一緒に解説します。line:Uxxx という uid プレフィックスの設計意図など、後から効いてくる小ネタも書きます。

状況・前提

認証フローの全体像

[1] LIFF アプリが起動
    └─ liff.init() → liff.getIDToken() で ID トークン取得

[2] クライアント → Cloud Functions: verifyLineIdToken
    └─ POST /verifyLineIdToken { idToken }

[3] サーバー側で LINE Verify API に問い合わせ
    └─ https://api.line.me/oauth2/v2.1/verify
       └─ client_id = LINE ログインチャネルの ID

[4] 検証 OK なら sub(LINE userId)から Firebase Custom Token 発行
    └─ admin.auth().createCustomToken(`line:${userId}`)

[5] クライアントで signInWithCustomToken
    └─ Firebase Auth セッションが確立
       ★ 以降は Firestore SDK が自動でセッション付き

ポイントは 2 段階 にしていること。LIFF の ID トークンを毎リクエスト送って検証するのではなく、初回 1 回 だけ Custom Token に変換して、以降は Firebase Auth のセッションで動かします。Firestore SDK もこのセッションを勝手に拾ってくれるので、クライアントコードはほぼ普通の Firebase アプリと同じ書き味になります。

サーバー側の実装

functions/src/verifyLineIdToken.ts:

import { onRequest } from "firebase-functions/v2/https";
import { getAuth } from "firebase-admin/auth";
import { defineSecret } from "firebase-functions/params";

const LINE_CHANNEL_ID = defineSecret("LINE_CHANNEL_ID");

export const verifyLineIdToken = onRequest(
  { secrets: [LINE_CHANNEL_ID], cors: true },
  async (req, res) => {
    if (req.method !== "POST") {
      res.status(405).send("Method Not Allowed");
      return;
    }

    const { idToken } = req.body as { idToken?: string };
    if (!idToken) {
      res.status(400).json({ error: "idToken required" });
      return;
    }

    const params = new URLSearchParams({
      id_token: idToken,
      client_id: LINE_CHANNEL_ID.value(),
    });

    const verifyRes = await fetch("https://api.line.me/oauth2/v2.1/verify", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: params,
    });

    if (!verifyRes.ok) {
      res.status(401).json({ error: "invalid LINE id token" });
      return;
    }

    const payload = (await verifyRes.json()) as { sub: string };
    const lineUserId = payload.sub;

    const firebaseToken = await getAuth().createCustomToken(`line:${lineUserId}`);
    res.json({ firebaseToken });
  }
);

Secret として LINE_CHANNEL_IDdefineSecret で取り込むのが第 2 世代 Functions の流儀です。firebase functions:secrets:set LINE_CHANNEL_ID で値を入れておきます。

client_id に渡すのは LINE ログインチャネル のチャネル ID。Messaging API チャネル側の ID を間違って入れると 401 が返り続けます(別記事 #11 参照)。

クライアント側の実装

lib/liffAuth.ts:

import liff from "@line/liff";
import { getAuth, signInWithCustomToken } from "firebase/auth";
import { firebaseApp } from "./firebase";

const LIFF_ID = process.env.NEXT_PUBLIC_LIFF_ID!;
const VERIFY_ENDPOINT = process.env.NEXT_PUBLIC_VERIFY_TOKEN_ENDPOINT!;

export async function signInWithLiff() {
  await liff.init({ liffId: LIFF_ID });
  if (!liff.isLoggedIn()) {
    liff.login();
    return;
  }

  const idToken = liff.getIDToken();
  if (!idToken) throw new Error("LIFF id token unavailable");

  const verifyRes = await fetch(VERIFY_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ idToken }),
  });

  if (!verifyRes.ok) {
    throw new Error(`verify failed: ${verifyRes.status}`);
  }

  const { firebaseToken } = (await verifyRes.json()) as {
    firebaseToken: string;
  };

  await signInWithCustomToken(getAuth(firebaseApp), firebaseToken);
}

クライアント側は liff.init()getIDToken()fetch(検証関数)signInWithCustomToken() の 4 ステップ。signInWithCustomToken() まで通れば、getAuth().currentUser.uidline:Uxxxx... の形で取れる状態になります。

AuthGate コンポーネントで起動時に呼び出す

ルート近くにこういうゲートを置くと、画面ロジックは認証済みを前提に書けます。

"use client";
import { useEffect, useState } from "react";
import { onAuthStateChanged, getAuth } from "firebase/auth";
import { firebaseApp } from "@/lib/firebase";
import { signInWithLiff } from "@/lib/liffAuth";

export function AuthGate({ children }: { children: React.ReactNode }) {
  const [ready, setReady] = useState(false);
  const [error, setError] = useState<unknown>(null);

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(getAuth(firebaseApp), (user) => {
      if (user) {
        setReady(true);
      } else {
        signInWithLiff().catch(setError);
      }
    });
    return () => unsubscribe();
  }, []);

  if (error) return <div>ログインに失敗しました</div>;
  if (!ready) return <div>ログイン中…</div>;
  return <>{children}</>;
}

onAuthStateChanged で「ログイン済み」が伝わってきたら ready を立て、そうでなければ signInWithLiff() を走らせる、という素直な設計にしています。エラー時の握りつぶしには注意(別記事 #9 参照)。

line:Uxxx プレフィックス設計の意図

サーバー側で createCustomToken に渡している uid を line:Uxxxx の形にしています。LINE userId(U で始まる 33 文字)はそのまま auth.uid の制約を満たすので、プレフィックスは技術的に不要です。それでも付けている理由は 将来の拡張への保険 です。

[1] 今: LINE 認証だけ
    line:U1234abcd...
[2] 将来: メール認証や Twitter 認証を追加するとき
    email:user@example.com
    twitter:1234567890
    ★ 認証手段の混在が uid を見ただけで分かる

DB 側の uid を見ただけで「どの認証由来か」が分かるので、後で別経路をつないだときに分岐が書きやすくなります。Push 通知のために uid から LINE userId に戻すときも uid.startsWith("line:") ? uid.slice(5) : null で済みます。

学び・余談

LIFF + Firebase の認証は、1 回だけ ID トークンを検証して以降は Firebase Auth で動かす というパターンを覚えてしまえば、ほぼボイラープレートです。毎リクエストで LINE Verify API を叩くと、レスポンス時間と LINE 側のレート制限の両方で詰みます。Custom Token に変換した瞬間に、それ以降は普通の Firebase アプリと変わらない開発体験になる、というのがこの構成の気持ちよさです。

エラーハンドリングはまだ薄めの実装です。liff.init のリトライ、signInWithCustomToken 失敗時の再試行、ネットワーク不安定時の挙動などは別記事で扱います。

関連記事

参考


← ブログ一覧へ