🧊

JavaScriptからAmazon Cognitoを使うためのまとめ

最低限な要件だけ使ってみるにしても罠だらけだったのでメモ。
調べてもろくな情報出てこなかったので、業務レベルではまじで誰も使ってないんじゃねーのって気持ちがある。

願わくばもう使いたくない( ˘ω˘)

Amazon Cognito is 何

そもそもですが・・。
Amazon Cognitoでできることは、大きく分けて2つです。

  • ログイン・セッション機能
  • データの同期機能

AWSのコンソールのCognitoのページUI的にも、

  • User Pools
  • Federated Identities

ってな切り分けになっててそれぞれ対応してる。
まあこのサーバーレスだなんだの時代に、そういうことできるサービスがあることは不思議ではない。

Alternativesという意味では、Firebase Authenticationとかあたり?

JavaScriptSDKたち

そしてjsで使う上で必要であろうSDKですが、いくつかあります。(=バラバラです)

npmからインストールできるそれ関連のパッケージはこの3つ。
使いたい機能によって、必要なライブラリが変わります。

Cognitoを使うには、どちらの機能にしてもコンソールでいくつか作業が必要なのですが、この記事ではコードだけ載せていきます。
なんしかコンソールを通して拾っておくべき情報は以下4つ。

  • region
  • IdentityPoolId
  • UserPoolId
  • ClientId

コンソール作業での落とし穴

User Poolsを作ったあと、それを使うためのAppを登録すると思います。
そのときに、クライアントシークレットを「生成しない」ようにする必要があります。

なんかJavaScriptSDKからは、シークレットを使った認証をサポートしてないらしく。
`1 validation error detected: Value at 'clientId' failed to satisfy constraint`とかいう見当違いのエラーが返ってきたらそれな可能性が高いです。

ちなみに、別App = 別ClientIdになるので、Federated IdentitiesのIdentity Poolの設定としても、別の外部Providerとして追加が必要。

にしてもCognitoのコンソールのUIが使いにくすぎる・・・

User Pools

いわゆるログイン・セッションの機能。
端末またぎで同一ユーザーとしてログイン状態を管理したり、他AWSAPIを叩くためのトークン取得などに使う。

npm i amazon-cognito-identity-js

検索するといろんなサンプルが出てくるけど、こっちはこれだけでOK。
ただし`webpack` x `json-loader`が必須っぽくて、`browserify`だけでシュッとやるのは無理そう・・?

これはSMS認証する最低限のコード。

まずはプールに登録する。

const {
  CognitoUserPool,
  CognitoUserAttribute,
} = require('amazon-cognito-identity-js');
const config = require('./config');

const userPool = new CognitoUserPool({
  UserPoolId: config.UserPoolId,
  ClientId: config.ClientId,
});

const attributeList = [new CognitoUserAttribute({
  Name: 'phone_number',
  Value: '+8190xxxxyyyy',
})];

userPool.signUp(username, password, attributeList, null, (err, result) => {
  if (err) { console.error(err); return; }

  const cognitoUser = result.user;
});

これでSMSにメールが飛ぶ!ので、そこのコードを見て、

const {
  CognitoUser,
  CognitoUserPool,
} = require('amazon-cognito-identity-js');
const config = require('./config');

const userPool = new CognitoUserPool({
  UserPoolId: config.UserPoolId,
  ClientId: config.ClientId,
});

const cognitoUser = new CognitoUser({
  Username: username,
  Pool: userPool,
});

cognitoUser.confirmRegistration(pincode, true, (err, result) => {
  if (err) { console.error(err); return; }
  console.info(result);
});

これで確認完了。
そうすると、ついにトークンを取りにいけるので・・

const {
  AuthenticationDetails,
  CognitoUser,
  CognitoUserPool,
} = require('amazon-cognito-identity-js');
const config = require('./config');

const userPool = new CognitoUserPool({
  UserPoolId: config.UserPoolId,
  ClientId: config.ClientId,
});

const cognitoUser = new CognitoUser({
  Username: username,
  Pool: userPool,
});

const authenticationDetails = new AuthenticationDetails({
  Username: username,
  Password: password,
});

cognitoUser.authenticateUser(authenticationDetails, {
  onSuccess(result) {
    const token = result.getIdToken().getJwtToken();
  },

  onFailure(err) {
    console.error(err);
  },
});

晴れてトークンが手に入ったので、これでこの後の処理をなんやかんや。
他のAWSAPIを叩くときにはこのトークンを渡す。

今回はSMSで認証してるけど、`attributeList`を空にすればID/PWだけでもOKで、最低限のログイン機能は作れる。

他にこのSDKでできることはリポジトリにあるコードサンプルが全てなので、PINコードの再送とか、パスワード忘れたとか、認証要素を追加したいとかは以下リンクを参照。
2017年ですがPromiseなAPIはありません。

GitHub - aws/amazon-cognito-identity-js

Federated Identities

上述の機能とは切り離して使えるデータ同期の仕組み。
存在意義としては、前述のログイン機能と連携することもできるし、連携せずに未認証状態でデータを貯められる。

こっちはこの2つが必要。というのも、

const AWS = require('aws-sdk');
console.log(AWS.CognitoSyncManager); // undefined

require('amazon-cognito-js');
console.log(AWS.CognitoSyncManager); // function....

こういうわけ。
必要というより、`AWS.CognitoSync`だけでできる処理をラップしてある感じ。

( ˘ω˘)..。o( いやそれもう本家にいれろよ

使うのは簡単で、

const AWS = require('aws-sdk');
require('amazon-cognito-js');
const config = require('./conf');

AWS.config.region = config.region;
AWS.config.credentials = new AWS.CognitoIdentityCredentials({
  IdentityPoolId: config.IdentityPoolId,
});

AWS.config.credentials.getPromise()
  .then(() => {
    const client = new AWS.CognitoSyncManager();
    // コンソールではコレで探すことになる
    // client.getIdentityId()
    // 同じものが取れる
    // AWS.config.credentials.identityId

    client.openOrCreateDataset('test', (err, dataset) => {
      if (err) { console.error(err); return; }

      dataset.get('foo', (err, value) => {
        if (err) { console.error(err); return; }
        console.info('get', value);
      });

      dataset.put('foo', 'bar', (err, value) => {
        if (err) { console.error(err); return; }
        console.info('put', value);
      });

      // これするまではLocalStorageで完結
      dataset.synchronize({});
    });
  })
  .catch(err => console.error(err));

Unauthenticatedでの利用を許可してれば、これだけでデータを貯め続けることができます。

GitHub - aws/amazon-cognito-js: Amazon Cognito Sync Manager for JavaScript

ただこのままだとLocalStorageが消えたらデータにアクセスできなくなるので、なんらかひもづけしたい・・となるはず。

そこで、今まで紹介した2つの合わせ技を使うことで、ログイン状態を保ちつつデータを貯め続けることができます。
ですがその前に・・、

aws/amazon-cognito-js の落とし穴

[1] READMEには載ってないメソッドがいろいろある。

  • `putAll()`とか
  • `getAll()`とか

必要あればそっちも。

[2] `put()`する時の注意。

dataset.put('文字列以外を', 1, () => {});
dataset.put('putしてしまうと, {}, () => {});

// ここでエラーになってリモートに保存されない
dataset.synchronize({});

`synchronize()`しない場合は問題ないです。LocalStorageにデータはちゃんと貯まるし、`put() / get()`の際に`JSON.stringify() / parse()`してくれます。

なのに、`synchronize()`すると突然エラーになります。しかも、`exceeded maximum retry count`とか見当違いのエラーを吐きます。

"Synchronized failed: exceeded maximum retry count" when you attempt to store numbers · Issue #6 · aws/amazon-cognito-js · GitHub

あと、`put()`の第三引数の空関数は省略できません。(^ω^#)

ログインしてデータ同期

さて、どうやって同一IDでCognito Syncし続けるかでした。
まずさっきの未認証状態でのデータ同期のコードのおさらい。

// まずCredentialsを生成
AWS.config.credentials = new AWS.CognitoIdentityCredentials({
  IdentityPoolId: config.IdentityPoolId,
});

// そしてそれを取得して
AWS.config.credentials.getPromise()
  .then(() => {
    // 取得できたらSync
    const client = new AWS.CognitoSyncManager();    
  });

これは未認証状態でも同期できるようにしてるので、Credentialsにはユーザーごとに一意な情報を渡してないです。
なのでココに、ユーザーを判別できるもの = ログイン機能で得られるトークンを渡せばOKということ。

// authenticateしてたら取れる
const cognitoUser = userPool.getCurrentUser();

if (cognitoUser === null) {
  console.log('no user');
  return;
}

cognitoUser.getSession((err, result) => {
  if (err) { console.error(err); return; }
  console.log('user', cognitoUser);

  // ログイン状態でCredentialsを生成
  AWS.config.credentials = new AWS.CognitoIdentityCredentials({
    IdentityPoolId: config.IdentityPoolId,
    Logins: {
      [`cognito-idp.${config.region}.amazonaws.com/${config.UserPoolId}`]: result.getIdToken().getJwtToken()
    }
  });
});

ここまでやれば、あとは同じコードでいけます。
もちろんコンソールで、Identity Poolの外部プロバイダーの設定をしてないとダメです。

なので冒頭の3つのライブラリを全て組合せないといけない・・。

未認証 -> 認証ユーザーへのアップグレード

私はこれで困ったのですが、

  • 未認証状態でSyncしてるデータがある
  • 認証状態になったときに、そのデータを引き継ぎたい

結論からいうと、それ用のAPIは用意されてないっぽいので、自力でやるしかないです。
FirebaseにはそういうAPIあったのに。

なので流れとしては、

  • 未認証状態でSyncしてるデータを`get`
  • オンメモリで一時的に保存
  • 認証して新しいCredentialsを生成
  • 認証状態でさっき一時的に保存したデータを`put`

という手間が必要。ダルい。

`synchronize()`時にコンフリクトする

コンフリクトは、`synchronize()`にコールバックとして解消するためのハンドラを渡す。
これはREADMEにあったコード。

dataset.synchronize({
  onSuccess: function(dataset, newRecords) {
     //...
  },

  onFailure: function(err) {
     //...
  },

  onConflict: function(dataset, conflicts, callback) {
     var resolved = [];

     for (var i=0; i<conflicts.length; i++) {

        // Take remote version.
        resolved.push(conflicts[i].resolveWithRemoteRecord());

        // Or... take local version.
        // resolved.push(conflicts[i].resolveWithLocalRecord());

        // Or... use custom logic.
        // var newValue = conflicts[i].getRemoteRecord().getValue() + conflicts[i].getLocalRecord().getValue();
        // resolved.push(conflicts[i].resolveWithValue(newValue);

     }

     dataset.resolve(resolved, function() {
        return callback(true); // onSuccessへ
     });

     // Or... callback false to stop the synchronization process.
     // return callback(false); // onFailureへ
  },

  onDatasetDeleted: function(dataset, datasetName, callback) {
     // Return true to delete the local copy of the dataset.
     // Return false to handle deleted datasets outsid ethe synchronization callback.

     return callback(true);
  },

  onDatasetsMerged: function(dataset, datasetNames, callback) {
     // Return true to continue the synchronization process.
     // Return false to handle dataset merges outside the synchroniziation callback.

     return callback(false);
  }
});

わかる・・わかるけど解せぬ・・。