AngularDartとFirebase Authentication

特に難しくはないが、今後新規プロジェクトを始めるたびに考えずにコピペで済ませられるように記録に残しておく。
[Angular2とFirebase hostingでSPA開発環境を整える
](http://qiita.com/ntaoo/items/d11a0cd3fa92f98372a4)の続編のような感じ。

# やりたいこと

1. AngularDart(SPA)からFirebase Authenticationを行いたい
2. Backend serverのAuthorizationを行いたい
3. Authenticationが必要なページと必要ないページが混在できるようにしたい

### サンプルコード

[https://github.com/ntaoo/ng2_firebase_gcp/tree/master/authentication](https://github.com/ntaoo/ng2_firebase_gcp/tree/master/authentication)

# Consoleを操作してAuthentication Providerを有効化する

SIGN-IN METHODでAuthenticationに使用するProvider有効化する。今回はGoogleのみにするが他のProviderでも特につまづかないはず。
![Screen Shot 2016-12-04 at 16.00.07.png](https://qiita-image-store.s3.amazonaws.com/0/8065/fae679f9-881d-089c-4277-253ff6986b3b.png "Screen Shot 2016-12-04 at 16.00.07.png")

# Firebase Package

[firebase package](https://pub.dartlang.org/packages/firebase)を使用する。(firebase3 packageの作者の協力で、firebase3に対応したものが再びfirebase/firebase-dartリポジトリでメンテナンスされるようになっている)

firebase script tagをhtml>headに追加しておくこと。

# FirebaseService

Firebase APIをAngularDartで利用したいので、ServiceにしてAppComponentのproviderに登録しておく。

```dart:firebase_service.dart
import 'package:angular2/angular2.dart';
import 'package:firebase3/firebase.dart' as firebase;

@Injectable()
class FirebaseService {
  final firebase.Auth auth;

  factory FirebaseService() {
    firebase.initializeApp(
        apiKey: " AIzaSyCIQNJV-TFRl9BK_9hVuS8lkgMxD69_Z0A",
        authDomain: "ng2-firebase-gcp.firebaseapp.com",
        databaseURL: "https://ng2-firebase-gcp.firebaseio.com",
        storageBucket: "ng2-firebase-gcp.appspot.com");
    return new FirebaseService._(firebase.auth());
  }
  FirebaseService._(this.auth);
}
```

# AuthService

FirebaseServiceに依存してこのSPAアプリのauthを担当するserviceも作成し、同じくAppComponentのproviderに登録しておく。

```dart:auth_service.dart
/// 前略
@Injectable()
class AuthService {
  final firebase.Auth _auth;
  final StreamController<bool> _onAuthStateChangedController;

  /// Nullable. If null, it means that firebase authentication process has not been completed yet (intermediate state).
  User user;

  /// This getter doesn't care the intermediate state. Use [checkUserAuthenticated()] if that state is possibility.
  bool get isUserAuthenticated => user is AuthenticatedUser;
  Stream<bool> get onAuthStateChanged => _onAuthStateChangedController.stream;

  AuthService(FirebaseService firebaseService)
      : _auth = firebaseService.auth,
        _onAuthStateChangedController = new StreamController<bool>.broadcast() {
    _listenFirebaseOnAuthStateChanged();
  }
/// 後略
```

# Sign In

FirebaseのSign Inには、`SignInWithPopup`と`SignInWithRedirect`の２つのメソッドがある。
`SignInWithPopup`はSPAアプリの同一ページのままConsent ScreenがPopupする制御フローとなり、`SignInWithRedirect`はConsent Screenページに遷移したあと、元のSPAアプリのページにリダイレクトされる制御フローとなる。

`SignInWithPopup`はページ遷移しないためSign Inフローの制御がしやすい。ただし、Mobile WebでのUXを考慮する場合は`SignInWithRedirect`を選択するのが望ましい。

今回は両方のメソッドを実装し、サンプルコードを公開している。

## SignInWithPopup

1) FirebaseのAPI(`signInWithPopup`)をcallする。(_runBackendAuthorizationについては、後述の「Backend serverのAuthorization」を参照)

```dart:auth_service.dart
  // Assuming only google auth on this example.
  Future signInWithPopup() async {
    var credential =
        await _auth.signInWithPopup(new firebase.GoogleAuthProvider());
    return _runBackendAuthorization(credential);
  }
```

2) AuthServiceのconstructor bodyでlistenしておいた、firebase.auth.onAuthStateChangedイベントを処理する。

```dart:auth_service.dart
  void _listenFirebaseOnAuthStateChanged() {
    _auth.onAuthStateChanged.listen((firebase.AuthEvent event) {
      _handleOnAuthStateChanged(event);
      _onAuthStateChangedController.add(isUserAuthenticated);
    });
  }

  void _handleOnAuthStateChanged(firebase.AuthEvent event) {
    var u = event.user;
    user = u != null ? new AuthenticatedUser(u) : new GuestUser();
  }
```

firebase.auth.onAuthStateChangedイベントは主にsign in, sign outで発火し、Sign Inすれば`event.user`が存在し、そうでなければ`event.user`はnullとなる。

3) authentication成功後、クライアントサイドルーティングでページ遷移。

認証済みページ等に遷移させる。

```dart:sign_in_component.dart
  Future<Null> signInWithPopup() async {
    await _authService.signInWithPopup();
    if (await _authService.checkUserAuthenticated()) _router.navigate(['Some']);
  }
```

## SignInWithRedirect

Mobile Webを考慮すればこちらのメソッドを選択するべきだが、いったんFirebaseによる認証画面に遷移し、SPAアプリにリダイレクトされる場合、SPAアプリがreloadされることになる。リダイレクト先URLを指定することはできないため、認証後のリダイレクトであることを判別するため、あらかじめその情報を一時的に保持しておく必要がある。

今回は、SessionStorageに保持しておくことにした。

```dart:sign_in_component.dart
  Future<Null> signInWithRedirect() {
    window.sessionStorage[_willRedirectKey] = 'true';
    return _authService.signInWithRedirect();
  }
```

Firebaseからリダイレクトされたとき、SessionStorageの情報を参照し、リダイレクト直後の起動であるかどうかを判定する。

```dart:sign_in_component.dart
  Future ngOnInit() {
    return new Future.sync(() async {
      if (_redirectedFromFirebase()) {
        await _authService.handleSignInWithRedirectResult();
        window.sessionStorage.remove(_willRedirectKey);
        if (await _authService.checkUserAuthenticated()) {
          _router.navigate(['Some']);
        }
      }
    });
  }
```

firebase.auth.onAuthStateChangedイベントが発火することになり、SignInWithPopupの場合と同じく、認証処理が完了する。

この認証フローの場合のcredentialはFirebaseのgetRedirectResult() APIで取得する。

```dart:auth_service.dart
  Future handleSignInWithRedirectResult() async {
    var credential = await _auth.getRedirectResult();
    return _runBackendAuthorization(credential);
  }
```

プロダクションアプリではリダイレクト後の認証処理の間、全画面progress circleなどを表示しておくと良い。

# Sign Out

FirebaseのsignOut() APIをcallするだけ。onAuthStateChangedイベントが発火する。

# Backend serverのAuthorization

Firebaseだけではできない処理を行うために、別途Backend serverを用意し、Authorizationしたい。
そのためトークンをBackend serverに渡し、トークンをverifyさせる。
適宜server側のCORSの設定を行うこと。以下の例ではAuthorizationヘッダーをリクエストに追加しているので、serverにCORSでアクセスさせる場合は、'Access-Control-Allow-Headers'に'Authorization'を許可する設定が必要となる。

```dart
  Future _runBackendAuthorization(firebase.UserCredential credential) async {
    var idToken = await credential.user.getToken();
    var client = new http.BrowserClient();
    try {
      return await client.post('http://localhost:9920/authorization',
          headers: {'Authorization': idToken});
    } catch (e) {
      print("Error on '_runBackendAuthorization': $e");
    }
  }
```

サーバー側の実装は省略。
いくつかの言語では`verify_firebase_token()`的なAPIを叩けるライブラリすら用意されている。
tokenをverifyし、userIdやexpired time等を取得し、それらを含めた必要な情報を永続化してキャッシュし、その後は必要に応じてまたtokenをverifyすれば良い。
クライアントSPAは、有効なtokenを毎回Authorization headerにセットしてhttpRequestする。

# ユーザー認証済みでなければアクセスできないページを設定する

特定のページを、ユーザー認証済みでなければアクセスできないようにしたい。
AngularDartのrouterには`CanActivate` APIがあるため、これをアノテーションにしてページを表すComponentに付加し、認証済みであるかどうかを判定するメソッドを呼び出す。

```dart
@Component(
    selector: 'some',
    template: 'Some auth required component. user is {{authService.user.displayName}}')
@CanActivate(checkAuthenticated)
class SomeComponent {
  AuthService authService;
  SomeComponent(this.authService);
}

```

ただし、APIの制限によりAuthServiceのcheckUserAuthenticated()を直接呼び出すことができないため、ワークアラウンドコードを書く。

あらかじめAppComponentのコンストラクタで`setInjector()`を実行してInjectorをセットしておき、checkAuthenticated()内でそのInjectorからAuthServiceを取得し、`checkUserAuthenticated()`で認証済みであるかを判定する。

```dart:check_authenticated.dart
import 'dart:async';
import 'package:angular2/angular2.dart';
import 'package:angular2/router.dart';
import 'model/service/auth_service.dart';

Injector injector;

void setInjector(Injector _injector) {
  injector = _injector;
}

Future<bool> checkAuthenticated(
    ComponentInstruction _, ComponentInstruction __) async {
  bool isAuthenticated =
      await injector.get(AuthService).checkUserAuthenticated();
  if (!isAuthenticated) injector.get(Router).navigate(['SignIn']);
  return isAuthenticated;
}

```

Router APIは近々書きかえられるようなので、このような不格好なワークアラウンドコードを書かなくてもよくなるかもしれない。

# あとがき

もしももっと良い方法があれば教えてください。