🌱

LINE ログインするための React Native Native Module を作る

2023/10/22に公開

サンプルプロジェクトは次。

https://github.com/januswel/LineLoginSample

動機

Firebase で LINE ログイン経由の認証を実現したい場合、次のページで紹介されている react-native-app-auth を使うはずだ。しかし、 iOS だと「サイト越えトラッキングを防ぐ」設定のためにうまくクレデンシャルを引き継げない。

https://rnfirebase.io/auth/oidc-auth

ただし iOS / Android ともに LINE 社は SDK を提供してくれている。そこで Native Module として LINE ログインを実装し、クレデンシャルを得て Firebase へ連携する方法で実現する。

方針

OpenID Connect を用いて実装する。 LINE 公式ドキュメントに詳しく書いてある。

https://developers.line.biz/ja/docs/line-login/secure-login-process/#using-openid-to-register-new-users

スコープ指定は openidprofileemail で固定し、ハードコードする。実際に欲しい物が足りない場合は適宜追加すると良いだろう。

また、セキュリティのためにログイン用メソッドの引数に nonce を指定する設計としているが、これはサーバーで生成したものをクライアントに払い出して指定すると良い。

LINE ログインチャンネルの ID は環境変数経由で指定する。ネイティブ層で環境変数を参照したいので、使うパッケージは react-native-config 一択。

React Native 公式の Native Module 実装ガイドをもとに作業する。

https://reactnative.dev/docs/native-modules-intro

実装

Android

Kotlin を使うので、事前に設定をしておくこと。

基本的な流れは次となる。

  1. 設定
  2. 機能実装
  3. 1 番をパッケージとしてまとめる
  4. MainApplication.java から 2 番のパッケージを呼び出す

パッケージ名は com.example.line とする。

設定

LINE SDK の制約で、 minSdkVersion は 24 でなければならない。

android/build.gradle
        minSdkVersion = 24

Java 1.8 のサポートを有効にする。

android/app/build.gradle
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

LINE SDK の依存を追加する。

android/app/build.gradle
    implementation("com.linecorp.linesdk:linesdk:latest.release")

機能実装

LINE 公式のガイドだと Java での実装なので、ここにサンプル実装を紹介する。

android/app/src/main/java/com/example/line/LineLoginModule.kt
package com.example.line

import android.app.Activity
import android.content.Intent
import android.util.Log
import com.facebook.react.bridge.*
import com.linecorp.linesdk.LineApiResponseCode.*
import com.linecorp.linesdk.Scope
import com.linecorp.linesdk.api.LineApiClient
import com.linecorp.linesdk.api.LineApiClientBuilder
import com.linecorp.linesdk.auth.LineAuthenticationParams
import com.linecorp.linesdk.auth.LineLoginApi
import java.util.Arrays

class LineLoginModule(reactContext: ReactApplicationContext, lineChannelId: String) :
    ReactContextBaseJavaModule(reactContext) {
  override fun getName() = "LineLogin"

  private var loginPromise: Promise? = null

  private val reactContext: ReactApplicationContext = reactContext
  private val lineChannelId: String = lineChannelId

  init {
    val activityEventListener: BaseActivityEventListener =
        object : BaseActivityEventListener() {
          override fun onActivityResult(
              activity: Activity?,
              requestCode: Int,
              resultCode: Int,
              intent: Intent?
          ) {
            if (requestCode != REQUEST_CODE) {
              loginPromise?.let { promise ->
                promise.reject(E_FAILED_TO_LOGIN, "Unsupported Request Code: " + requestCode)
              }
              return
            }

            val result = LineLoginApi.getLoginResultFromIntent(intent)

            loginPromise?.let { promise ->
              when (result.getResponseCode()) {
                SUCCESS -> {
                  val ret = Arguments.createMap()
                  ret.putString("displayName", result.lineProfile?.displayName)
                  ret.putString("pictureUrl", result.lineProfile?.pictureUrl.toString())
                  ret.putString("email", result.lineIdToken?.email)
                  ret.putString("idToken", result.lineIdToken?.rawString)
                  promise.resolve(ret)
                }
                CANCEL -> {
                  promise.reject(E_LOGIN_CANCELED, "LINE Login canceled by user")
                }
                else -> {
                  promise.reject(E_FAILED_TO_LOGIN, result.errorData.message)
                }
              }

              loginPromise = null
            }
          }
        }

    reactContext.addActivityEventListener(activityEventListener)

    val apiClientBuilder = LineApiClientBuilder(reactContext, lineChannelId)
    lineApiClient = apiClientBuilder.build()
  }

  @ReactMethod
  fun login(nonce: String, promise: Promise) {
    val activity = currentActivity

    if (activity == null) {
      promise.reject(E_ACTIVITY_DOES_NOT_EXIST, "Activity doesn't exist")
      return
    }

    loginPromise = promise

    try {
      val loginIntent =
          LineLoginApi.getLoginIntent(
              reactContext,
              lineChannelId,
              LineAuthenticationParams.Builder()
                  .scopes(Arrays.asList(Scope.PROFILE, Scope.OPENID_CONNECT, Scope.OC_EMAIL))
                  .nonce(nonce)
                  .build()
          )
      activity.startActivityForResult(loginIntent, REQUEST_CODE)
    } catch (t: Throwable) {
      Log.e("ERROR", t.toString())
      loginPromise?.reject(E_FAILED_TO_LOGIN, t)
      loginPromise = null
    }
  }

  @ReactMethod
  fun logout(dummy: Srring, promise: Promise) {
    lineApiClient?.let { client ->
      val response = client.logout()

      if (response.isSuccess()) {
        promise.resolve(null)
        return
      }

      promise.reject(E_FAILED_TO_LOGOUT, response.errorData.message)
      return
    }

    // not reachable
    throw Exception("LineApiClient is not initialized")
  }

  companion object {
    const val REQUEST_CODE = 1
    const val E_ACTIVITY_DOES_NOT_EXIST = "E_ACTIVITY_DOES_NOT_EXIST"
    const val E_LOGIN_CANCELED = "E_LOGIN_CANCELED"
    const val E_FAILED_TO_LOGIN = "E_FAILED_TO_LOGIN"
    const val E_FAILED_TO_LOGOUT = "E_FAILED_TO_LOGOUT"

    var lineApiClient: LineApiClient? = null
  }
}

メールアドレスなど、 OpenID Connect 経由の情報は lineIdToken に格納されている。これは JWT をデコードしたものだ。

機能をパッケージとしてまとめる

Native Module は ReactPackage を継承しなければならないので、そのためのクラスを作る。

android/app/src/main/java/com/example/line/LineLoginPackage.kt
package com.example.line

import android.view.View
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ReactShadowNode
import com.facebook.react.uimanager.ViewManager

class LineLoginPackage(lineChannelId: String) : ReactPackage {
  private val lineChannelId: String = lineChannelId

  override fun createViewManagers(
      reactContext: ReactApplicationContext
  ): MutableList<ViewManager<View, ReactShadowNode<*>>> = mutableListOf()

  override fun createNativeModules(
      reactContext: ReactApplicationContext
  ): MutableList<NativeModule> =
      listOf(LineLoginModule(reactContext, lineChannelId)).toMutableList()
}

createViewManagerscreateNativeModules を実装してやれば良いだけなのでここはお作法として覚えてしまうと良い。

MainApplication.java から作ったパッケージを呼び出す

    @Override
    protected List<ReactPackage> getPackages() {
      @SuppressWarnings("UnnecessaryLocalVariable")
      List<ReactPackage> packages = new PackageList(this).getPackages();
      packages.add(new LineLoginPackage(BuildConfig.LINE_CHANNEL_ID));
      return packages;
    }

getPackages メソッドで、作成したパッケージのインスタンスを作り、 packages.add の引数として渡す。

iOS

React Native 側が Objective-C で書かれているので、 LINE SDK は Objective-C でラップされたものを使う。

https://developers.line.biz/ja/docs/line-login-sdks/ios-sdk/swift/using-objc/#use-wrapper

  1. 設定
  2. 機能実装

設定

LINE SDK の依存を追加する。

Podfile
target 'LineLoginSample' do
    pod 'LineSDKSwift/ObjC', '~> 5.0'
end

設定を書いたあとに (cd ios; pod install) を実行する。

LINE からアプリに、アプリから LINE に移動する必要があるので、そのための設定をする。

ios/Info.plist
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <!-- LINEからアプリに戻る際に利用するURLスキーマを追加 -->
            <string>line3rdp.$(PRODUCT_BUNDLE_IDENTIFIER)</string>
        </array>
    </dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
    <!-- アプリからLINEを起動する際に利用するURLスキーマを追加 -->
    <string>lineauth2</string>
</array>
ios/LineLoginSample/AppDelegate.m
@import LineSDK;

// snip

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
  return [[LineSDKLoginManager sharedManager] application:app open:url options:options];
}

また、 AppDelegate.mm は Objective-C++ であり、 @import が使えないため、拡張子を .m に変更しておく。

機能実装

ヘッダーは次のように、 RCTBridgeModule プロトコルを指定する。

ios/LineLogin/LineLogin.h
#ifndef LineLogin_h
#define LineLogin_h

#import <React/RCTBridgeModule.h>

@interface MBTLineLogin : NSObject <RCTBridgeModule>

+(void)initialize;

@end

#endif /* LineLogin_h */

実装は次のように。

ios/LineLogin/LineLogin.m
#import <UIKit/UIKit.h>
#import <React/RCTLog.h>
#import "AppDelegate.h"
#import "RNCConfig.h"
#import "LineLogin.h"
@import LineSDK;

@implementation MBTLineLogin

// https://developer.apple.com/documentation/objectivec/nsobject/1418639-initialize
+ (void)initialize {
  if (self == [MBTLineLogin self]) {
    NSString *lineChannelId = [RNCConfig envFor:@"LINE_CHANNEL_ID"];
    RCTLogInfo(@"LINE channel ID: %@", lineChannelId);
    [[LineSDKLoginManager sharedManager] setupWithChannelID:lineChannelId universalLinkURL:nil];
  }
}

RCT_EXPORT_MODULE(LineLogin);

RCT_EXPORT_METHOD(login: (NSString *)nonce
                  resolver:(RCTPromiseResolveBlock)resolve
                  refector:(RCTPromiseRejectBlock)reject)
{
  RCTLogInfo(@"Trying LINE login");

  NSSet *permissions = [NSSet setWithObjects:
                        [LineSDKLoginPermission profile],
                        [LineSDKLoginPermission email],
                        [LineSDKLoginPermission openID],
                        nil];

  LineSDKLoginManagerParameters *parameters = [[LineSDKLoginManagerParameters new] init];
  parameters.IDTokenNonce = nonce;

  // appDelegate and rootViewController must be used in main thread
  dispatch_async(dispatch_get_main_queue(), ^{
    AppDelegate *delegate = (AppDelegate *)[[UIApplication sharedApplication] delegate];

    [[LineSDKLoginManager sharedManager]
     loginWithPermissions:permissions
     inViewController:delegate.window.rootViewController
     parameters:parameters
     completionHandler:^(LineSDKLoginResult *result, NSError *error) {
      if (result) {
        RCTLogInfo(@"Succeeded login with LINE as %@", result.userProfile.displayName);
        NSURL* pictureUrl = result.userProfile.pictureURL;
        NSString* email = result.accessToken.IDToken.payload.email;
        NSDictionary *user = @{
          @"displayName": result.userProfile.displayName,
          @"pictureUrl": pictureUrl ? pictureUrl.absoluteString : [NSNull null],
          @"email": email ? email : [NSNull null],
          @"idToken": result.accessToken.IDTokenRaw,
        };
        resolve(user);
        return;
      } else {
        RCTLogInfo(@"[%ld]%@(%@)", error.code, error.description, error.localizedRecoverySuggestion);
        if ([error.domain isEqualToString:[LineSDKErrorConstant errorDomain]]) {
          // TODO: handle error
          reject([NSString stringWithFormat:@"%ld", error.code], error.description, error);
          return;
        }
        reject([NSString stringWithFormat:@"%ld", error.code], error.description, error);
        return;
      }
    }];
  });
}

RCT_EXPORT_METHOD(logout: (NSString *) dummy
                  resolver:(RCTPromiseResolveBlock)resolve
                  refector:(RCTPromiseRejectBlock)reject)
{
  [[LineSDKLoginManager sharedManager] logoutWithCompletionHandler:^(NSError *error) {
    if (error) {
      reject([NSString stringWithFormat:@"%ld", error.code], error.description, error);
      return;
    }
    resolve(@"success");
  }];
}
@end

logout メソッドを Promise で扱えるようにダミーの文字列を渡すようにしている。これは React Native 側の制約。

JavaScript 層

直接 NativeModules をいじるよりも、扱いやすいようにラップしてやるのが良いだろう。

src/LineLogin.ts
import {NativeModules} from 'react-native';

const {LineLogin} = NativeModules;

export interface Profile {
  idToken: string;
  displayName: string;
  pictureUrl: string;
  email: string;
}
export type Login = (nonce: string) => Promise<Profile>;
export type Logout = () => Promise<void>;

export const login: Login = LineLogin.login;
export const logout: Logout = async function () {
  await LineLogin.logout('dummy');
};

logout についてはダミーの文字列を渡してやっている。

これで実装は終了。実際に使うのは次のようにする。

App.tsx
import React from 'react';
import {
  Button,
  Image,
  SafeAreaView,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import {login, logout, Profile} from './src/LineLogin';

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
});

function App(): JSX.Element {
  const [profile, setProfile] = React.useState<Profile | null>(null);
  const handleTapLogin = React.useCallback(async () => {
    console.log('login');
    try {
      const result = await login('hoge');
      setProfile(result);
    } catch (e) {
      console.log(e);
    }
  }, []);
  const handleTapLogout = React.useCallback(async () => {
    console.log('logout');
    await logout();
    setProfile(null);
  }, []);
  const isLoggedIn = profile !== null;

  return (
    <SafeAreaView style={styles.container}>
      {isLoggedIn ? (
        <>
          <View>
            <Text>{profile.displayName}</Text>
            <Text>{profile.email}</Text>
            <Image source={{uri: profile.pictureUrl, width: 64, height: 64}} />
          </View>
          <Button title="logout" onPress={handleTapLogout} />
        </>
      ) : (
        <Button title="login" onPress={handleTapLogin} />
      )}
    </SafeAreaView>
  );
}

export default App;
GitHubで編集を提案

Discussion