8
4

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 SDKの認証をAuth.js (NextAuth.js)でサーバーサイド対応にする

Last updated at Posted at 2026-01-22

株式会社RAYVENのhisuzuyaです。フロントエンド開発をメインにやっていますが、最近は開発よりもフロントワークやマネジメント寄りの仕事が増えてきました。

解決したい課題

LINE Mini Appを開発していて、こんな課題に直面しました:

  • サーバーサイドで認可処理をしたいが、LIFF SDKはクライアント専用
  • idTokenを毎回APIに送信して検証するのは効率が悪い
  • トークンのリフレッシュ処理を自前で実装するのは大変

そこで、Auth.js v5(旧NextAuth.js) と連携して、JWTベースのセッション管理基盤を実装してみました。この記事では、その実装方法と詰まったポイントを共有します。

TL;DR

  • LIFF SDKのidTokenをAuth.jsのCredentials Providerで検証してセッション化
  • 毎回idTokenを送信する必要がなくなり、JWTをCookieに保存するステートレスなセッション管理に移行
  • LINE Verify APIでidTokenをサーバーサイドで検証(署名検証は不要)
  • JWTストラテジーを使用し、セッション情報を拡張してLINEユーザー情報を保持

想定読者と前提知識

想定読者

  • LINE Mini Appを初めて開発する方
  • Auth.js(NextAuth.js)を初めて使う方
  • 認証・認可の基本概念は理解しているが、実装経験が少ない方

前提知識

  • Next.js App Routerの基本(Client Component、Server Component)
  • TypeScriptの基本
  • React Hooksの基本(useEffect, useState

この記事で作るもの

  • LINE認証 → セッション管理 → 認証済みAPIまでの一連の認証フロー

1. なぜLINE Mini AppでAuth.jsを使うのか

LIFF SDK単体の限界

LIFF SDKは優秀ですが、以下の課題があります:

課題 詳細
SSR非対応 liff.getAccessToken()はクライアントサイドでしか使えない
トークン管理が大変 アクセストークンの有効期限管理、リフレッシュを自前で実装する必要がある
毎回検証が必要 APIリクエストごとにトークンを送信・検証するオーバーヘッドがある

Auth.js連携のメリット

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│ LIFF SDK    │ --> │ Auth.js      │ --> │ サーバーサイド │
│ (認証)      │     │ (セッション)  │     │ (API保護)   │
└─────────────┘     └──────────────┘     └─────────────┘
  • Cookie/JWTベースのセッション: SSRでも認証状態を参照可能
  • 自動トークン管理: セッションの有効期限・更新をAuth.jsに任せられる
  • 毎回検証不要: 初回ログイン時のみLINE Verify APIを呼び、以降はセッションで管理

通常のOAuth認証との違い

LINE Mini Appの認証フローは、Google認証やGitHub認証とは構成が異なります。

項目 通常のOAuth(Google, GitHub等) LINE Mini App
認証フロー サーバーサイドで完結 クライアント→サーバーの2段階
トークン取得 サーバー側でauthorization code交換 クライアント側でLIFF SDKが取得
Auth.jsプロバイダー OAuth Provider(組み込み) Credentials Provider(カスタム実装)
SDKの必要性 不要 LIFF SDK必須(ブラウザ専用)
【通常のOAuth】
ブラウザ → Auth.js(サーバー) → OAuthプロバイダー → Auth.js → セッション作成
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                     すべてサーバーサイドで完結

【LINE Mini App】
ブラウザ → LIFF SDK → LINE Platform → LIFF SDK(idToken取得)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                     クライアントサイド
                                       ↓
                               Auth.js(サーバー) → LINE Verify API → セッション作成
                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                                         サーバーサイド

LINE Mini AppはLINEアプリ内のWebViewで動作するため、LIFF SDKを使ってクライアント側でidTokenを取得する必要があります。これが通常のOAuth認証と大きく異なる点であり、Credentials Providerを使う理由です。


2. 認証フローの全体像

シーケンス図

各コンポーネントの役割

コンポーネント 実行場所 役割
LIFF SDK クライアント LINE認証画面への遷移、idTokenの取得
Auth.js サーバー idTokenの検証、セッション(JWT)の作成・管理
LINE Verify API 外部API idTokenの署名検証、ユーザー情報の返却

なぜこの構成なのか

従来の方法(毎回トークン送信):
  リクエストごとに idToken を送信 → 毎回検証 → オーバーヘッド大

この記事の方法(セッション化):
  初回のみ idToken を検証 → セッション作成 → 以降は Cookie で認証

初回ログイン時だけLINE Verify APIを呼び、以降はCookieベースのセッションで認証することで、効率的な認証が実現できます。


3. LIFF SDK初期化のポイント

ディレクトリ構成

src/
├── app/
│   ├── login/
│   │   └── page.tsx          # ログインページ
│   └── auth/
│       └── callback/
│           └── page.tsx      # コールバックページ
└── lib/
    └── auth.ts               # Auth.js設定

重要: liff.init() を必ず await する

LIFF SDKを使う際の最重要ポイントです。

// ✅ await で初期化完了を待つ
await liff.init({ liffId: process.env.NEXT_PUBLIC_LIFF_ID! })
const idToken = liff.getIDToken() // 取得可能

liff.init() を await せずに liff.getIDToken() を呼ぶと null が返ります。必ず初期化完了を待ってから他のLIFF APIを呼び出してください。


4. 実装: Auth.js v5の設定

これがこの記事の核心部分です。

型拡張

Auth.js(next-authパッケージ)のデフォルト型を拡張して、LINEユーザー固有の情報を追加します。

src/types/next-auth.d.ts
import 'next-auth'
import 'next-auth/jwt'

declare module 'next-auth' {
  interface Session {
    user: {
      id: string           // LINEユーザーID
      displayName: string  // 表示名
      pictureUrl?: string  // プロフィール画像
    }
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    id?: string           // LINEユーザーID
    displayName?: string  // 表示名
    pictureUrl?: string   // プロフィール画像
  }
}

ポイント: next-auth/jwtモジュールの型も拡張することで、callbacks.jwt内での型安全性が向上し、as stringなどの型アサーションが不要になります。

メインの認証設定

src/lib/auth.ts
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { z } from 'zod'

/**
 * LINE Verify APIのレスポンス型
 */
const LineVerifyResponseSchema = z.object({
  sub: z.string(),              // ユーザーID
  name: z.string().optional(),  // 表示名
  picture: z.string().optional(), // プロフィール画像URL
})

/**
 * LINE idTokenを検証する関数
 */
async function verifyLineIdToken(idToken: string) {
  // LIFF IDからチャネルIDを抽出(形式: {チャネルID}-{任意の文字列})
  const channelId = process.env.NEXT_PUBLIC_LIFF_ID?.split('-')[0]
  if (!channelId) return null

  const params = new URLSearchParams()
  params.append('id_token', idToken)
  params.append('client_id', channelId)

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

  if (!response.ok) return null

  const result = LineVerifyResponseSchema.safeParse(await response.json())
  return result.success ? result.data : null
}

/**
 * LIFF Credentials Provider
 */
const liffCredentialsProvider = Credentials({
  id: 'line-liff',
  name: 'LINE LIFF',
  credentials: {
    idToken: { label: 'ID Token', type: 'text' },
  },
  async authorize(credentials) {
    if (!credentials?.idToken || typeof credentials.idToken !== 'string') {
      return null
    }

    const verifiedData = await verifyLineIdToken(credentials.idToken)

    if (!verifiedData) {
      return null
    }

    return {
      id: verifiedData.sub,
      name: verifiedData.name,
      image: verifiedData.picture,
    }
  },
})

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [liffCredentialsProvider],
  session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60, // 30日間
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id
        token.displayName = user.name ?? ''
        token.pictureUrl = user.image ?? undefined
      }
      return token
    },
    async session({ session, token }) {
      if (token.id) {
        session.user.id = token.id
        session.user.displayName = token.displayName ?? ''
        session.user.pictureUrl = token.pictureUrl
      }
      return session
    },
  },
  pages: {
    signIn: '/login',
  },
})

重要ポイント解説

LINE Verify APIの呼び方

const params = new URLSearchParams()
params.append('id_token', idToken)
params.append('client_id', channelId)

const response = await fetch('https://api.line.me/oauth2/v2.1/verify', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded', // ← JSONではない!
  },
  body: params.toString(),
})
  • Content-Typeはapplication/x-www-form-urlencoded (JSONではない)
  • client_idはLIFF IDの-より前の部分(チャネルID)
  • 署名検証はLINE側が行うので、開発者は結果を受け取るだけ

API Routeの設定

src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'

export const { GET, POST } = handlers

5. 実装: ログイン・コールバックフロー

なぜClient Componentで実装するのか

ここが重要なポイントです。

LIFF SDK (liff.getIDToken()) はブラウザでしか動作しない
        ↓
/auth/callback は Client Component で実装する必要がある
        ↓
取得した idToken を Auth.js に渡してセッション作成

LIFF SDKはクライアントサイド専用のライブラリです。liff.getIDToken() でidTokenを取得するには、ブラウザ上で実行する必要があります。そのため、ログインページとコールバックページはClient Component('use client')として実装します。

ログインページ

src/app/login/page.tsx
'use client'

import { useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import liff from '@line/liff'

export default function LoginPage() {
  const searchParams = useSearchParams()
  const redirectTo = searchParams.get('redirect') || '/'

  useEffect(() => {
    const login = async () => {
      await liff.init({ liffId: process.env.NEXT_PUBLIC_LIFF_ID! })

      // 古いセッションをクリアしてからログイン
      if (liff.isLoggedIn()) {
        liff.logout()
      }

      // LINE認証画面へリダイレクト
      const callbackUrl = `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(redirectTo)}`
      liff.login({ redirectUri: callbackUrl })
    }

    login()
  }, [redirectTo])

  return (
    <div className="flex items-center justify-center min-h-screen">
      <p>LINEログイン中...</p>
    </div>
  )
}

コールバックページ

src/app/auth/callback/page.tsx
'use client'

import { useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import liff from '@line/liff'
import { signIn } from 'next-auth/react'

export default function CallbackPage() {
  const searchParams = useSearchParams()
  const redirectTo = searchParams.get('redirect') || '/'

  useEffect(() => {
    const handleCallback = async () => {
      await liff.init({ liffId: process.env.NEXT_PUBLIC_LIFF_ID! })

      if (!liff.isLoggedIn()) return

      // idTokenを取得してAuth.jsでセッション作成
      const idToken = liff.getIDToken()
      if (idToken) {
        signIn('line-liff', { idToken, redirectTo })
      }
    }

    handleCallback()
  }, [redirectTo])

  return (
    <div className="flex items-center justify-center min-h-screen">
      <p>認証処理中...</p>
    </div>
  )
}

6. セッションの使い方

サーバーサイドでの取得

src/app/mypage/page.tsx
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

export default async function MyPage() {
  const session = await auth()

  if (!session) {
    redirect('/login?redirect=/mypage')
  }

  return (
    <div>
      <h1>マイページ</h1>
      <p>ようこそ{session.user.displayName}さん</p>
      {session.user.pictureUrl && (
        <img src={session.user.pictureUrl} alt="プロフィール画像" />
      )}
    </div>
  )
}

クライアントサイドでの取得

src/components/UserInfo.tsx
'use client'

import { useSession } from 'next-auth/react'

export function UserInfo() {
  const { data: session, status } = useSession()

  if (status === 'loading') {
    return <p>読み込み中...</p>
  }

  if (!session) {
    return <p>ログインしていません</p>
  }

  return (
    <div>
      <p>ユーザーID: {session.user.id}</p>
      <p>表示名: {session.user.displayName}</p>
    </div>
  )
}

注意: クライアントサイドでuseSessionを使う場合は、SessionProviderでラップする必要があります。


7. ハマりポイントと解決策

エラー/問題 原因 解決策
liff.getIDToken()nullを返す liff.init()を待っていない await liff.init()で初期化完了を待つ
LINE Verify APIが400 Bad Request Content-Typeが間違っている application/x-www-form-urlencodedを使用
LINE Verify APIが400 Bad Request client_idが間違っている LIFF IDの-より前の部分を使用
セッションに追加したプロパティがない 型拡張が反映されていない next-auth.d.tsを作成
LIFFログイン後に古いセッションが残る ログアウト処理がない liff.login()前にliff.logout()を呼ぶ

8. まとめ

この記事で構築した認証フローをまとめます:

1. ユーザーが /login にアクセス
   ↓
2. await liff.init() で初期化
   ↓
3. liff.login() でLINE認証画面へ
   ↓
4. 認証完了後、/auth/callback にリダイレクト
   ↓
5. await liff.init() → liff.getIDToken()
   ↓
6. signIn('line-liff', { idToken }) でAuth.jsへ
   ↓
7. LINE Verify API で検証
   ↓
8. JWTセッション作成 → ログイン完了

ポイント

  • LIFF SDKは「認証」だけを担当
  • Auth.jsは「セッション管理」を担当
  • 役割を明確に分離することで、保守性が向上

続編について

以下のトピックは続編で解説予定です:

  • tRPCとの連携: 認証済みAPIの作成、protectedProcedureの実装
  • Preview環境対応: 開発効率を上げるための認証バイパス
  • DBとの連携: ユーザー情報の永続化、登録状態の管理

参考リンク


この記事が、LINE Mini Appの認証実装で困っている方の助けになれば幸いです。質問や改善提案があればコメントください!


株式会社RAYVENでは一緒に働く仲間を募集しています!

私たちは「Tumiki MCP Manager」をはじめとしたAIサービスの開発を通じて、技術で新しい価値を創り出しています。今回の記事のような技術スタックに興味がある方、ぜひ一度お話ししませんか?

「まずは話を聞いてみたい」という方も大歓迎です。お気軽にどうぞ!

8
4
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
8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?