🌊

【Next.js + Supabase Auth】 自前で LINE ログインを実装する

に公開

はじめに

Next.js (App Router) + Supabase Auth を使用したプロジェクトにおいて、LINEログインを実装したかったのですが、Supabase AuthはLINEログインにはネイティブ対応していなかった(2025年現在)ので、自前実装しました。

コールバック後のSupabaseセッション作成については、SupabaseではユーザーIDを指定してクライアント向けのセッションCookieを直接発行するAPIが存在しないので、Magic Link発行 → 即時検証というワークアラウンド的な方法を取っています。

アーキテクチャと実装の全体像

処理の流れ

  1. リクエスト: ユーザーが「LINEでログイン」ボタンを押す。
  2. 認可URL生成: Next.jsのRoute HandlerでPKCE用のパラメータ等を生成し、LINEの認可画面へリダイレクト。
  3. コールバック: LINEでの認可後、アプリに戻ってくる。
  4. トークン検証: 認可コードをアクセストークン・IDトークンと交換し、プロフィールを取得。
  5. ユーザー同期: SupabaseのDB(users および line_accounts)を検索・更新・作成。
  6. セッション確立: Supabase Admin機能を使ってログイン用トークンを生成・即時検証し、セッションCookieをセットする。

事前準備

https://developers.line.biz/ja/docs/line-login/integrate-line-login/#applying-for-email-permission

1. LINE Developersコンソールの設定

LINE Developersコンソールでチャネルを作成し、以下を設定します。

  • LINEログイン設定: 「ウェブアプリ」を有効化。
  • Callback URL: http://localhost:3000/auth/callback/line (本番環境用も追加)
  • 権限: OpenID Connectの「Email address」権限を申請し、取得できるようにしておくこと。
  • 環境変数: NEXT_PUBLIC_LINE_CHANNEL_IDLINE_CHANNEL_SECRETを取得。

2. データベース設計 (Supabase)

Supabase標準の auth.users とは別に、LINE固有の情報を管理するテーブルを作成します。

LINEの仕様上、メールアドレスは未登録だったり変更されたりする可能性があるため、ユーザーの識別には不変なIDである User ID (sub) を使用します。
また、複数のLINEチャネルを扱う可能性も考慮し、channel_id と line_sub の組み合わせで一意になるよう設計します。

supabase/migrations/create_line_accounts.sql
create table public.line_accounts (
  id uuid not null default gen_random_uuid(),
  auth_user_id uuid not null references auth.users(id) on delete cascade,
  channel_id text not null,
  line_sub text not null, -- LINEのユーザーID (sub)
  email text,
  display_name text,
  picture_url text,
  created_at timestamp with time zone not null default now(),
  updated_at timestamp with time zone not null default now(),
  constraint line_accounts_pkey primary key (id),
  
  -- チャネルIDとLINE User IDの組み合わせで一意性を担保する
  constraint line_accounts_channel_id_line_sub_key unique (channel_id, line_sub)
);

-- RLS設定(必要に応じて)
alter table public.line_accounts enable row level security;
grant all on table public.line_accounts to service_role;

実装 Step 1: 認可URLの生成とリダイレクト

まず、ログインボタンが押されたときに叩かれるエンドポイントを作成します。ここではPKCE対応のための code_verifier 生成と、CSRF対策の state 生成を行います。

ユーティリティ関数

core/auth/line-utils.ts
import * as crypto from "crypto";

// PKCE用のcode_verifierを生成 (RFC7636準拠)
export function generateCodeVerifier(): string {
  const buffer = crypto.randomBytes(32);
  return base64UrlEncode(buffer.toString("base64"));
}

// code_verifierをハッシュ化してcode_challengeを生成
export function generateCodeChallenge(codeVerifier: string): string {
  const hash = crypto.createHash("sha256").update(codeVerifier).digest("base64");
  return base64UrlEncode(hash);
}

function base64UrlEncode(str: string): string {
  return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}

エンドポイント

app/auth/line/route.ts
import * as crypto from "crypto";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
// 定数やユーティリティのインポートは省略

export const dynamic = "force-dynamic";

export async function GET(request: Request) {
  const channelId = process.env.NEXT_PUBLIC_LINE_CHANNEL_ID;
  const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback/line`;

  // 1. CSRF対策とPKCEの準備
  const state = crypto.randomBytes(32).toString("hex");
  const nonce = crypto.randomBytes(32).toString("hex");
  const codeVerifier = generateCodeVerifier(); // ランダムな文字列生成
  const codeChallenge = generateCodeChallenge(codeVerifier); // S256ハッシュ化

  // 2. Cookieに一時保存 (HttpOnly, Secure)
  const cookieStore = cookies();
  const cookieOptions = { httpOnly: true, secure: true, path: "/", maxAge: 600 };
  
  cookieStore.set("line_oauth_state", state, cookieOptions);
  cookieStore.set("line_oauth_nonce", nonce, cookieOptions);
  cookieStore.set("line_oauth_code_verifier", codeVerifier, cookieOptions);

  // 3. LINE認可URLへリダイレクト
  const params = new URLSearchParams({
    response_type: "code",
    client_id: channelId!,
    redirect_uri: redirectUri,
    state: state,
    nonce: nonce,
    scope: "profile openid email", // emailを含める
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });

  return NextResponse.redirect(`https://access.line.me/oauth2/v2.1/authorize?${params.toString()}`);
}

実装 Step 2: コールバック処理とトークン検証

LINEから戻ってきた際のリクエストを処理します。

app/auth/callback/line/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get("code");
  const state = searchParams.get("state");
  const error = searchParams.get("error");

  // 1. エラーハンドリングとState検証
  const cookieStore = cookies();
  const storedState = cookieStore.get("line_oauth_state")?.value;
  const codeVerifier = cookieStore.get("line_oauth_code_verifier")?.value;
  const storedNonce = cookieStore.get("line_oauth_nonce")?.value;

  // 検証用Cookieは使い捨てなので削除
  cookieStore.delete("line_oauth_state");
  cookieStore.delete("line_oauth_code_verifier");
  cookieStore.delete("line_oauth_nonce");

  if (error || !state || state !== storedState) {
    return NextResponse.redirect(new URL("/login?error=auth_failed", request.url));
  }

  // 2. LINEアクセストークン取得 (PKCE検証含む)
  const tokenResponse = await fetch("https://api.line.me/oauth2/v2.1/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code!,
      redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback/line`,
      client_id: process.env.NEXT_PUBLIC_LINE_CHANNEL_ID!,
      client_secret: process.env.LINE_CHANNEL_SECRET!,
      code_verifier: codeVerifier!,
    }),
  });

  const lineData = await tokenResponse.json();
  
  // 3. プロフィール情報の取得と検証 (verifyエンドポイント推奨)
  const verifyResponse = await fetch("https://api.line.me/oauth2/v2.1/verify", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      id_token: lineData.id_token,
      client_id: process.env.NEXT_PUBLIC_LINE_CHANNEL_ID!,
      nonce: storedNonce!,
    }),
  });

  const profile = await verifyResponse.json();
  const { sub: lineSub, email, name, picture } = profile;
  
  // ... 次のステップへ
}

実装 Step 3: Supabaseユーザーとの紐付け

取得したLINEのプロフィール情報を元に、Supabase上のユーザーを特定・作成します。
Supabase Admin Client (Service Role) を使用します。

ここでは以下の処理を行っています。

  1. 既に連携済みのLINEアカウントがあるか検索、紐付け済みユーザーがいればそのIDを使用。
  2. いなければ、email で auth.users を検索し、一致すれば紐付け作成。
  3. どちらもなければ、auth.admin.createUser で新規ユーザー作成&紐付け。

2について、LINE以外ですでにサインアップしているユーザーの扱いはビジネス要件次第ですが、ここでは自動で統合するよう処理しています。

// app/auth/callback/line/route.ts の続き

  // Adminクライアントの作成
  const supabaseAdmin = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );

  let userId: string;

  // 1. 既に連携済みのLINEアカウントがあるか検索
  const { data: existingLineAccount } = await supabaseAdmin
    .from("line_accounts")
    .select("auth_user_id")
    .eq("line_sub", lineSub)
    .single();

  if (existingLineAccount) {
    userId = existingLineAccount.auth_user_id;
    // 必要に応じてプロフィール情報を更新
  } else {
    // 2. 未連携だが、同じメールアドレスのユーザーがいるか確認
    const { data: existingUser } = await supabaseAdmin
      .from("users") // 事前に auth.users と同期する public.users テーブルを作成しておく必要があります
      .select("id")
      .eq("email", email)
      .single();

    if (existingUser) {
      userId = existingUser.id;
    } else {
      // 3. 完全新規ユーザー作成
      const { data: newUser, error } = await supabaseAdmin.auth.admin.createUser({
        email: email,
        email_confirm: true, // 確認済みにする
        user_metadata: { full_name: name, avatar_url: picture },
      });
      if (error) throw error;
      userId = newUser.user.id;
    }

    // line_accounts テーブルに紐付けを作成
    await supabaseAdmin.from("line_accounts").insert({
      auth_user_id: userId,
      channel_id: process.env.NEXT_PUBLIC_LINE_CHANNEL_ID!,
      line_sub: lineSub,
      email: email,
      display_name: name,
      picture_url: picture,
    });
  }

  // ... 次のステップへ

実装 Step 4: Supabaseセッションの確立

SupabaseはセッションCookieを直接発行するAPIが存在しないので、Admin権限でMagic Linkを発行し、それをサーバーサイドで即座に検証するという方法を取っています。これはワークアラウンド的な実装であり、推奨されるかどうかは微妙です。

// app/auth/callback/line/route.ts の続き

  // 1. ログイン用のMagic Linkトークンを生成
  // (メールは送信されず、トークンハッシュだけが返る)
  const { data: linkData } = await supabaseAdmin.auth.admin.generateLink({
    type: "magiclink",
    email: email,
  });

  const tokenHash = linkData.properties?.hashed_token;

  // 2. 通常のSupabaseクライアント(Cookie操作用)を作成
  // ※ @supabase/ssr の createServerClient を使用
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name) { return cookieStore.get(name)?.value; },
        set(name, value, options) { cookieStore.set(name, value, options); },
        remove(name, options) { cookieStore.delete(name); },
      },
    }
  );

  // 3. サーバーサイドでOTP検証を実行し、セッションを確立
  const { error: sessionError } = await supabase.auth.verifyOtp({
    token_hash: tokenHash,
    type: "email",
  });

  if (sessionError) {
    return NextResponse.redirect(new URL("/login?error=session_failed", request.url));
  }

  // 4. ログイン完了後のページへリダイレクト
  return NextResponse.redirect(new URL("/dashboard", request.url));

これにより、verifyOtp が成功した時点で createServerClient がレスポンスヘッダーにSupabaseのセッションCookieをセットしてくれます。ユーザーから見れば、LINEから戻ってきた瞬間にログインが完了しています。

迷いポイントとTips

  1. メールアドレスではなくLINE User ID (sub) を識別子にする:
    LINEのメールアドレスはユーザーによって変更されたり、未登録だったりする可能性があります。そのため、アカウントの紐付けや検索には不変であるLINE User ID (sub) を使用します。メールアドレスは初回紐付け時の補助的な検索キーとして扱っています。

  2. メールアドレスがない場合:
    LINEアカウントによってはメールアドレスが登録されていない、あるいは権限許可を拒否される場合があります。その場合 emailundefined になるため、ハンドリングを考えておく必要があります。

  3. PKCE・nonce:
    PKCE・nonce の実装は任意ですが、セキュリティのため推奨されています。特にPKCEは、実装することでYahoo! JAPANアプリからの自動ログインが有効になるおまけ付きです。

  4. Supabase Authのメール設定:
    admin.createUsergenerateLink を使用する場合でも、Supabaseプロジェクト側でEmailプロバイダーが有効になっている必要があります。

まとめ

Next.js (App Router) + Supabase Auth の環境において、標準対応していないLINEログインを実装しました。
Supabaseの対応が待たれます。

Discussion