📱

Supabaseで完結するLINEログイン実装!

に公開

アプリにSNSログインを導入するのは今や当たり前。特に日本のユーザーをターゲットにするなら、LINEログインは外せない選択肢です。しかし、Supabaseの標準機能にはLINEログインが含まれていません。

この記事では、そのギャップを埋めるためにSupabase Edge Function を活用し、Webアプリネイティブアプリの両方で、安全かつ信頼性の高いLINEログインを実装する方法を解説します。

なぜEdge Function?

なぜEdge Functionを使うのでしょうか?

Supabaseは多くの主要なSNSログイン(OAuthプロバイダ)に対応していますが、リストにLINEは含まれていません。そのため、supabase.auth.signInWithOAuth()のような便利な関数は使えません。

この問題を解決するのがEdge Functionです。LINEとの認証に関わる機密性の高い処理(Client Secretの利用やトークンの検証など)をすべてサーバーサイド(Edge Function)で安全に実行し、magic linkを発行後すぐ検証することでセッションを取得、それをクライアントに返却します。

これにより、以下のようなメリットが得られます。
✅ SupabaseとLINEログインの併用: RLSやAuthなどSupabaseの恩恵を受けつつ、LINEログインを実装できます。
✅ 高いセキュリティ: Client Secretなどの機密情報をフロントエンドに漏らすことなく、安全な認証フローを構築できます。
✅ 柔軟性: Web/ネイティブを問わず、同じバックエンドロジックを再利用してLINEログインを導入できます。

ログインフロー

Webアプリとネイティブアプリでは認証フローが若干異なるため、それぞれを見てみましょう。

Webアプリのフロー

Webアプリでは、認証コードをEdge Functionで検証してしid_tokenを取得するのが楽です。
もちろん下記ネイティブアプリのフローと同様、ログイン後id_tokenを発行してバックエンドにリクエストすることも可能ですが、認証を丸ごとEdge Functionに投げてしまったほうがシンプルです。

ネイティブアプリのフロー

ネイティブアプリでは、LINE SDKでログインを完了させた後、IDトークンをバックエンドに送信します。

事前準備: ユーザー検索用のPostgreSQL関数

Edge Functionから安全にユーザーを検索するため、SupabaseのSQL Editor(Database > SQL Editor)で以下の関数を定義します。これにより、service_roleを持つAdminクライアントだけがメールアドレスでユーザーを検索できるようになります。

  1. 関数を作成
    以下のSQLを実行して、auth.usersテーブルからメールアドレスでユーザーIDを検索する関数を作成します。SECURITY DEFINER句により、この関数は作成者(postgresロール)の権限で実行され、authスキーマへの安全なアクセスが可能になります。

    CREATE OR REPLACE FUNCTION public.get_user_by_email (  
      user_email TEXT  
    )  
    RETURNS UUID  
    LANGUAGE plpgsql  
    SECURITY DEFINER  
    SET search_path = public  
    AS $$  
    BEGIN  
      RETURN (SELECT id  
              FROM auth.users  
              WHERE email = user_email  
              LIMIT 1);  
    END;  
    $$;
    
  2. 権限を設定
    次に、service_role以外のロールからこの関数を呼び出せないように権限を制限します。これにより、意図しない場所からのユーザー検索を防ぎます。

    REVOKE EXECUTE ON FUNCTION public.get_user_by_email(TEXT) FROM anon, authenticated, public;
    

それでは、具体的な実装をWebアプリとネイティブアプリの2部に分けて見ていきましょう。

第1部: Webアプリでの実装 (Next.js, Vite / Reactなど)

Webアプリでは、OAuth 2.0の標準的な認証コードフローを利用します。

1. フロントエンドの実装 (React)

1.1. LINEログインボタンの作成

ユーザーがクリックするログインボタンを作成します。このボタンのリンク先は、LINEの認証エンドポイントに、必須パラメータを付与したURLです。

pages/login.tsx
const handleLogin = () => {  
    const state = crypto.randomUUID(); // CSRF対策  
    sessionStorage.setItem('line_oauth_state', state); // stateをセッションストレージに保存

    const scope = 'profile openid email';  
    const responseType = 'code';

    const params = new URLSearchParams({  
        response_type: responseType,  
        client_id: process.env.NEXT_PUBLIC_LINE_CHANNEL_ID!,  
        redirect_uri: process.env.NEXT_PUBLIC_LINE_REDIRECT_URI!,  
        scope: scope,  
        state: state,  
    });

    const lineAuthUrl = `https://access.line.me/oauth2/v2.1/authorize?${params.toString()}`;  
      
    window.location.href = lineAuthUrl;  
};

return (  
    <button onClick={handleLogin}>  
        LINEでログイン  
    </button>  
);

redirect_uri は、LINEでの認証後にユーザーが戻ってくるページのURLです。LINE Developersコンソールとここの値を一致させる必要があります。

1.2. コールバックページの作成

LINEからリダイレクトされた後の処理を行うページ(例: /auth/callback/line)を作成します。URLクエリからcodeとstateを取得し、Edge Functionに渡します。

pages/auth/callback/line.tsx
import { useEffect } from 'react';  
import { useRouter } from 'next/router';  
import { supabase } from '@/lib/supabase';

const LineCallback = () => {  
    const router = useRouter();

    useEffect(() => {  
        const handleAuthCallback = async () => {  
            const { code, state } = router.query;

            // stateを検証してCSRF攻撃を防ぐ  
            const storedState = sessionStorage.getItem('line_oauth_state');  
            if (!code || !state || state !== storedState) {  
                // エラー処理  
                router.push('/login?error=invalid_state');  
                return;  
            }  
            sessionStorage.removeItem('line_oauth_state');

            try {  
                // 認証コードをEdge Functionに渡す  
                const { data, error } = await supabase.functions.invoke('line-auth-web', {  
                    body: { code },  
                });

                if (error) throw error;  
                  
                // 返されたトークンでセッションを設定  
                await supabase.auth.setSession({  
                    access_token: data.access_token,  
                    refresh_token: data.refresh_token,  
                });

                // ログイン後、ホームページなどにリダイレクト  
                router.push('/');

            } catch (error) {  
                console.error('LINE Auth Callback Error:', error);  
                router.push(`/login?error=${error.message}`);  
            }  
        };

        if (router.isReady) {  
            handleAuthCallback();  
        }  
    }, [router]);  
      
    return <div>LINE認証中...</div>;  
};

export default LineCallback;

2. バックエンドの実装 (Edge Function for Web)

クライアントから受け取ったcodeを使い、安全なサーバーサイドでLINEと通信します。

supabase/functions/line-auth-web/index.ts
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"

// JWTペイロードをデコードするヘルパー  
function decodeJwtPayload(token: string): any {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('Invalid JWT');
  const payload = parts[1]
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const decoded = atob(payload);
  return JSON.parse(decoded);
}

Deno.serve(async (req) => {  
  try {  
    const { code } = await req.json();

    // 1. 認証コードをアクセストークンと交換  
    const tokenParams = new URLSearchParams();  
    tokenParams.append('grant_type', 'authorization_code');  
    tokenParams.append('code', code as string);  
    tokenParams.append('redirect_uri', Deno.env.get('LINE_REDIRECT_URI')!);  
    tokenParams.append('client_id', Deno.env.get('LINE_CHANNEL_ID')!);  
    tokenParams.append('client_secret', Deno.env.get('LINE_CHANNEL_SECRET')!);

    const tokenRes = await fetch('https://api.line.me/oauth2/v2.1/token', {  
      method: 'POST',  
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },  
      body: tokenParams,  
    });  
    const lineTokens = await tokenRes.json();  
    if (!tokenRes.ok) throw new Error(lineTokens.error_description);

    // 2. IDトークンからユーザー情報を取得  
    const { email, sub: lineUserId } = decodeJwtPayload(lineTokens.id_token);  
    if (!email) throw new Error('Email is not available from LINE.');  
      
    // 3. Supabase Adminクライアントでユーザーを検索または作成  
    const supabaseAdmin = createClient(  
      Deno.env.get('SUPABASE_URL')!,  
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,  
      { auth: { autoRefreshToken: false, persistSession: false } }  
    );  
      
    // 事前準備で作成したRPC関数を呼び出してユーザーIDを検索  
    const { data: userId, error: rpcError } = await supabaseAdmin.rpc(  
      'get_user_by_email', { user_email: email }  
    );  
    if (rpcError) throw rpcError;

    if (!userId) {  
        // ユーザーが存在しない場合、新規作成  
        const { data: newUser, error: createError } = await supabaseAdmin.auth.admin.createUser({  
            email: email,  
            email_confirm: true, // LINEで認証済みのため、メール確認は不要  
            app_metadata: { provider: 'line', line_id: lineUserId }  
        });  
        if (createError) throw createError;  
    } else {
        const {error: updateUserError} = await supabaseAdmin.auth.admin.updateUserById(
            userId,
            {app_metadata: { provider: 'line', line_id: lineUserId }}
        )
        if (updateUserError) throw updateUserError;  
    }
      
    // 5. 該当ユーザーのmagic linkを発行  
    const {data: linkData, error: linkError} = await supabaseAdmin.auth.admin.generateLink({
      email: email,
      type: 'magiclink',
    });
    if (linkError) {
      return new Response(`Error generating magic link: ${linkError.message}`, {status: 500});
    }
    const {hashed_token} = linkData.properties;
    if (!hashed_token) {
      return new Response('Failed to retrieve token from magic link', {status: 500});
    }

    // 6. magic linkを使ってサインイン
    const {data: verifyData, error: verifyError} = await supabaseAdmin.auth.verifyOtp({
      token_hash: hashed_token,
      type: 'email',
    });
    if (verifyError || !verifyData.session) {
      return new Response(`Error verifying OTP: ${verifyError?.message || 'No session created'}`, {status: 500});
    }
    const session = verifyData.session;

    return new Response(JSON.stringify({  
        access_token: session.access_token,
        refresh_token: session.refresh_token,
        user: session.user
    }), { headers: { 'Content-Type': 'application/json' } });

  } catch (error) {  
    return new Response(JSON.stringify({ error: error.message }), { status: 400, headers: { 'Content-Type': 'application/json' } });  
  }  
});

第2部: ネイティブアプリでの実装 (React Native)

ネイティブアプリでは、公式SDKのラッパーライブラリを使い、よりシームレスなユーザー体験を提供します。

1. フロントエンドの実装 (React Native)

1.1. ライブラリの導入

ネイティブSDKを利用するため、必要なライブラリをインストールします。

npx expo install @xmartlabs/react-native-line expo-build-properties

1.2. app.json の設定

app.jsonに、プラグインとネイティブ連携のための設定を追加します。

{  
  "expo": {  
    // ...  
    "ios": {  
      "bundleIdentifier": "com.your.bundle.identifier",  
      "infoPlist": { "LSApplicationQueriesSchemes": ["lineauth2"] }  
    },  
    "android": {  
      "package": "com.your.package.name"  
    },  
    "plugins": [  
      ["expo-build-properties", { "ios": { "useFrameworks": "static" } }],  
      "@xmartlabs/react-native-line"
    ]  
  }  
}

注: ネイティブコードを含むため、開発ビルドの作成(npx expo run:iosなど)が必要です。

1.3. ログイン処理の実装

ログインボタンが押されたら、SDKを呼び出して認証フローを開始します。nonceはSDKが自動で生成・管理してくれます。

import { Line } from '@xmartlabs/react-native-line';  
import { supabase } from '../lib/supabase';  
import { Alert } from 'react-native';

const handleLineLogin = async () => {  
  try {  
    // 1. ネイティブSDKでログイン処理を呼び出す  
    const loginResult = await Line.login({  
      scopes: ['profile', 'openid', 'email'],  
    });  
      
    // 2. 結果からIDトークンと、サーバー検証用のnonceを取得  
    const idToken = loginResult.accessToken.idToken;  
    const nonce = loginResult.idTokenNonce;  
    if (!idToken || !nonce) throw new Error('Failed to get token or nonce from LINE.');

    // 3. IDトークンとnonceをEdge Functionに渡す  
    const { data, error } = await supabase.functions.invoke('line-auth-native', {  
      body: { id_token: idToken, nonce: nonce },  
    });  
    if (error) throw error;

    // 4. 返されたトークンでセッションを設定  
    await supabase.auth.setSession({  
      access_token: data.access_token,  
      refresh_token: data.refresh_token,  
    });  
      
    // 5. ホーム画面などに遷移

  } catch (error) {  
    if (error.code === 'E_CANCELLED') {  
      console.log('LINE login was cancelled.');  
    } else {  
      Alert.alert('Error', 'An error occurred during LINE login.');  
    }  
  }  
};

2. バックエンドの実装 (Edge Function for Native)

ネイティブアプリから送られてきたid_tokenを検証します。

supabase/functions/line-auth-native/index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts"  
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"

Deno.serve(async (req) => {  
  try {  
    const { id_token, nonce } = await req.json();  
    if (!id_token || !nonce) throw new Error('Missing id_token or nonce');

    // 1. IDトークンをLINEの検証APIに送信  
    const params = new URLSearchParams();  
    params.append('id_token', id_token);  
    params.append('client_id', Deno.env.get('LINE_CHANNEL_ID')!);

    const verifyRes = await fetch('https://api.line.me/oauth2/v2.1/verify', {  
      method: 'POST',  
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },  
      body: params,  
    });  
    const verifyData = await verifyRes.json();  
    if (!verifyRes.ok) throw new Error(verifyData.error_description || 'Unknown error');

    // 2. nonce と aud (Channel ID) を検証  
    if (verifyData.nonce !== nonce) throw new Error('Nonce does not match.');  
    if (verifyData.aud !== Deno.env.get('LINE_CHANNEL_ID')!) throw new Error('Audience does not match.');

    // 3. 検証済みのユーザー情報を取得  
    const lineUserId = verifyData.sub as string;  
    const email = verifyData.email as string;  
    if (!email) throw new Error('Email not available from LINE.');  
      
    // 4. Supabase Adminクライアントでユーザーを検索または作成  
    const supabaseAdmin = createClient(  
      Deno.env.get('SUPABASE_URL')!,  
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,  
      { auth: { autoRefreshToken: false, persistSession: false } }  
    );

    // 事前準備で作成したRPC関数を呼び出してユーザーIDを検索  
    const { data: userId, error: rpcError } = await supabaseAdmin.rpc(  
      'get_user_by_email', { user_email: email }  
    );  
    if (rpcError) throw rpcError;  
      
    if (!userId) {  
        // ユーザーが存在しない場合、新規作成  
        const { data: newUser, error: createError } = await supabaseAdmin.auth.admin.createUser({  
          email: email,  
          email_confirm: true,  
          app_metadata: { provider: 'line', line_id: lineUserId }  
        });  
        if (createError) throw createError;  
    } else {
        const {error: updateUserError} = await supabaseAdmin.auth.admin.updateUserById(
            userId,
            {app_metadata: { provider: 'line', line_id: lineUserId }}
        )
        if (updateUserError) throw updateUserError;  
    }
      
    // 5. 該当ユーザーのmagic linkを発行  
    const {data: linkData, error: linkError} = await supabaseAdmin.auth.admin.generateLink({
      email: email,
      type: 'magiclink',
    });
    if (linkError) {
      return new Response(`Error generating magic link: ${linkError.message}`, {status: 500});
    }
    const {hashed_token} = linkData.properties;
    if (!hashed_token) {
      return new Response('Failed to retrieve token from magic link', {status: 500});
    }

    // 6. magic linkを使ってサインイン
    const {data: verifyData, error: verifyError} = await supabaseAdmin.auth.verifyOtp({
      token_hash: hashed_token,
      type: 'email',
    });
    if (verifyError || !verifyData.session) {
      return new Response(`Error verifying OTP: ${verifyError?.message || 'No session created'}`, {status: 500});
    }
    const session = verifyData.session;

    return new Response(JSON.stringify({  
        access_token: session.access_token,
        refresh_token: session.refresh_token,
        user: session.user
    }), { headers: { 'Content-Type': 'application/json' } });

  } catch (error) {  
    return new Response(JSON.stringify({ error: error.message }), { status: 400, headers: { 'Content-Type': 'application/json' } });  
  }  
});

環境変数の設定

どちらの実装でも、Supabaseの管理画面(Project Settings > Edge Functions)で以下の環境変数(Secrets)を設定する必要があります。

  • LINE_CHANNEL_ID: LINE DevelopersコンソールのChannel ID。
  • LINE_CHANNEL_SECRET: (Webアプリのみ) LINE DevelopersコンソールのChannel Secret。
  • LINE_REDIRECT_URI: (Webアプリのみ) アプリのコールバックURL。
  • SUPABASE_URL: SupabaseプロジェクトのURL。
  • SUPABASE_SERVICE_ROLE_KEY: Supabaseプロジェクトの service_role キー。

まとめ

お疲れ様でした!
多くの方が個人開発などでsupabaseを使っていると思いますが、LINEログインに対応できないのをもどかしく思っていた方もいるのではないでしょうか。この方法で実装することで、Supabaseのメリットを活かしつつLINEログインを活用できます。

Discussion