株式会社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ユーザー固有の情報を追加します。
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などの型アサーションが不要になります。
メインの認証設定
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の設定
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')として実装します。
ログインページ
'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>
)
}
コールバックページ
'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. セッションの使い方
サーバーサイドでの取得
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>
)
}
クライアントサイドでの取得
'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サービスの開発を通じて、技術で新しい価値を創り出しています。今回の記事のような技術スタックに興味がある方、ぜひ一度お話ししませんか?
- 採用情報はこちら: RAYVEN 求人
- カジュアル面談も受付中!: Google Calendarで日程調整
「まずは話を聞いてみたい」という方も大歓迎です。お気軽にどうぞ!