Firebase HostingでLINEログインする

# Overview
Firebase Hostingを使って、LINEログインさせるWEBページを作りたかったのですが、下記のサイトではLINE APIの仕様が古かったり（v1）で、順調には行かなかったので、LINEログインv2.1版として書いてみました。

[Google Developers Japan: LINE ログインによる Firebase ユーザーの認証](https://developers-jp.googleblog.com/2016/11/authenticate-your-firebase-users-with-line-login.html)

# 前提条件
* [Firebase](https://console.firebase.google.com)で projectを作成済み
* [LINEログイン](https://developers.line.me/ja/services/line-login/) でプロバイダー登録済み、新規channel作成済み

## 利用するサービスやAPIバージョン
* Firebase Hosting ([AngularFire2](https://github.com/angular/angularfire2)を利用)
* Firebase functions （外部のLINE APIへ通信するのでSparkプラン以外）
* LINEログインv2.1

# シーケンス図
<img width="827" alt="スクリーンショット 2017-12-19 18.20.47.png" src="https://qiita-image-store.s3.amazonaws.com/0/94245/b27e334a-3364-0d3c-c63e-7f03d92b11e6.png">

<!---
シーケンス図は下記サイトで作成
https://www.websequencediagrams.com/

Browser ->+ Firebase\nHosting: ログイン画面
Firebase\nHosting ->- Browser:
Browser ->+ LINE: LINEログインボタン押下
LINE ->- Browser: 
Browser ->+ LINE: ID&パスワード入力
LINE ->- Browser: 302 Redirect
Browser ->+ Firebase\nFunctions: 

Firebase\nFunctions ->+ LINE API : アクセストークン取得
LINE API ->- Firebase\nFunctions :

Firebase\nFunctions ->+ LINE API : プロフィール情報取得
LINE API ->- Firebase\nFunctions : 

Firebase\nFunctions ->+ Firebase\nAuthentication : ユーザー作成
Firebase\nAuthentication ->- Firebase\nFunctions : 
Firebase\nFunctions ->+ Firebase\nAuthentication : CustomToken取得
Firebase\nAuthentication ->- Firebase\nFunctions : 

Firebase\nFunctions ->- Browser: CustomToken
Browser ->+ Firebase\nAuthentication : Custom Token認証
Firebase\nAuthentication ->- Browser : 

note right of Browser : 完了！
--->

# ソースコード
全てのソースコードはこちらにあります。
https://github.com/kanemotos/firebase-login-sample

# 手順１．Firebase HostingのサイトをAngularで作成
```shell:terminal
# プロジェクト作成
mkdir firebase-login-sample
cd firebase-login-sample/
ng new line-login-app --skipInstall
cd line-login-app
npm-check-updates -u --removeRange
npm install
npm install --save typescript@2.4.2 firebase angularfire2


# ルーティング、コンポーネント、サービス、インタフェース作成
ng generate module app-routing --flat --module=app
ng generate component components/home
ng generate component components/line-auth-handler
ng generate service services/auth-state
ng generate interface interfaces/create-custom-token-response

```

## ルーティングの設定
ポイントは、下記のように２つの画面を用意する点です。

| URL                           | Component                | 説明 |
|-------------------------------|--------------------------|------------------|
| example.com/                  | HomeComponent            | ログインボタンを置く画面 |
| example.com/line_auth_handler | LineAuthHandlerComponent | LINEからリダイレクトされて戻って来る時の画面 |

```typescript:app-routing.module.ts
const routes: Routes = [
  {path: '', component: HomeComponent, pathMatch: 'full'},
  {path: 'line_auth_handler', component: LineAuthHandlerComponent},
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {
}
```

##AppComponentの設定

```typescript:app.module.ts
@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    LineAuthHandlerComponent,
  ],
  imports: [
    BrowserModule,
    HttpClientModule, // MUST include this under 'imports' after BrowserModule.
    AngularFireModule.initializeApp(environment.firebase),
    AngularFireAuthModule,
    AppRoutingModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
```

## Home Component
Lineログインボタンが押されたあとの動作を書きます。
LINE認証が終わったあとに戻ってくる画面のURLを「redirect_uri」で指定しながら、移動します。

```typescript:home.component.ts
export class HomeComponent implements OnInit {

  constructor(public afAuth: AngularFireAuth,
              private authStateService: AuthStateService) {
  }

  ngOnInit() {
  }

  loginWithLine() {
    const response_type = encodeURIComponent('code');
    const client_id = environment.line.login.channel_id;
    const redirect_uri = encodeURIComponent(window.location.href + 'line_auth_handler');
    const state = this.authStateService.getState();
    const scope = encodeURIComponent('openid profile');
    const url = `https://access.line.me/oauth2/v2.1/authorize?response_type=${response_type}&client_id=${client_id}&`
      + `redirect_uri=${redirect_uri}&state=${state}&scope=${scope}`;

    window.location.href = url;
  }

  logout() {
    this.afAuth.auth.signOut();
  }
}
```

## LineAuthHandlerComponent
LINEからリダイレクトされて戻ってきた際の処理をします。
「code」というリクエストパラメータが渡されるので、それをFirebase Functionsで作るAPIに渡します。
APIでは、FirebaseのCustomTokenを作ってもらえるようにコーディングします。（詳細は下部参照）
なので、CustomTokenが返却されたら、これを元にsign-inすればFirebaseにログインできます。

```typescript:line-auth-handler.component.ts
export class LineAuthHandlerComponent implements OnInit {

  constructor(private authStateService: AuthStateService,
              private route: ActivatedRoute,
              private router: Router,
              private http: HttpClient,
              private afAuth: AngularFireAuth) {
    this.route.queryParams.subscribe((params: Params) => {
        const responded_error = params['error'];
        const responded_error_description = params['error_description'];

        if (responded_error) {
          console.error(responded_error_description);
          this.router.navigate(['/']);
          return;
        }

        const code = params['code'];
        const state = params['state'];

        if (this.authStateService.checkState(state) === false) {
          console.error('state is wrong');
          this.router.navigate(['/']);
          return;
        }

        Promise.resolve().then(() => {
          // Request to Cloud Functions
          const url = `${environment.cloud_functions.host_name}/api/auth/line_login/web/custom_token`;
          const redirectUri = (window.location.href).replace(/\?.*$/, '');
          const body = {
            code: code,
            redirect_uri: redirectUri,
          };
          return this.http.post<CreateCustomTokenResponse>(url, body, {
            headers: new HttpHeaders()
              .set('Content-Type', 'application/json')
            ,
          }).toPromise();

        }).then((response: CreateCustomTokenResponse) => {
          return this.afAuth.auth.signInWithCustomToken(response.firebase_token);

        }).then(() => {
          console.log('signed in successfully.');
          this.router.navigate(['/']);

        }).catch((error) => {
          console.error('failed to sign in.');
          console.error(error);

        });
      },
    );
  }

  ngOnInit() {
  }
}
```

## AuthStateService
Open IDの「state」をフロントエンドでランダム文字列(UUID)にして、リダイレクトされたフロントエンドで照合するためのサービスです。
（しかし、Open ID connect的にこれで適切なのか自信がないです。サーバサイドに保存して照合すべきなのか、必要ないのか、誰か詳しい人いたらコメントお願いします。）


```typescript:auth-state.service.ts
@Injectable()
export class AuthStateService {
  constructor(private cookieService: CookieService) {
  }

  getState() {
    const state = UUID.UUID().toString();
    this.cookieService.set('auth_state', state);
    return state;
  }

  checkState(target: string): boolean {
    const state = this.cookieService.get('auth_state');
    const checkResult = (state === target);

    this.cookieService.delete('auth_state');
    return checkResult;
  }
}
```

## 環境変数の設定
[こちらの手順](https://github.com/angular/angularfire2/blob/master/docs/install-and-setup.md)を元に、environment.tsを設定します。

```typescript:environment.ts
export const environment = {
  production: false,
  firebase: {
    apiKey: 'XXXXX',
    authDomain: 'XXXXX',
    databaseURL: 'XXXXX',
    projectId: 'XXXXX',
    storageBucket: 'XXXXX',
    messagingSenderId: 'XXXXX',
  },
  cloud_functions: {
    host_name: 'https://XXXXX.cloudfunctions.net',
  },
  line: {
    login: {
      channel_id: 'XXXXXXXXXX',
    },
  },
};
```

- [こちらの手順](https://firebase.google.com/docs/functions/http-events?hl=ja#invoke_an_http_function)を参考に、CloudFunctionsのURLを設定しておくのを忘れないで下さい。
- [こちらの手順](https://developers.line.me/ja/docs/line-login/web/integrate-line-login/)を元に、LINEのチャネルIDを設定しておくのを忘れないで下さい。


# 手順２．Firebase Functionsを作成

Firebase Functionsでは、LINEから返却される「code」を元に、LINEアクセストークンを取得し、LINEのUserIDを取得し、FirebaseのCustomTokenを発行するAPIを作成します。

```shell
# プロジェクト作成
cd firebase-login-sample/
mkdir line-login-functions
cd line-login-functions
firebase init functions
cd functions
npm-check-updates -u --removeRange
npm install
npm install --save body-parser express express-validator cookie-parser cors request request-promise

# functions config 設定
furebase use --add
firebase functions:config:set service_account="$(cat service-account.json)"
firebase functions:config:set line.login.channel_id=XXXXXXXXXX
firebase functions:config:set line.login.client_secret=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

- [こちらの手順](https://firebase.google.com/docs/admin/setup?hl=ja#add_firebase_to_your_app)を元にFirebaseのservice-accountを取得し、上記のコマンドでfunctions configを設定しておくのを忘れないで下さい。
- [こちらの手順](https://developers.line.me/ja/docs/line-login/web/integrate-line-login/)を元に、LINEのチャネルIDとチャネルシークレットを取得し、上記のコマンドでfunctions configを設定しておくのを忘れないで下さい。

## index.js
APIのパス設定や、サードパーティー製ライブラリの設定をします。

```javascript:index.js
'use strict';

// Modules imports
const functions = require('firebase-functions');
const rp = require('request-promise');

// Firebase Setup
const admin = require('firebase-admin');
const serviceAccount = functions.config().service_account;
if (!serviceAccount) {
    throw new Error('please set SERVICE ACCOUNT before you deploy!');
} // you can remove this if statement after you set.
admin.initializeApp({
    credential: admin.credential.cert(serviceAccount),
});

const bodyParser = require('body-parser');
const express = require('express');
const expressValidator = require("express-validator");
const cookieParser = require('cookie-parser')();
const cors = require('cors')({origin: true});

const app = express();

app.use(bodyParser.json());
app.use(expressValidator([])); // this line must be immediately after any of the bodyParser middlewares!

app.use(cors);
app.use(cookieParser);

app.post('/auth/line_login/web/custom_token', require('./api/auth/line_login/createCustomTokenForWeb').handler);

// Expose the API as a function
exports.api = functions.https.onRequest(app);
```


## createCustomTokenForWeb.js
codeを受け取ってLINEのアクセストークンを取得する処理です。
lineLoginUtilにアクセストークンを渡して、CustomTokenを発行してもらって、レスポンスします。

```javascript:createCustomTokenForWeb.js
'use strict';
const functions = require('firebase-functions');
const rp = require('request-promise');

// Line Setup
if (!functions.config().line ||
    !functions.config().line.login ||
    !functions.config().line.login.channel_id ||
    !functions.config().line.login.client_secret) {
    throw new Error('please set LINE login CHANNEL_ID & CLIENT_SECRET before you deploy!');
}

const line_login_channel_id = functions.config().line.login.channel_id;
const line_login_client_secret = functions.config().line.login.client_secret;

// API code
exports.handler = ((req, res) => {
    req.checkBody('code').notEmpty();
    req.checkBody('redirect_uri').notEmpty();

    const code = req.body.code;
    const redirect_uri = req.body.redirect_uri;

    req.getValidationResult().then(result => {
        console.info('validating requested data.');

        if (!result.isEmpty()) {
            return res.status(400).json({
                error: result.array()
            });
        }
    }).then((error) => {
        if (error) {
            console.error(error);
            throw new Error('validating request Error');
        }
        console.info('validated. (result OK)');
        return Promise.resolve();

    }).then(() => {
        const options = {
            method: 'POST',
            uri: 'https://api.line.me/oauth2/v2.1/token',
            json: true,
            timeout: 10 * 1000,
            form: {
                grant_type: 'authorization_code',
                code: code,
                redirect_uri: redirect_uri,
                client_id: line_login_channel_id,
                client_secret: line_login_client_secret,
            },
        };
        return rp(options);

    }).then((response) => {
        console.log('requested token from code successfully.');
        return Promise.resolve(response);

    }).then((response) => {
        const lineAccessToken = response.access_token;

        const line_login_util = require('./lineLoginUtil');
        return line_login_util.createFirebaseCustomToken(lineAccessToken);

    }).then((firebaseCustomToken) => {
        const result = {
            firebase_token: firebaseCustomToken,
        };
        return res.status(200).json(result);

    }).catch(error => {
        console.error('Error: ', error);
        return res.status(500).json({error: error});
    });
});
```

## lineLoginUtil.js
LINEアクセストークンを元に、LINEプロフィールAPIを呼び出します。
本記事では触れていませんが、iOS/Android用のcreateCustomTokenForClient.jsと処理を共通化するために、ファイルを分けて書いています。（詳細は、Githubのコードをご参照ください。）

```javascript:lineLoginUtil.js
'use strict';
const admin = require('firebase-admin');
const rp = require('request-promise');

exports.createFirebaseCustomToken = function (lineAccessToken) {
    let firebaseUid;

    return Promise.resolve().then(() => {
        const options = {
            method: 'GET',
            url: 'https://api.line.me/v2/profile',
            json: true,
            timeout: 10 * 1000,
            headers: {
                'Authorization': `Bearer ${lineAccessToken}`,
            },
        };
        return rp(options);

    }).then((response) => {
        console.log('requested profile successfully.');
        return Promise.resolve(response);

    }).then((response) => {
        if (!response.userId) {
            return Promise.reject('No userId.');
        }

        firebaseUid = 'line:' + response.userId;
        return admin.auth().getUser(firebaseUid).then(() => {
            console.log(`user ${firebaseUid} was found.`);
        }).catch((error) => {
            if (error.code === 'auth/user-not-found') {
                const createRequest = {
                    uid: firebaseUid,
                };
                if (response.displayName) createRequest.displayName = response.displayName;
                if (response.pictureUrl) createRequest.pictureUrl = response.pictureUrl;

                return admin.auth().createUser(createRequest).then(() => {
                    console.log('created user successfully.');
                    return Promise.resolve();
                });
            } else {
                return Promise.reject(error);
            }
        });

    }).then(() => {
        const claims = {
            provider: 'LINE',
        };

        return admin.auth().setCustomUserClaims(firebaseUid, claims);

    }).then(() => {
        console.log('set custom user claims successfully.');
        return Promise.resolve();

    }).then(() => {
        return admin.auth().createCustomToken(firebaseUid);

    }).then((firebaseCustomToken) => {
        console.log('created custom token successfully.');
        return Promise.resolve(firebaseCustomToken);

    });
};
```

## CloudFunctionsのデプロイ
```shell:terminal
cd firebase-login-sample/line-login-functions/functions/
firebase deploy --only functions:api
```

# 手順３．LINEのコールバックURLの設定
[こちらの手順](https://developers.line.me/ja/docs/line-login/web/integrate-line-login/)を元に、LINEコンソールでコールバックURLを設定して下さい。

（設定例） 
http://localhost:4200/line_auth_handler
https://XXXXX.firebaseapp.com/line_auth_handler

# Firebaseホスティングの起動
```shell:terminal
# ローカル起動
cd firebase-login-sample/line-login-app/
ng serve
```


# 参考
[Google Developers Japan: LINE ログインによる Firebase ユーザーの認証](https://developers-jp.googleblog.com/2016/11/authenticate-your-firebase-users-with-line-login.html)
[ウェブアプリにLINEログインを組み込む](https://developers.line.me/ja/docs/line-login/web/integrate-line-login/)