LINE ログインするための React Native Native Module を作る
サンプルプロジェクトは次。
動機
Firebase で LINE ログイン経由の認証を実現したい場合、次のページで紹介されている react-native-app-auth
を使うはずだ。しかし、 iOS だと「サイト越えトラッキングを防ぐ」設定のためにうまくクレデンシャルを引き継げない。
ただし iOS / Android ともに LINE 社は SDK を提供してくれている。そこで Native Module として LINE ログインを実装し、クレデンシャルを得て Firebase へ連携する方法で実現する。
方針
OpenID Connect を用いて実装する。 LINE 公式ドキュメントに詳しく書いてある。
スコープ指定は openid
と profile
、 email
で固定し、ハードコードする。実際に欲しい物が足りない場合は適宜追加すると良いだろう。
また、セキュリティのためにログイン用メソッドの引数に nonce
を指定する設計としているが、これはサーバーで生成したものをクライアントに払い出して指定すると良い。
LINE ログインチャンネルの ID は環境変数経由で指定する。ネイティブ層で環境変数を参照したいので、使うパッケージは react-native-config 一択。
React Native 公式の Native Module 実装ガイドをもとに作業する。
実装
Android
Kotlin を使うので、事前に設定をしておくこと。
基本的な流れは次となる。
- 設定
- 機能実装
- 1 番をパッケージとしてまとめる
- MainApplication.java から 2 番のパッケージを呼び出す
パッケージ名は com.example.line
とする。
設定
LINE SDK の制約で、 minSdkVersion
は 24 でなければならない。
minSdkVersion = 24
Java 1.8 のサポートを有効にする。
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
LINE SDK の依存を追加する。
implementation("com.linecorp.linesdk:linesdk:latest.release")
機能実装
LINE 公式のガイドだと Java での実装なので、ここにサンプル実装を紹介する。
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
を継承しなければならないので、そのためのクラスを作る。
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()
}
createViewManagers
、 createNativeModules
を実装してやれば良いだけなのでここはお作法として覚えてしまうと良い。
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 でラップされたものを使う。
- 設定
- 機能実装
設定
LINE SDK の依存を追加する。
target 'LineLoginSample' do
pod 'LineSDKSwift/ObjC', '~> 5.0'
end
設定を書いたあとに (cd ios; pod install)
を実行する。
LINE からアプリに、アプリから LINE に移動する必要があるので、そのための設定をする。
<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>
@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
プロトコルを指定する。
#ifndef LineLogin_h
#define LineLogin_h
#import <React/RCTBridgeModule.h>
@interface MBTLineLogin : NSObject <RCTBridgeModule>
+(void)initialize;
@end
#endif /* LineLogin_h */
実装は次のように。
#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
をいじるよりも、扱いやすいようにラップしてやるのが良いだろう。
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
についてはダミーの文字列を渡してやっている。
これで実装は終了。実際に使うのは次のようにする。
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;
Discussion