Firebaseを使ったLine認証実装方法

# はじめに
SNS連携を実装するに当たり、Firebaseを利用して実装すると面倒な設定や各プラットフォーム個別の対応をしなくてよいため非常に便利なのはよく知られていますが、その中でLine連携だけ少し特殊な実装が必要になるため記載します。

### なにが特殊なのか？
一般的によくSNS連携で使用されるSNSとしては、TwitterやFacebook、またその他の連携としてはGoogleやAppleID等を利用して連携を行います。
上記記載した全てにおいてFirebaseでは元からサインイン用のメソッドが準備されておりますが、Line用のメソッドはないため自分で実装する必要があります。

# 構成について

以下がざっくりとした処理の流れになります。
![Firebase.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/467966/c4e642f8-2a9b-48eb-c2a8-c68179c7cf8e.png)


1. Lineログイン
クライアント側アプリケーションより、[LineSDK](https://developers.line.biz/ja/docs/line-login/overview/#native-app)を使用してaccess tokenを取得します。

1. access tokenとcustom tokenの交換リクエスト
1で受け取ったaccess token自分のサーバに送信します。

1. access tokenの検証
2で受け取ったaccess tokenが正しいものか検証するため、lineの認証サーバにaccess tokenを送り検証します。

1. custom tokenの取得
3での検証結果が正常であれば、Firebaseからcustom tokenを取得します。

1. custom tokenの返却
デバイスに対してcustom tokenを返却。

1. Firebaseログイン
custom tokenを使用し、クライアント側アプリケーションからFirebaseにログインする。

といったような流れになります。
今回は主に3~5までのサーバ側の処理について細かく記載します。


## 準備
まずはLine Developerより<b>新規プロバイダー</b>と<b>新規チャネル</b>を作成し、チャネルIDとチャネルシークレットをメモしておきましょう。
また、ネイティブアプリで開発する場合においても、サーバ側のみで先んじて開発を進める場合は一時的にLineログイン設定→ウェブアプリのコールバックURLも設定しておくと便利だと思います。

この記事ではサーバ側の認証が主ですがaccess tokenがあったほうがテストしやすいため、準備としてウェブで簡易的にaccess tokenを発行します。

なお、LineログインAPIは[v2.1](https://developers.line.biz/ja/reference/line-login/)を前提として進めます。（2021/5/23現在最新）

### 認証コードの取得
access tokenを発行する場合、[認可コード](https://developers.line.biz/ja/docs/line-login/integrate-line-login/#receiving-the-authorization-code)が必要になるので、先んじて認可コードを発行します。

以下のような簡易的な認可コード発行用のhtmlを用意しておくと便利かもしれません。

```login.html
<html>
	<body>
		<a class="btn" href="https://access.line.me/oauth2/v2.1/authorize?
					response_type=code&
					client_id=[チャネルID]&
					redirect_uri=[コールバックURL]&
					state=12345abcde&
					scope=profile%20openid&
					nonce=09876xyz">
				<div>
					Lineログインする
				</div>
		</a>
	</body>
</html>
```
パラメータにある<b>チャネルID</b>と<b>コールバックURL</b>に関しては自分で取得したものを入れてください。
scope,state,nonceについては、[LineAPIドキュメント](https://developers.line.biz/ja/docs/line-login/integrate-line-login/#making-an-authorization-request)に記載してありますが、今回のような開発用として使用する場合は上記のままで特に問題はありません。

ログインに成功すると、以下のようなURLにリダイレクトされます。

```
https://[コールバックURL]/oauth2/authresp?code=XXXXXXXXXXXXX&state=12345abcde
```

### accces tokenの取得
続いてaccess tokenを取得します。
リダイレクトされたURLパラメータにある<b>code</b>、Line developerに登録した<b>コールバックURL</b>、<b>チャネルID</b>、<b>チャネルシークレット</b>を入れアクセスするとaccess tokenが取得できます。

```request.
curl -v -X POST https://api.line.me/oauth2/v2.1/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code' \
-d 'code=xxx' \
-d 'redirect_uri=xxx' \
-d 'client_id=xxx' \
-d 'client_secret=xxx'
```

```response.
{
	"access_token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
	"token_type": "Bearer",
	"refresh_token": "YYYYYYYYYYYYYYYYYYYYYYYYYYYY",
	"expires_in": 2592000,
	"scope": "profile openid",
	"id_token": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ"
}
```
ここで取得したaccess tokenは開発中使用するのでどこかに保存しておくといいでしょう。
紛失した場合は、再度認証コードから発行し直す必要があります。

## access tokenの検証

図の3の工程であるように、まずはクライアントから送られてきたaccess tokenが正しいものか検証する必要があります。

今回は準備としてaccess tokenを取得済みなので、テストコードでアクセスする場合は直接引き渡してテストすると良いと思います。なお、以下サンプルコードはc#で記載します。

```SnsAuthLogic.cs
using System.Net;
using System.Net.Http;
namespace AuthService.logic {

	public interface ISnsAuthLogic {
		public Task<ApiResponseCustomTokenData> GetCustomAuthTokenForLine(string accessToken);
	}

	public class SnsAuthLogic : ISnsAuthLogic {

		private static readonly HttpClient _httpClient;

		static SnsAuthLogic() {
			_httpClient = new HttpClient();
		}

		/// <summary>
		/// カスタムトークン取得
		/// </summary>
		/// <returns></returns>
		public async Task<ApiResponseCustomTokenData> GetCustomAuthTokenForLine(string accessToken) {

			string verifyUrl	= $"https://api.line.me/oauth2/v2.1/verify?access_token={accessToken}";

			var veryfyRequest	= new HttpRequestMessage(HttpMethod.Get, verifyUrl);

			//response
			ApiResponseCustomTokenData data = new ApiResponseCustomTokenData() {
				Status		= 0,
				CustomToken = null
			};

			//accessToken検証
			var verifyResponse	= await _httpClient.SendAsync(veryfyRequest);
			data.Status			= (int)verifyResponse.StatusCode;

			if(verifyResponse.StatusCode == HttpStatusCode.OK) {

				//channelId抽出
				var verifyBody	= await verifyResponse.Content.ReadAsStringAsync();
				JObject verify	= JObject.Parse(verifyBody);
				var clientId	= verify["client_id"].ToString();
			}

			return data;
		}
	}
}
```
c#標準のHttpClientでLineのtoken検証用urlにアクセスします。
HttpClientは都度生成するとパフォーマンスが低下するため、コンストラクタでstatic化して使用しています。
（[不適切なインスタンス化のアンチパターン](https://docs.microsoft.com/ja-jp/azure/architecture/antipatterns/improper-instantiation/)にも記載されていました）

ApiResponseCustomTokenDataはレスポンス用のクラスのため必要に応じて調整してください。
<b>失敗時のHttpStatus</b>と<b>成功時のCustomToken</b>が返せれば問題ないかと思います。

HttpClientのレスポンスはContentプロパティにボディが入るためReadAsStringAsyncをかけないと中身をみることができないので注意です。

ReadAsStringAsync後のレスポンスは以下のようになります。

```
{
	"client_id": "XXXXXXXXXXXX",
	"expires_in": 2578105,
	"scope": "profile openid"
}
```

<b>HttpStatusCodeが200である</b>、かつレスポンスの<b>client_idが自分が作成したLineDeveloperのチャネルと同一</b>であればaccess tokenの検証は問題なしということになります。

## user_idの取得

Firebaseからcustom tokenを取得する場合、一意な値としてユーザIDのような値が必要になるのですが、access token検証APIのレスポンスにはuserIdが存在しないため別途取得する必要があります。
（v1.0のAPIではレスポンスにuidがあったようですがv2.0以降含まれなくなりました）

[プロフィールAPI](https://developers.line.biz/ja/reference/line-login/#get-user-profile)のレスポンスにuserIdが存在するため、こちらを取得します。

```
https://api.line.me/v2/profile
```

こちらのAPIはリクエストにAuthorizationヘッダを付けてアクセスする必要があります。
以下は該当部分のサンプルコードです。

```SnsAuthLogic.cs
string profileUrl	= "https://api.line.me/v2/profile";
var profileRequest	= new HttpRequestMessage(HttpMethod.Get, profileUrl);

//profile取得
if(clientId == CLIENT_ID) {
	profileRequest.Headers.Add("Authorization", "Bearer " + accessToken);
	var profileResponse = await _httpClient.SendAsync(profileRequest);
	var profileBody		= await profileResponse.Content.ReadAsStringAsync();
	JObject profile		= JObject.Parse(profileBody);
	var userId			= profile["userId"].ToString();
}

```
※CLIENT_IDは自分で取得したチャネルIDになります。
access token検証で返ってきたclientIdと比較し、偽造されていないことを確認しましょう。

## custome tokenの取得
access tokenの検証が完了し、custom tokenの取得に必要なuidも取得できたためcustome tokenを取得します。
なお、custome tokenを取得するためにはFirebase Admin SDKが必要になるためパッケージを追加します。

```project.csproj
<ItemGroup>
  <PackageReference Include="FirebaseAdmin" Version="2.1.0" />
</ItemGroup>
```

こちらについてはサーバ起動時に初期化し、staticとして保持しておいても問題ないと思います。
以下のサンプルコードでは処理と一緒に記載しています。

```SnsAuthLogic.cs
private static FirebaseApp _firebaseApp;

//Firebase初期化
if(_firebaseApp == null) {
	_firebaseApp = FirebaseApp.Create(new AppOptions() {
		Credential = GoogleCredential.FromFile(FirebaseCredentialsFile),
	});
}

//カスタムトークンの取得
data.CustomToken = await FirebaseAuth.DefaultInstance.CreateCustomTokenAsync(userId);
```

上記のとおり、Firebase Admin SDKは初期化する必要があるのですがサーバ環境がAWS等、Amazon環境下でない場合はFirebaseプロジェクトの<b>秘密鍵</b>が必要になります。

サンプルコードでは予め、FirebaseCredentialsFileにファイル名を設定してあります。
実際はappSettingsで設定するのがよろしいかと思われます。

秘密鍵についてはFirebaseのサイト上からダウンロードすることができます。
なお、ダウンロードした秘密鍵は紛失しないように保存しておきましょう。
![firebase_json.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/467966/fe577a3f-ce96-febe-4be4-30fd4d85e4ff.png)

以上がaccess tokenを受け取ってcustom tokenを返すまでの一連の流れになります。
以下にサンプルとして結合したメソッドを記載しておきます。

```SnsAuthLogic.cs
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net;
using System.Net.Http;
using FirebaseAdmin;
using FirebaseAdmin.Auth;
using Google.Apis.Auth.OAuth2;
using Newtonsoft.Json.Linq;

namespace AuthService.logic {

	public interface ISnsAuthLogic {
		public Task<ApiResponseCustomTokenData> GetCustomAuthTokenForLine(string accessToken);
	}

	public class SnsAuthLogic : ISnsAuthLogic {

		private readonly ILogger<SnsAuthLogic> m_Logger = null;
		private readonly AppSettings m_AppSettings = null;
		private static FirebaseApp _firebaseApp;
		private static readonly HttpClient _httpClient;

		public SnsAuthLogic(IServiceProvider provider) {
			m_Logger = provider.GetRequiredService<ILogger<SnsAuthLogic>>();
			m_AppSettings = provider.GetRequiredService<IOptions<AppSettings>>().Value;
		}

		static SnsAuthLogic() {
			_httpClient = new HttpClient();
		}

		/// <summary>
		/// カスタムトークン取得
		/// </summary>
		/// <returns></returns>
		public async Task<ApiResponseCustomTokenData> GetCustomAuthTokenForLine(string accessToken) {

			string verifyUrl		= $"https://api.line.me/oauth2/v2.1/verify?access_token={accessToken}";
			string profileUrl		= "https://api.line.me/v2/profile";

			var veryfyRequest	= new HttpRequestMessage(HttpMethod.Get, verifyUrl);
			var profileRequest	= new HttpRequestMessage(HttpMethod.Get, profileUrl);

			//response
			ApiResponseCustomTokenData data = new ApiResponseCustomTokenData() {
				Status		= 0,
				CustomToken = null
			};

			//accessToken検証
			var verifyResponse	= await _httpClient.SendAsync(veryfyRequest);
			data.Status			= (int)verifyResponse.StatusCode;

			if(verifyResponse.StatusCode == HttpStatusCode.OK) {

				//channelId抽出
				var verifyBody	= await verifyResponse.Content.ReadAsStringAsync();
				JObject verify	= JObject.Parse(verifyBody);
				var clientId	= verify["client_id"].ToString();

				//profile取得
				if(clientId == m_AppSettings.SnsAuthentication.Line.ClientId) {
					profileRequest.Headers.Add("Authorization", "Bearer " + accessToken);
					var profileResponse = await _httpClient.SendAsync(profileRequest);
					var profileBody		= await profileResponse.Content.ReadAsStringAsync();
					JObject profile		= JObject.Parse(profileBody);
					var userId			= profile["userId"].ToString();

					//Firebase初期化
					if(_firebaseApp == null) {
						_firebaseApp = FirebaseApp.Create(new AppOptions() {
							Credential = GoogleCredential.FromFile(m_AppSettings.SnsAuthentication.FirebaseCredentialsFile),
						});
					}

					data.CustomToken = await FirebaseAuth.DefaultInstance.CreateCustomTokenAsync(userId);

				} else { 
					data.Status = (int)HttpStatusCode.Unauthorized;
					m_Logger.LogError("channel error");
				}

			} else { 
				m_Logger.LogError("status error");
			}

			return data;
		}
	}
}



```

```ApiResponseCustomTokenData.cs
public class ApiResponseCustomTokenData {
	public int Status { get; set; }
	public string CustomToken { get; set; }
}
```


※m_AppSettingsはサーバ起動時にappsetting.jsonを元に設定しています。