見出し画像

Magic Leap 1 OAuth Windowを使い、Google OAuth 2.0 認証によるログイン~ユーザー情報の取得について

概要

Lumin SDK 0.24からOAuth Window機能が追加されました。

今回は、OAuth Windowの機能説明とOAuth Windowを使い、Google OAuth 2.0 認証を使ったログイン処理~ユーザー情報の取得の一連の処理について説明します。 (開発についての説明はUnity Editorを使用した説明となります。)

Magic Leap 1 の OAuth Windowについて

画像2

Magic Leap 1 の OAuth Windowは、Magic Leap 1 アプリケーションからOAuth認証のWeb画面にアクセスを提供する機能です。

OAuth Windowの処理フロー

画像17

OAuth Window はログイン機能へのアクセス、ログイン後に認可サーバーから返却された認可コード、scope、state(※1)をMagic Leap 1 アプリケーションで取得することができます。

(※1)ログイン画面にアクセス時にパラメータに含めてた場合に限ります。

この認可コードを使い、アクセストークンやリフレッシュトークンの取得を行い、取得したアクセストークンを使ってアカウントに紐づく情報にアクセスするわけですが、Magic Leap が提供するOAuth Windowには、そのような機能は提供されていません。

OAutn Windowは、認可コード、scope、stateの取得まで。

アクセストークンの取得~アクセスしたい情報の取得処理は、自前で実装する必要があります。

開発環境

Lumin SDK 0.24 or 0.24.1 の場合 Unity Editor 2019.3
Lumin SDK 0.25 の場合 Unity Editor 2020.2
Lumin SDK 0.26 の場合 Unity Editor 2020.3

以下はLumin SDK のバージョンに対し、サポートしているUnity Editorのバージョンが全て網羅したページになります。

https://developer.magicleap.com/en-us/learn/guides/unity-doc-archive

当機能は、Unity、Unreal Engine、C API、Lumin Runtime、MagicScript をサポートしています。この記事では、Unityによる開発方法の説明となります。

事前準備

今回は、Magic Leap 1を使用し、GoogleアカウントのログインからGoogle アカウントの情報を取得を行います。

1. プロジェクトの作成

Google Developer Console(以下)にアクセスし、Googleアカウントのユーザー情報を取得するプロジェクトを作成します。

画像3
画像4

2.認証情報(OAuth 2.0 クライアント ID)の作成

アプリケーションから認証するための情報を作成します。

画像11

左側のメニューから「APIとサービス」→ 「OAuth 同意画面」を選択します。


画像8

OAuth同意画面の User Type は「外部」を選択後、「作成」ボタンをクリックします。


画像9

OAuth 同意画面のアプリ情報、デベロッパーの連絡先情報を入力します。

認可コード、scope、stateをMagic Leap 1のアプリケーションに返却するためのページを私のgithubページ配下に作成しています。その為、承認済みドメインには「tokufxug.github.io」を追加後、「保存して次へ」ボタンをクリックします。


画像10

スコープでは、何も設定しないまま「保存して次へ」ボタンをクリックします。


画像11

テストユーザーでは、何も設定しないまま「保存して次へ」ボタンをクリックします。


画像10

左側のメニューから「APIとサービス」→ 「認証情報」を選択します。


画像11

認証情報画面内の左側辺りにある「+認証情報を作成」をクリック。メニューが表示されます。「OAuth クライアントID」を選択してください。


画像12

OAuth クライアントIDを作成します。以下の通りに入力してください。

アプリケーションの種類:ウェブ アプリケーション
名前: (任意)
URI:https://tokufxug.github.io/ML-OAuth-Redirect/mloauth.html

今回作成する OAuth Windowのアプリケーションに認可コード、scope、stateを返却するためのWebページを事前に用意してました。URIについては、このWebページのURLを設定しています。


画像13

OAuthクライアントが作成されると「クライアントID」と「クライアントシークレット」が作成されます。これらの情報は後で使用するため、どこかに保存しておいてください。

構築 / 実装

ここではUnity Editorを使用し、Magic Leap 1上で動作するOAuth Windowのサンプルアプリケーションを構築します。

Privileges(特権)

以下の特権が必要です。

SecureBrowserWindow
Internet
LocalAreaNetwork

API Level

Level 8 以上の設定が必要です。

1. OAuth Window サンプルプログラム Unity Package の インポート

OAuth Windowを使い、Google アカウントのユーザー情報を取得するサンプルプログラムが含まれたUnity Packageを以下からダウンロードします。

https://drive.google.com/file/d/1AQmWHHwnQbQ64LVnc9kYBjoBpf11t2rX/view?usp=sharing


画像14

ダウンロードしたUnity Packageをインポートします。

インポート後、Assets/Magic Leap Google OAuth/Scene/にあるGoogleOAuthDemo.unityを開きます。


画像15

ヒエラルキーの[Example]/GoogleOAuthExampleを選択します。


2. クライアントID と クライアントシークレットの設定

画像16

インスペクタの Client ID Client Secret は、Google Developer Console のOAuth クライアントID で作成時に発行された「クライアントID」と「クライアントシークレット」を設定します。


3. アプリケーションのビルド

Magic Leap 1にビルドする設定~ビルドまでを行います。

画像17

Player Settings の Magic Leap Manifest Settings にある InternetSecureBrowserWindow LocalAreaNetworkにチェックを入れます。次にMagic Leap 1をPCに接続して、Build And Run を実行します。


4. 動作確認

Control の Bumperを押すとOAuth Windowが表示され、Googleアカウントのログイン画面が表示されます。

ログイン後、リダイレクト先ページ内のリンクをクリックするとアプリケーションに戻ります。

このタイミングでリダイレクト先ページからアクセストークンとリフレッシュトークンが返却され、アクセストークンから、ユーザー情報の取得~表示までを行っています。

アプリケーションの再ロードまたは再度、起動した場合、リフレッシュトークンから、アクセストークンを取得するため、OAuth Windowは起動せず、ログイン処理~ユーザー情報の取得を実施しています。

実装ポイント

ここではOAuth Window ~ ユーザー情報取得までの処理について、説明します。

1.  Google 認証画面  URL の 作成

void Awake()
{
    state = GetRandomStringForUrl(32);
    oauthURL = string.Format(
         "https://accounts.google.com/o/oauth2/v2/auth?client_id={0}&redirect_uri={1}&scope={2}&code_challenge={3}&state={4}&&code_challenge_method=S256&response_type=code&access_type=offline&prompt=consent&session=false"
        , clientId
        , redirectUri
        , "https://www.googleapis.com/auth/userinfo.profile"
        , GetCodeChallenge()
        , state
    );        
}

OAuth Window上で開くGoogle 認証画面 の URLを作成します。

client_id Google Developer Consoleで生成されたクライアントID。redirect_uri Googleの認証後に遷移するURL。(このページからアプリケーションに認可コードなどを渡して戻ります。)
scope Google APIの認証したいAPIを設定します。(今回はユーザー情報の取得を行うため、https://www.googleapis.com/auth/userinfo.profile を指定。
code_challenge 何らかの方法で認可コードを含むカスタムURIを取得した場合、ユーザー固有のアクセストークンを横取りされる恐れがあります。そのためPKCE(Proof Key for Code Exchange)を実施を行うため照合用の
データ。
state リダイレクト先がリクエストの結果であることを確認するためのデータ。


2.  OAuth Window を開く

void Start()
{
    MLInput.OnControllerButtonUp += OnButtonUp;
    oAuthEvent += OAuthCallback;
}

void OnDestroy()
{
    MLInput.OnControllerButtonUp -= OnButtonUp;
    oAuthEvent -= OAuthCallback;
}

void OnButtonUp(byte controllerID, MLInput.Controller.Button button)
{
    if (button == MLInput.Controller.Button.Bumper)
    {
        string refreshToken = GetRefreshToken();
        if (String.IsNullOrEmpty(refreshToken))
        {
            MLResult result = MLDispatch.OAuthRegisterSchema(customURLSchemeRedirectUri, ref oAuthEvent);
            MLDispatch.OAuthRegisterSchema(cancelUri, ref oAuthEvent);
            MLDispatch.OAuthOpenWindow(oauthURL, cancelUri);
        }
        else
        {
            StartCoroutine(RetrieveAccessToken(refreshToken));
        }
    }
    else if (button == MLInput.Controller.Button.HomeTap)
    {
        MLDispatch.OAuthUnRegisterSchema(customURLSchemeRedirectUri);
        MLDispatch.OAuthUnRegisterSchema(cancelUri);
    }
 }

OAuthWindowからMagic Leap 1のアプリケーションに戻ったときに呼び出されるイベント、oAuthEvent(MLDispatch.OAuthHandler)のイベントハンドラを登録。Controlのボタンアップのイベントハンドラも登録。

Control の Bumperボタンが押されたタイミングで、MLSecureStorageにリフレッシュトークンの存在チェックを実施。リフレッシュトークンが取得できない場合、OAuthRegisterSchemaにリダイレクト先ページから認可コードなどをアプリケーションに返却するスキーマーとキャンセル時のスキーマーのイベントハンドラを登録。その後、OAuth Windowを起動。ああああ

Homeボタンが押された場合、各スキーマーのイベントハンドラの登録を解除します。


3. OAuthのコールバック

void OAuthCallback(string response, string schema)
{
    if (schema == customURLSchemeRedirectUri)
    {
        var authResponse = response.Replace(customURLSchemeRedirectUri, "");
        var authJson = JsonUtility.FromJson<AuthResult>(authResponse);
        if (authJson.State == state)
        {
            StartCoroutine(RetrieveAccessTokenAndRefreshToken(authJson.Code));
        }
        else
        {
            var msg = "Error: Inconsistent State during authentication.";
            DataText.text = msg;
            throw new Exception(msg);
        }
    }
    else if (schema == cancelUri)
    {
        DataText.text = "Cancel";
    }
}

OAuthCallbackは、OAuth Windowからアプリケーションに戻った際に呼び出されるイベントハンドラです。事前にOAuthRegisterSchemaで登録している必要があります。スキーマーはstring変数で定義しています。(以下、参照。)

private string customURLSchemeRedirectUri = "redirecturi://";
private string cancelUri = "canceluri://";

canceluri:// ‥ OAuthWindow の Cancelボタンで呼び出されるスキーマーredirecturi:// ‥ 今回作成したリダイレクト先のページに埋め込まれたアンカーに含まれているスキーマー。(以下、参照。)

<!DOCTYPE html>
<html lang="en">
   <head>
       <meta charset="UTF-8">
       <title>Magic Leap 1 OAuth Sample</title>
   </head>
   <script type="text/javascript">
       function onRedirectOAuth()
       {
           var params = new URLSearchParams(window.location.search);
           var val = '';
           
           if (params.get('state'))
           {
               val = 'redirecturi://{"code" : "' + params.get('code') + '", "scope" : "' +  params.get('scope') + '", "state" : "' +  params.get('state') + '"}'; 
           }
           else
           {
               val = 'redirecturi://{"code" : "' + params.get('code') + '", "scope" : "' +  params.get('scope') + '"}';
           }
           console.log(val);
           location.href = val;
       }
   </script>
   <body>
       <center>
           <a href="#" onclick="onRedirectOAuth();">Return to the application</a>
       </center>
   </body>
</html>

今回、サンプル用に作成したリダイレクト先のページになります。ページ内のリンクをクリックすると redirecturi:// が呼び出しを行っています。認可コード、scope、stateはJSON形式にし、redirecturi://に含めてます。

if (schema == customURLSchemeRedirectUri)
{
    var authResponse = response.Replace(customURLSchemeRedirectUri, "");
    var authJson = JsonUtility.FromJson<AuthResult>(authResponse);
    if (authJson.State == state)
    {
        StartCoroutine(RetrieveAccessTokenAndRefreshToken(authJson.Code));
    }
    else
    {
        var msg = "Error: Inconsistent State during authentication.";
        DataText.text = msg;
        throw new Exception(msg);
    }
}

スキーマーがredirecturi:// であれば、認可コード、scope、stateをJSONオブジェクトに変換。stateが一致していればアクセストークンとリフレッシュトークンの取得処理に進みます。

4. アクセストークンとリフレッシュトークンの取得~ユーザー情報の取得

アクセストークンとリフレッシュトークンの取得~ユーザー情報の取得方法についての説明は、Magic Leap 1が提供するOAuth Windowによる処理ではないため割愛します。今回のメインとなるソースコードは以下になります。(OAuth Window サンプルプログラム Unity Packageに含まれているソースコードと同一のものになります。)

using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using System.Security.Cryptography;
using UnityEngine;
using UnityEngine.XR.MagicLeap;
using UnityEngine.UI;
using UnityEngine.Networking;

namespace MagicLeapGoogleOAuth
{
   public class GoogleOAuthExample : MonoBehaviour
   {
       [Serializable]
       public class AuthResult
       {
           [SerializeField] private string code = default;
           public string Code => code;
           [SerializeField] private string scope = default;
           public string Scope => scope;
           [SerializeField] private string state = default;
           public string State => state;
       }

       [Serializable]
       public class TokenResult
       {
           [SerializeField] private string access_token = default;
           public string AccessToken => access_token;
           [SerializeField] private string expires_in = default;
           public string ExpiresIn => expires_in;
           [SerializeField] private string id_token = default;
           public string IdToken => id_token;
           [SerializeField] private string refresh_token = default;
           public string RefreshToken => refresh_token;
           [SerializeField] private string scope = default;
           public string Scope => scope;
           [SerializeField] private string token_type = default;
           public string TokenType => token_type;
       }

       [Serializable]
       public class UserInfoResult
       {
           [SerializeField] private string id = default;
           public string Id => id;
           [SerializeField] private string name = default;
           public string Name => name;
           [SerializeField] private string given_name = default;
           public string GivenName => given_name;
           [SerializeField] private string family_name = default;
           public string FamilyName => family_name;
           [SerializeField] private string picture = default;
           public string Picture => picture;
           [SerializeField] private string locale = default;
           public string Locale => locale;
       }

       // Have UI Text available for the hierarchy.
       [SerializeField]
       private Text DataText;

       [SerializeField]
       private RawImage UserInfoPicture;

       // Client ID in your Google Cloud Platform credentials
       [SerializeField]
       private string clientId;

       // Client Secret in your Google Cloud Platform credentials
       [SerializeField]
       private string clientSecret;

       [SerializeField]
       private string redirectUri = "https://tokufxug.github.io/ML-OAuth-Redirect/mloauth.html";

       private string customURLSchemeRedirectUri = "redirecturi://";
       private string cancelUri = "canceluri://";

       private string oauthURL = "";
       private string state = "";
       private string codeVerifier = "";
       private const string REFRESH_TOKEN_KEY = "refresh_token_key";

       // Start listening for OAuth dispatch events
       public event MLDispatch.OAuthHandler oAuthEvent;

       void Awake()
       {
           state = GetRandomStringForUrl(32);
           oauthURL = string.Format(
               "https://accounts.google.com/o/oauth2/v2/auth?client_id={0}&redirect_uri={1}&scope={2}&code_challenge={3}&state={4}&&code_challenge_method=S256&response_type=code&access_type=offline&prompt=consent&session=false"
               , clientId
               , redirectUri
               , "https://www.googleapis.com/auth/userinfo.profile"
               , GetCodeChallenge()
               , state
               );        
       }

       void Start()
       {
           MLInput.OnControllerButtonUp += OnButtonUp;
           oAuthEvent += OAuthCallback;
       }

       private void OnDestroy()
       {
           MLInput.OnControllerButtonUp -= OnButtonUp;
           oAuthEvent -= OAuthCallback;
       }

       void OnButtonUp(byte controllerID, MLInput.Controller.Button button)
       {
           if (button == MLInput.Controller.Button.Bumper)
           {
               string refreshToken = GetRefreshToken();
               if (String.IsNullOrEmpty(refreshToken))
               {
                   MLResult result = MLDispatch.OAuthRegisterSchema(customURLSchemeRedirectUri, ref oAuthEvent);
                   MLDispatch.OAuthRegisterSchema(cancelUri, ref oAuthEvent);
                   MLDispatch.OAuthOpenWindow(oauthURL, cancelUri);
               }
               else
               {
                   StartCoroutine(RetrieveAccessToken(refreshToken));
               }
           }
           else if (button == MLInput.Controller.Button.HomeTap)
           {
               MLDispatch.OAuthUnRegisterSchema(customURLSchemeRedirectUri);
               MLDispatch.OAuthUnRegisterSchema(cancelUri);
           }
       }

       string GetRefreshToken()
       {
           byte[] data = new byte[0];
           string refreshToken = "";
           try
           {
               MLSecureStorage.GetData(REFRESH_TOKEN_KEY, ref data);
               refreshToken = System.Text.Encoding.UTF8.GetString(data);
           }
           catch (Exception e){}
           return refreshToken;
       }

       // Show callbacks
       void OAuthCallback(string response, string schema)
       {
           if (schema == customURLSchemeRedirectUri)
           {
               var authResponse = response.Replace(customURLSchemeRedirectUri, "");
               var authJson = JsonUtility.FromJson<AuthResult>(authResponse);
               if (authJson.State == state)
               {
                   StartCoroutine(RetrieveAccessTokenAndRefreshToken(authJson.Code));
               }
               else
               {
                   var msg = "Error: Inconsistent State during authentication.";
                   DataText.text = msg;
                   throw new Exception(msg);
               }
           }
           else if (schema == cancelUri)
           {
               DataText.text = "Cancel";
           }
       }

       IEnumerator RetrieveAccessTokenAndRefreshToken(string code)
       {
           var form = new WWWForm();
           form.AddField("client_id", clientId);
           form.AddField("client_secret", clientSecret);
           form.AddField("code", code);
           form.AddField("code_verifier", codeVerifier);
           form.AddField("grant_type", "authorization_code");
           form.AddField("redirect_uri", redirectUri);
           UnityWebRequest request = UnityWebRequest.Post("https://oauth2.googleapis.com/token", form);
           request.SetRequestHeader("Content-Type", "application/x-www-form-urlencoded");

           yield return request.SendWebRequest();

           if (request.result != UnityWebRequest.Result.Success)
           {
               DataText.text = request.error + " " + request.downloadHandler.text;
           }
           else
           {
               var response = JsonUtility.FromJson<TokenResult>(request.downloadHandler.text);
               try
               {
                   MLSecureStorage.StoreData(REFRESH_TOKEN_KEY, Encoding.UTF8.GetBytes(response.RefreshToken));
                   StartCoroutine(GetUserInfo(response.AccessToken));
               }
               catch (Exception e)
               {
                   DataText.text += e.ToString() + e.Message;
               }
           }
       }

       IEnumerator RetrieveAccessToken(string refreshToken)
       {
           var form = new WWWForm();
           form.AddField("client_id", clientId);
           form.AddField("client_secret", clientSecret);
           form.AddField("grant_type", "refresh_token");
           form.AddField("refresh_token", refreshToken);

           UnityWebRequest request = UnityWebRequest.Post("https://oauth2.googleapis.com/token", form);
           request.SetRequestHeader("Content-Type", "application/x-www-form-urlencoded");

           yield return request.SendWebRequest();
           var data = ResultResponseData(request);
           if (data != null)
           {
               var response = JsonUtility.FromJson<TokenResult>(data.text);
               StartCoroutine(GetUserInfo(response.AccessToken));
           }
       }

       IEnumerator GetUserInfo(string accessToken)
       {
           var form = new WWWForm();
           UnityWebRequest request = UnityWebRequest.Get(
                   "https://www.googleapis.com/oauth2/v1/userinfo?access_token=" + accessToken);

           yield return request.SendWebRequest();
           var data = ResultResponseData(request);
           if (data != null)
           {
               var response = JsonUtility.FromJson<UserInfoResult>(data.text);
               DataText.text = string.Format("ID:{0}\nName:{1}\nLocale:{2}", response.Id, response.Name, response.Locale);
               StartCoroutine(GetPicture(response.Picture));
           }
       }

       IEnumerator GetPicture(string picture)
       {
           var form = new WWWForm();
           UnityWebRequest request = UnityWebRequestTexture.GetTexture(picture);

           yield return request.SendWebRequest();

           var data = ResultResponseData(request);
           if (data != null)
           {
               UserInfoPicture.gameObject.SetActive(true);
               UserInfoPicture.texture = ((DownloadHandlerTexture)data).texture;
           }
       }

       private string GetCodeChallenge()
       {
           codeVerifier = GetRandomStringForUrl(32);
           return ConvertToBase64Url(Sha256(codeVerifier));
       }

       private string GetRandomStringForUrl(uint length)
       {
           var cryptoServiceProvider = new RNGCryptoServiceProvider();
           var bytes = new byte[length];
           cryptoServiceProvider.GetBytes(bytes);
           return ConvertToBase64Url(bytes);
       }

       private string ConvertToBase64Url(byte[] bytes)
       {
           return Convert.ToBase64String(bytes)
               .Replace("+", "-")
               .Replace("/", "_")
               .Replace("=", "");
       }

       private static byte[] Sha256(string source)
       {
           var bytes = Encoding.ASCII.GetBytes(source);
           return new SHA256Managed().ComputeHash(bytes);
       }

       private DownloadHandler ResultResponseData(UnityWebRequest request)
       {
           DownloadHandler data = null;
           switch (request.result)
           {
               case UnityWebRequest.Result.Success:
                   data = request.downloadHandler;
                   break;
               case UnityWebRequest.Result.ConnectionError:
               case UnityWebRequest.Result.ProtocolError:
               case UnityWebRequest.Result.DataProcessingError:
                   DataText.text = request.error + " " + request.downloadHandler.text;
                   break;
               case UnityWebRequest.Result.InProgress:
                   break;
               default:
                   DataText.text = "ArgumentOutOfRangeException";
                   throw new ArgumentOutOfRangeException();
           }
           return data;
       }
   }
}

まとめ

OAuth Windowは、OAuth2のログイン画面、認可サーバーから認可コード、scope、stateをアプリケーションに返却する機能を提供している。

認可コード、scope、stateを渡すためのリダイレクトページは事前に用意しておく必要がある。

参考

Magic Leap Developer Portal OAuth Windows - Unity https://developer.magicleap.com/ja-jp/learn/guides/sdk-oauth-windows-for-unity

【Unity】UnityエディタでGoogle APIのOAuth2認証をする(ライブラリ使わない版)https://light11.hatenadiary.com/entry/2020/05/05/183325

最後に

弊社では、Magic Leap 1を活用したアプリケーションをMagic Leap Worldに2つリリースしています!

OnePlanet XR

「OnePlanet XR」はAR/MR技術に専門特化したコンサルティングサービスです。豊富な実績を元に、AR/MR技術を活用した新たな事業の立ち上げ支援や、社内業務のデジタル化/DX推進など、貴社の必要とするイノベーションを実現いたします。

MRグラスを活用した3Dモデル設置シミュレーション

ご相談から受け付けております。ご興味ございましたら弊社までお問い合わせください。

お問い合わせ先: https://1planet.co.jp/xrconsulting.html

OnePlanet Tech Magazine

https://note.com/oneplanetinc/m/m25ceb06130d0