2015年1月20日火曜日

[AzureAD]管理者アカウントの権限の絞り込み

注)現在Public Previewとして提供されている機能について紹介しているため、正式版がリリースされるタイミングで機能が変更になる可能性がありますのでご承知おきください。

ここしばらくAzure Active Directory(AzureAD)の管理アカウントの権限の絞り込みについて試行錯誤をしてきましたが、今回は現在Public Previewとして提供されている「Administrative Units(管理単位)」の機能について紹介します。
今のところ、やりたいことに一番近い機能はこれかも知れません。

まず、おさらいですが、やりたいことは以下の通りでした。

  • シングルテナントのAzureADに複数のドメインを作成する
  • 複数の企業や組織が各ドメインを使用、各組織のオンプレADからユーザを同期する
  • 各組織の管理者は自ドメインのユーザのみを管理したい


これまでのポストでは多要素認証やFederationを使って各ドメインの管理者アカウント(AADSyncに設定するユーザ)の権限を制限しようとしましたが、うまい方法はありませんでした。
 [AADSync/DirSync]同期に使うAzureAD管理者アカウントの管理
 [AADSync/DirSync]同期に使うAzureAD管理者アカウントの管理(続)
 [AzureAD]管理者アカウントでAzureにサインアップする


今回は昨年12月にPublic Preview公開されたAdministrative Units(管理単位)を使ってみます。
この機能はAzureADのディレクトリを論理的な単位に分割し、それぞれの管理権限をユーザへ委譲するための仕組みです。(オンプレActive DirectoryでOU単位で管理権限を委譲するのと同様の考え方です)

想定ケースの例として地域ごとに管理者をアサインするようなケースがあげられています。


Active Directoryチームのblog
 Wrapping up the year with a boat load of Azure AD news!

MSDNドキュメント
 管理単位の管理 - パブリック プレビュー


では、簡単に試してみます。

現状はPowerShellコマンドレットでしか操作が出来ませんので、PowerShellを使います。
必要なWindows Azure Active Directory管理PowerShellモジュールのバージョンは1.0.8070.2以上です。

以下のコマンドで使っているバージョンがわかります。
> (get-item C:\Windows\System32\WindowsPowerShell\v1.0\Modules\MSOnline\Microsoft.Online.Administration.Automation.PSModule.dll).VersionInfo.FileVersion

⇒現状の最新版だと「1.0.8262.2」が出てきます。


以下の順に操作をします。(特定のAU以下のユーザしか管理出来ないように管理者の権限を制限します)
①グローバル管理者でAzureADに接続する
②管理単位(AU)を作成する
③管理単位(AU)に管理対象ユーザを追加する
④管理者ユーザにスコープ付管理ロールを付与する

結果的に言いますと、管理者は③で管理対象ユーザについてだけ「更新」が出来るようになります。
つまり、
・管理対象外ユーザの参照は出来てしまう
・新規にユーザを作成したり、削除することは出来ない
ということなので、やはり元々やりたかったことは実現出来ません。(当然AADSyncに管理者アカウントとして設定してもユーザの作成などが出来ないので実質使えません)


とりあえず現状出来ることを見てみます。

AU名TestAU
AUメンバkenshinu@example.com
AU管理者auadmin@example.com


◆設定
①グローバル管理者でAzureADに接続する
> Connect-MsolService

②管理単位(AU)を作成する
> New-MsolAdministrativeUnit -DisplayName "TestAU" -Description "Test AU"

ExtensionData       Description         DisplayName         ObjectId
-------------       -----------         -----------         --------
System.Runtime.S... Test AU             Test AU             edfd9c4a-e529-46...

③管理単位(AU)に管理対象ユーザを追加する
> $au = Get-MsolAdministrativeUnit -SearchString "TestAU"
> $user = Get-MsolUser -UserPrincipalName "kenshinu@example.com"
> Add-MsolAdministrativeUnitMember -AdministrativeUnitObjectId $au.ObjectId -AdministrativeUnitMemberObjectId $user.ObjectId

④管理者ユーザにスコープ付管理ロールを付与する
> $role = Get-MsolRole -RoleName "User Account Administrator"
> $admin = Get-MsolUser -UserPrincipalName "auadmin@example.com"
> Add-MsolScopedRoleMember -RoleObjectId $role.ObjectId -AdministrativeUnitObjectId $au.ObjectId -RoleMemberObjectId $admin.ObjectId

◆確認
①管理者ユーザでAzureADに接続する
> Connect-MsolService

②ユーザ一覧を参照する
> Get-MsolUser

UserPrincipalName          DisplayName                isLicensed
-----------------          -----------                ----------
mitsuhidea@example.net     Mitsuhide Akechi           True
nfujie@hoge.onmicrosof...  富士榮尚寛                 True
auadmin@example.com        AU Admin                   False
kenshinu@example.com       Kenshin Uesugi             True
⇒残念ながら全員見えてしまいます。

②管理対象ユーザを更新する
> Set-MsolUser -UserPrincipalName kenshinu@example.com -UsageLocation CA
⇒問題なく更新できます

③管理対象外ユーザを更新する
> Set-MsolUser -UserPrincipalName mitsuhidea@example.net -UsageLocation CA
Set-MsolUser : Access Denied. You do not have permissions to call this cmdlet.
発生場所 行:1 文字:1
+ Set-MsolUser -UserPrincipalName mitsuhidea@example.net -UsageLocatio ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~
    + CategoryInfo          : OperationStopped: (:) [Set-MsolUser], MicrosoftO
   nlineException
    + FullyQualifiedErrorId : Microsoft.Online.Administration.Automation.Acces
   sDeniedException,Microsoft.Online.Administration.Automation.SetUser
⇒権限がないって言われます

④ユーザを作成する
> New-MsolUser -UserPrincipalName hoge@example.com
New-MsolUser : Access Denied. You do not have permissions to call this cmdlet.
発生場所 行:1 文字:1
+ New-MsolUser -UserPrincipalName hoge@example.com
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (:) [New-MsolUser], MicrosoftO
   nlineException
    + FullyQualifiedErrorId : Microsoft.Online.Administration.Automation.Acces
   sDeniedException,Microsoft.Online.Administration.Automation.NewUser
⇒権限がないって言われます

⑤ユーザを削除する
> Remove-MsolUser -UserPrincipalName kenshinu@example.com

確認
この操作を続行しますか?
[Y] はい(Y)  [N] いいえ(N)  [S] 中断(S)  [?] ヘルプ (既定値は "Y"):
Remove-MsolUser : Access Denied. You do not have permissions to call this cmdle
t.
発生場所 行:1 文字:1
+ Remove-MsolUser -UserPrincipalName kenshinu@example.com
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (:) [Remove-MsolUser], Microso
   ftOnlineException
    + FullyQualifiedErrorId : Microsoft.Online.Administration.Automation.Acces
   sDeniedException,Microsoft.Online.Administration.Automation.RemoveUser
⇒権限がないって言われます

⑥管理者ユーザでAzureサブスクリプションにサインアップし管理ポータルからディレクトリを操作する
 ⇒ユーザ一覧が見えません。PowerShellだと見えるんですが、管理ポータルからだと管理対象AUのメンバユーザすら見れません。


現状はこんな感じです。
今後はグループオブジェクトなんかもAUメンバに追加できるようになったりするようなので、今後の進化に期待です。

2015年1月17日土曜日

[AzureAD]管理者アカウントでAzureにサインアップする

AzureAD上に作成(もしくはオンプレミスADから同期)したユーザに全体管理者ロールを与えておき、そのユーザで別のAzureサブスクリプションへサインアップするとどうなるのか?を試してみました。

前々回のポストで全体管理者としてアサインしたユーザが他のユーザ情報を参照・編集してしまうのを防ぐには、という話題で初期状態で管理ポータルへアクセス出来ないので、、ということを紹介しました。
ただし、その管理者アカウントでAzureサブスクリプションを新規にサインアップしてしまえば管理ポータルにアクセスできてしまうので、やはり注意が必要です。
これを防ぐには前回のポストで紹介したようにWebアプリケーションの認証を行う際にIdPへネットワーク的にアクセスできないように構成するなど別の対策が必要になります。

実際にどうなるのかを試してみます。(かなり当たり前な結果ばかりですが)
全体管理者アカウントでサインアップして管理ポータルにアクセスすると以下のような状態になります。


最初から自分が管理者となっているAzure Active Directory(AzureAD)のディレクトリが見えています。

開いてみると、当然全ての操作が可能です。


柔軟に色々なことができるAzureADのアイデンティティ管理ですが、何をしたら何が起きるかをしっかり把握しておかないと危ないので、しっかり把握しておくことが大切です。

尚、当該のアカウントに多要素認証設定をしておくと最初からアカウントの管理メニューに追加の認証に関する項目が出てきます。(ちなみにこのユーザ、OpenAMで認証するように構成してあるユーザなので、ポータルにアクセスすると、OpenAMにリダイレクトされ認証された後、AzureADの多要素認証が実行されます)


2015年1月16日金曜日

[AADSync/DirSync]同期に使うAzureAD管理者アカウントの管理(続)

昨日のポストに引き続きAzureADの管理者アカウントの話です。

タケさんより、AADSyncに仕込む管理者アカウントをFederationユーザにしたらどう?というコメントを頂きましたので、どうなるのかちょっと試してみました。
※昨日のコメント欄を見ていただければわかると思いますが、ws-trust/saml-ecpなどのActiveEndpointのことをすっかり忘れていましたので、当然FederationユーザはAADSyncやPowerShellでは使えないと思い込んでいました。タケさんありがとうございました。


結果、もともとの課題(シングルテナントに複数のドメインを定義し、各ドメイン毎にAADSyncを入れてオンプレADと同期、ただしドメイン間でユーザ情報の閲覧などはさせたくない)については解決は出来なかったのですが、管理者アカウントの制御を含め別の使い方も出来そうなので、簡単にまとめておきます。

構築したのは下図のような環境です。


AADSyncやPowerShellなどMicrosoft Online Serviceサインインアシスタント経由の通信はAzure Active Directory(AzureAD)上に登録したドメインに設定されたActiveUriへws-trustやSAML(ECP)で通信して認証されるので、ActiveUrlだけをインターネットに公開し、Webブラウザ経由でアクセスされるPassiveUriは.localドメインなど社内ネットワークからのみアクセスできるアドレスを設定します。
この設定により、AADSyncなどに設定するAzureAD管理者アカウントとパスワードを知っていてもW社外ユーザは、社内にあるIdPに到達できないため、Webアプリケーションは使えない、という環境が作れます。

具体的な設定ですが、一旦は.localドメインでAD FSを構築し、AzureADとSSO設定を行った後、「Set-MsolDomainFederationSettings」コマンドレットでActiveLogOnUriプロパティのURIを外部公開しているURIに変更する、という方法をとりました。

設定した結果はこんな感じです。
> Get-MsolDomainFederationSettings -DomainName example.com

ActiveLogOnUri         : https://adfs.example.com/adfs/services/trust/
                         2005/usernamemixed
FederationBrandName    : eIdentity
IssuerUri              : http://adfs.eidentity.local/adfs/services/trus
                         t
LogOffUri              : https://adfs.eidentity.local/adfs/ls/
MetadataExchangeUri    : https://adfs.eidentity.local/adfs/services/trust/mex
NextSigningCertificate :
PassiveLogOnUri        : https://adfs.eidentity.local/adfs/ls/
SigningCertificate     : MIIC9DCCAdygAwIBAgIQSjWgIWhJdKVN1NL85LCH3zANBgkqhkiG9w
                         0BAQsFADA2MTQwMgYDVQQDEytBREZTIFNpZ25pbmcgLSBhZGZzLWFh




ActiveLogOnUriのみ外部公開ドメインになっていて、後は.localになっているのがわかります。
これで、AADSyncに設定する管理アカウントにFederationユーザを設定しても問題なく認証が通るようになります。

ただ、昨日のポストでも書いたように、
・PowerShellが使えるのでGet/Set-MsolUserコマンドレットなどでユーザ一覧は参照・更新可能
・Synchronization Service ManagerのConnector SpaceやMetaverseでユーザ一覧を参照可能
・当然カスタムルールを書けば他ドメインのユーザを更新や削除も可能
なので、シングルテナント・マルチドメインの環境でドメイン単位でセキュリティ境界を作るのは困難なのは変わりません。

[AADSync/DirSync]同期に使うAzureAD管理者アカウントの管理

twitterで少し話題に上ったので、色々と調べ&試してみました。

きっかけは、複数企業(マルチドメイン)で一つのAzure Active Directory(AzureAD)テナントを共同利用するようなケースにおいて、各企業のオンプレミスActive Directoryから各社のユーザをAADSyncやDirSyncを使って同期する場合にAADSyncやDirSyncに設定するAzureADの管理者アカウントをどのように管理するべきなのか?という議論でした。

同期に利用するAzureAD上のユーザアカウントはテナントの全体管理者の権限が必要なので、同じAzureADテナント上のユーザやグループであれば他企業のものであっても管理することが権限上は可能となってしまいます。

参考)AADSyncに必要なAzureADアカウントの権限等
 http://msdn.microsoft.com/en-us/library/azure/dn757602.aspx
 ・必要な要件
  全体管理者ロールを持ったアカウントを利用すること
 ・推奨事項
  ①パスワード期限を無期限に設定すること(パスワードを16文字以上の強固なものにすること)
  ②AADSync専用のアカウントを作成すること


そこで、各AADSyncに設定する管理アカウントの権限をうまく制限出来ないか?という話になります。

まず、テナント内のユーザ情報の閲覧・更新を行うには、以下の4つの方法が考えられます。
①管理ポータルへアクセスする
②PowerShellのコマンドレット(Get-MsolUser等)を使う
③AADSyncの内部(Connector Space)を参照する
④GraphAPIを使う

AADSyncに設定するユーザで上記①~④を実行できなければよいのですが、結論としてはすべてを満たすのは無理なのですが、それぞれどのような状態になるのかを紹介してみます。

①管理ポータルへのアクセスを制限する
 これは比較的簡単です。
 単純にAzureのサブスクリプションを対象のアカウントに紐づけなければ済む話です。
 管理ポータルにアクセスしても以下の画面が表示され、ディレクトリの管理までたどりつけません。



②PowerShellのコマンドレットの実行を制限する
 まず、テナント中の特定ドメインのユーザのみを管理する、という思想がないのでコマンドレットの内部での権限分離は絶望的です。
 となると、コマンドレット自体を実行できないようにするには?という話になってきます。

 対策案としては、各企業に配る同期用ユーザのMFA(多要素認証)を有効にしてしまう、ということが考えられます。こうすれば勝手に各企業管理者が同期用ユーザを使って何かをしようとしても追加認証(例えばSMS通知)を抑えておけば勝手にログインすることはできなくなります。(当然管理ポータルへもログインできなくなります)

 これで問題ないのではないか?と思い、いざAADSyncの設定を行おうとすると認証エラーが出てしまいます。


 ちなみに設定時はMFAを設定せずに後からMFAを設定すると同期エラー(stopped-extension-dll-exception)が出ます。


 イベントログを見るとやはり認証エラーが出ています。



 次に試すのは多要素認証をONにして、アプリケーションパスワードを使ってAADSyncの設定をしてみます。
 結果、エラー内容は変わりますが、やはりだめです。



Technetのアプリケーションパスワードの説明を見ると、以下の記述があります。(強調は筆者追加)
 http://technet.microsoft.com/ja-jp/library/dn270518.aspx

[多要素認証をサポートしないブラウザー以外のクライアントに必要なアプリ パスワード] 多要素認証がユーザーのアカウントで有効になると、Outlook や Lync などの大半のブラウザー以外のクライアントでアプリ パスワードを使用できますが、そのユーザーが管理アカウントを保持している場合でも、Windows PowerShell などのブラウザー以外のアプリケーションではアプリ パスワードを使用した管理操作は実行できない点に注意してください。Powershell スクリプトを実行するための強力なパスワードでサービス アカウントを作成し、そのアカウントで多要素認証を有効にしないでください

クライアントがオンプレミスとクラウドの両方の自動検出エンドポイントと通信するハイブリッド環境ではアプリ パスワードは機能しない - オンプレミスでの認証にはドメイン パスワードが必要で、クラウドでの認証にはアプリ パスワードが必要なので、クライアントがオンプレミスとクラウドの両方の自動検出エンドポイントと通信するハイブリッド環境では、アプリ パスワードが機能しないことに注意してください。


 中々対応は難しそうです。
 ただ、このあたりを見ると
 「現在、Windows PowerShell 用 Azure Active Directory はアプリ パスワードに対応していません。」
 とあるので、将来的にはサポートするのかも知れません。


③AADSyncの内部(Connector Space)を参照を制限する
 Synchronization Service Manager(miisclient.exe)の利用を制限することは無理ですので、Connector SpaceやMetaverseの中身を見ればAzureADからのImportやSynchronizationジョブで同期されたユーザ一覧が参照できてしまいます。
 ここで自社のユーザ以外を見れなくしようとするとあらかじめルールエディタでフィルタを設定する必要がありますが、当然あとから設定を変えることも可能なので、完全に制限をかけるのは無理です。



④GraphAPIを使わせない
 これは割と簡単です。
 GraphAPIを使うにはAzureAD上にクライアントアプリケーションを登録する必要がありますが、登録作業は管理ポータルから実施するため、①の方法で管理ポータルへアクセスできないようにしておけばOKです。



◆まとめ
現状、同一AzureADテナント内に複数ドメインをホストし、ドメイン毎に管理アカウントの権限を完全に分離するのは不可能
⇒MFAを有効にしたり、アプリケーションパスワードを使うとAADSyncでの同期に支障が出るので対策とはならない。

とりあえず、セキュリティの境界を分けたければテナントを分けましょう、ということですね。


2015年1月9日金曜日

[AAD]Graph API 1.5のGAとスキーマ拡張+GUIツール公開

12月末にリリースされたGraph API ver. 1.5を使うことによりAzure Active Directory上のオブジェクトのスキーマの拡張がGraph API経由で出来るようになりました。
合わせて、Graph APIライブラリのver. 2.xがリリースされ、自分でREST APIを実行するクライアントを実装しなくても簡単にGraph APIが実行できるようになっています。

関連ポスト
- Active Directory Team のblog
  Improved Azure AD Graph API and Client libraries for .Net, Android, iOS and much more!

- Windows Azure Active Directory Graph Team のblog
  Announcing Azure AD Graph API Client Library 2.0

  Announcing the new version of Graph API: api-version=1.5


と言っても、結局は自分でライブラリを使ったアプリケーションを作って、、となるとハードルも高いので、新しいAPIを試しがてらGUIツールを作ってみました。
Baby Metal武道館ライブのBluRayを見ながら2時間くらいで実装したのでかなり適当ですのであしからず。試す時は試験用のディレクトリを使ってください。何が起きても責任は負えません)


こんな感じでGUIで拡張プロパティが登録できます。
(ちなみにRemoveも実装しているのですが、現状うまく動きません。消したいときはREST Clientなどで直接消してください)

ダウンロード、詳しい説明はこちらよりどうぞ(英語ですみません)
 - Codeplex : Azure Active Directory Extended Schema Manager
     https://aadschema.codeplex.com/


詳細手順はCodeplexにも書きましたが、Azure Active Directory上にアプリケーションを一つ定義してあげる必要があります。そのアプリケーションにディレクトリの読み書きの権限を与えて、アプリケーションを通してスキーマを触っています。

必要なのは、以下です。
・アプリケーションの登録(Webアプリケーション/WebAPIとして登録)

・ClientId/Keyの取得

・Azure Active DirectoryへのRead/Write権限の付与


そのうち、Azureの管理ポータルからも触れるようになる気もしていますが、それまでに手軽に試してみたい方はどうぞ。

2015年1月5日月曜日

[JWT/OAuth]Service Accountを使ってGoogle APIを利用する②

前回の続きです。

前回はGoogle Developer ConsoleおよびGoogleAppsの管理ダッシュボードでGoogle側の設定をするところまでを紹介しましたので、今回は実際にサービスアカウントを使ってAPIを利用するためのアクセストークンを取得してみます。

◆リクエストの仕様
Googleの公式ドキュメントを見るとアクセストークンを取得するためには、JWTを生成してトークンエンドポイントへPOSTする必要があるようです。

リクエストの仕様
エンドポイントhttps://www.googleapis.com/oauth2/v3/token
メソッドPOST
パラメータgrant_typeurn:ietf:params:oauth:grant-type:jwt-bearer
assertion作成したJWT


※ちなみにGoogleが提供している.Net用のクライアントライブラリの通信をキャプチャしてみるとエンドポイントアドレスがドキュメントに記載されているものとは異なり、https://accounts.google.com/o/oauth2/tokenとなっています。(ライブラリ側が古いまま?)

また、POSTするJWTは以下の通りの形である必要があるようです。

JWTの仕様
署名アルゴリズムRSA-SHA256
必要なクレームissサービスアカウントのメールアドレス
scope利用するAPIのスコープ
audhttps://www.googleapis.com/oauth2/v3/token
expトークンの有効期限To
iatトークンの有効期限From


尚、エンタープライズシナリオにおいて特定の管理者がAPIを実行したことを記録していくような場合においては、上記のクレームにsubを追加して管理者メールアドレスを設定することでロギングが出来るようになります。

◆JWTを生成する
・.Net標準のJwtSecurityTokenHandlerを利用する
せっかくマイクロソフト謹製のJWTを扱うライブラリがあり、Azure Active Directory(AzureAD)へのアクセス用のサンプルではほぼ100%このライブラリを使っているので、まずはこちらを使ってJWTを生成してみます。

このライブラリを使うことでコーディング量をかなり削減できるので、うまく使えればかなり楽に開発が出来るはずです。

使うのは、NuGetで提供されている以下のパッケージです。
 名称:JSON Web Token Handler For the Microsoft .Net Framework 4.5
 作成者:Microsoft Open Technologies.
 識別:System.IdentityModel.Tokens.Jwt
 バージョン:4.0.1

JWTを生成するときはシンプルにJwtSecurityTokenに必要なクレーム情報を指定してインスタンスを生成するだけです。
var token = new JwtSecurityToken(
    SERVICE_ACCOUNT, // iss
    TOKEN_ENDPOINT, // aud
    claims, // additional claims
    issuedTime, // iat
    expiresTime, // exp
    credentials); // 署名用のクレデンシャル

固定でiss/aud/iat/exp/credentialを引数で指定するのに加えて、追加のクレームについてはIEmurerable claimsで列挙型の引数を渡す形になるので、必要なクレームは事前にClaim型で定義しておきます。
var claims = new[]
{
    // 「クレーム名,値」のセット
    new Claim("scope", SCOPE)
};

また、署名についてはGoogleからダウンロードした鍵(.p12ファイル)から作成します。
var certificate = new X509Certificate2(
    CERTIFICATE_FILE, // .p12ファイル
    CERTIFICATE_PWD, // 秘密鍵のパスワード(notasecret)
    X509KeyStorageFlags.Exportable);
var credentials = new X509SigningCredentials(certificate);


JWT自体はこれで簡単に生成できるので、ハンドラのインスタンスを生成し、JWTを渡します。
こちらも非常に簡単で、
var handler = new JwtSecurityTokenHandler();
でハンドラは生成でき、
var strJWT = handler.WriteToken(token);
とすればBase64Urlエンコードされた、[ヘッダ].[ペイロード].[署名]が文字列として返ってきます。
この文字列をGoogleのトークンエンドポイントへassertionとしてPOSTしてやれば良いはずです。


出来上がったコードはこんな感じになります。
とてもシンプルです。
        static string CreateJWT()
        {
            // signing certificate
            // pfx(.p12) file which is downloaded from Google Developer Console
            var certificate = new X509Certificate2(
                CERTIFICATE_FILE,
                CERTIFICATE_PWD,
                X509KeyStorageFlags.Exportable);
            var credentials = new X509SigningCredentials(certificate);

            // token lifetime
            var issuedTime = DateTime.UtcNow;
            var expiresTime = issuedTime.AddMinutes(55);

            // additional claims
            var claims = new[]
            {
                // add scope claim
                new Claim("scope", SCOPE)
            };

            // create jwt token
            var token = new JwtSecurityToken(
                SERVICE_ACCOUNT, // iss
                TOKEN_ENDPOINT, // aud
                claims, // additional claims
                issuedTime,
                expiresTime,
                credentials);

            var handler = new JwtSecurityTokenHandler();

            // exception in WriteToken method
            return handler.WriteToken(token);

        }



では、早速実行してみましょう。


はい、怒られました。
Googleの秘密鍵のキー長は1024bitなんですが、このライブラリは最低でも2048bitを要求しているようです。
残念です。
今後のGoogleもしくはMicrosoftに期待しましょう。


・スクラッチでJWTを生成するコードを書く
仕方がないのでスクラッチでコードを書きます。と言っても基本はJSONの取り扱いと署名だけなので、それほど複雑にはなりません。
JSONの扱いは、マイクロソフトのサンプルコードでもよく使われているNewtonsoftのJson.NETパッケージを使います。こちらもNuGetで提供されています。
 名称:Json.NET
 作成者:James Newton-King
 識別:Newtonsoft.Json
 バージョン:6.0.7

◇ヘッダ部
求めるのは、{ alg = "RS256", typ = "JWT" }というJSONなのでまずはJson.NETを使ってバイト配列を作成します。
var header = new { alg = "RS256", typ = "JWT" };
var headerSerialized = JsonConvert.SerializeObject(header,Formatting.None);
var headerBytes = Encoding.UTF8.GetBytes(headerSerialized);

その後、作成したバイト配列をBase64Urlエンコードするのですが、JWTの作者でもあるMicrosoft ResearchのMike Jonesがスペック(ドラフト)のAppendixにC#のサンプルコードを載せてくれているので、そちらを使います。
 参考URL)
 http://self-issued.info/docs/draft-goland-json-web-token-00.html

private static string Base64UrlEncode(byte[] input)
{
    var output = Convert.ToBase64String(input);
    output = output.Split('=')[0]; // Remove any trailing '='s
    output = output.Replace('+', '-'); // 62nd char of encoding
    output = output.Replace('/', '_'); // 63rd char of encoding
    return output;
}

こちらを使って先ほどのバイト配列をエンコードします。
var headerEncoded = Base64UrlEncode(headerBytes);


◇ペイロード部
基本的な考え方はヘッダ部と同じです。必要なJSONは先に挙げた仕様通りなので、以下のようなコードになります。
var payload = new
{
    iss = SERVICE_ACCOUNT, // iss
    scope = SCOPE, // scope
    aud = TOKEN_ENDPOINT, // aud
    exp = exp, // exp
    iat = iat // iat
};
var payloadSerialized = JsonConvert.SerializeObject(payload,Formatting.None);
var payloadBytes = Encoding.UTF8.GetBytes(payloadSerialized);
var payloadEncoded = Base64UrlEncode(payloadBytes);


◇署名部
ここまで作成したヘッダとペイロードを"."(ピリオド)でつないだ文字列を先の秘密鍵を使って署名します。
ポイントはRSACryptoServiceProviderを生成する際のパラメータにキー長をちゃんと設定してあげることです。(certificate.PrivateKey.KeySizeで取得できます。先に説明した通りGoogleの場合、1024bitです)

こちらも同じく作成した署名部分のバイト配列をBase64Urlエンコードして文字列を生成します。
こんなコードになります。

RSAParameters rsaPara = ((RSACryptoServiceProvider)(certificate.PrivateKey)).ExportParameters(true);
RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(certificate.PrivateKey.KeySize);
rsa.ImportParameters(rsaPara);
Byte[] target = Encoding.UTF8.GetBytes(headerEncoded + "." + payloadEncoded);
byte[] signBytes = rsa.SignData(target, new SHA256Managed());
var signEncoded = Base64UrlEncode(signBytes);


◇JWTの生成
Googleに渡すJWTはヘッダ、ペイロード、署名を"."(ピリオド)で連携した文字列である必要があるので、単純に文字列を連結します。
var strJWT = headerEncoded + "." + payloadEncoded + "." + signEncoded;


出来上がったコードはこんな感じになります。
先ほどよりはコード量は多いですが、それでもシンプルです。
        static string CreateJWT()
        {
            // signing certificate
            // pfx(.p12) file which is downloaded from Google Developer Console
            var certificate = new X509Certificate2(
                CERTIFICATE_FILE,
                CERTIFICATE_PWD,
                X509KeyStorageFlags.Exportable);

            // token lifetime
            var utc0 = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
            var issueTime = DateTime.Now;
            var iat = (long)issueTime.ToUniversalTime().Subtract(utc0).TotalSeconds;
            var exp = (long)issueTime.ToUniversalTime().AddMinutes(55).Subtract(utc0).TotalSeconds;


            // header
            var header = new { alg = "RS256", typ = "JWT" };
            var headerSerialized = JsonConvert.SerializeObject(header,Formatting.None);
            var headerBytes = Encoding.UTF8.GetBytes(headerSerialized);
            var headerEncoded = Base64UrlEncode(headerBytes);
            
            // payload
            var payload = new
            {
                iss = SERVICE_ACCOUNT,
                scope = SCOPE,
                aud = TOKEN_ENDPOINT, 
                exp = exp,
                iat = iat
            };
            var payloadSerialized = JsonConvert.SerializeObject(payload,Formatting.None);
            var payloadBytes = Encoding.UTF8.GetBytes(payloadSerialized);
            var payloadEncoded = Base64UrlEncode(payloadBytes);

            // signature
            RSAParameters rsaPara = ((RSACryptoServiceProvider)(certificate.PrivateKey)).ExportParameters(true);
            RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(certificate.PrivateKey.KeySize);
            rsa.ImportParameters(rsaPara);
            Byte[] target = Encoding.UTF8.GetBytes(headerEncoded + "." + payloadEncoded);
            byte[] signBytes = rsa.SignData(target, new SHA256Managed());
            var signEncoded = Base64UrlEncode(signBytes);

            // concatinate header + payload + signature
            return headerEncoded + "." + payloadEncoded + "." + signEncoded;

        }


実行すると単純に文字列を生成しているだけなので問題なく
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3Mi..
という文字列が取得できます。

後はGoogleにこれをPOSTするだけです。

◆アクセストークンのリクエストを投げる
こちらは単純にHTTP POSTするだけなのでhttpClientを使います。

POSTするためのメソッドを用意しておき、パラメータをセットして実行するだけです。

こんな非同期メソッドを用意しておいて、、、
private static async Task<string> Post(string url, Dictionary<string, string> param)
{
    string result = "";
    try
    {
        HttpClient httpClient = new HttpClient();
        httpClient.MaxResponseContentBufferSize = int.MaxValue;
        HttpContent content = new FormUrlEncodedContent(param);
        var response = await httpClient.PostAsync(url, content);
        String text = await response.Content.ReadAsStringAsync();
        result = text;
    }
    catch (Exception Err)
    {
        result = "ERROR: " + Err.Message;
    }
    return result;
}

こんな感じでリクエストを投げ込みます。
Task task = Task.Factory.StartNew(async () =>
{
    string result = await Post(
        TOKEN_ENDPOINT,
        new Dictionary<string, string>() { 
            { "assertion" , request_jwt }, // 先ほど生成したJWT
            { "grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer" }});
                Console.WriteLine("Access Token request result :\n{0}", result);
}).Unwrap();
task.Wait();

結果、こんな感じでアクセストークンが取得できます。


後は各APIを利用する際にアクセストークンを提示してあげればOK、という話です。


◆まとめ
サービスアカウントを使ってアクセストークンを取得するところまで解説しました。
どこにもユーザが介在せずにAPIを実行するために必要なトークンが取得できることがわかりましたので、サーバアプリケーションを使う場合は、この方法を使うことになります。
尚、本当は折角なのでマイクロソフト標準のJwtSecurityTokenHandlerを使えればAzureAD用とGoogleApps用でモジュールが簡単に共通化出来るかと思ったのですが、まだまだ発展途上?ということがわかりましたので、今後の対応に期待です。


最後にNGパターンを含めコードを載せておきます。

using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;

namespace GetAccessToken
{
    class Program
    {

        //
        // Constants
        //
        static string TOKEN_ENDPOINT = "https://www.googleapis.com/oauth2/v3/token";
        static string SERVICE_ACCOUNT = "10.......kvghppo@developer.gserviceaccount.com";
        static string SCOPE = "https://www.googleapis.com/auth/admin.directory.user";
        static string CERTIFICATE_FILE = @"C:\Users\testuser\Downloads\FIMMA-8ce3eefa8ca2.p12";
        static string CERTIFICATE_PWD = "notasecret";

        //
        // Common Library
        //
        private static string Base64UrlEncode(byte[] input)
        {
            var output = Convert.ToBase64String(input);
            output = output.Split('=')[0]; // Remove any trailing '='s
            output = output.Replace('+', '-'); // 62nd char of encoding
            output = output.Replace('/', '_'); // 63rd char of encoding
            return output;
        }

        //
        static string OK_RSA256()
        {
            // signing certificate
            // pfx(.p12) file which is downloaded from Google Developer Console
            var certificate = new X509Certificate2(
                CERTIFICATE_FILE,
                CERTIFICATE_PWD,
                X509KeyStorageFlags.Exportable);

            // token lifetime
            var utc0 = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
            var issueTime = DateTime.Now;
            var iat = (long)issueTime.ToUniversalTime().Subtract(utc0).TotalSeconds;
            var exp = (long)issueTime.ToUniversalTime().AddMinutes(55).Subtract(utc0).TotalSeconds;


            // header
            var header = new { alg = "RS256", typ = "JWT" };
            var headerSerialized = JsonConvert.SerializeObject(header,Formatting.None);
            var headerBytes = Encoding.UTF8.GetBytes(headerSerialized);
            var headerEncoded = Base64UrlEncode(headerBytes);
            
            // payload
            var payload = new
            {
                iss = SERVICE_ACCOUNT,
                scope = SCOPE,
                aud = TOKEN_ENDPOINT, 
                exp = exp,
                iat = iat
            };
            var payloadSerialized = JsonConvert.SerializeObject(payload,Formatting.None);
            var payloadBytes = Encoding.UTF8.GetBytes(payloadSerialized);
            var payloadEncoded = Base64UrlEncode(payloadBytes);

            // signature
            RSAParameters rsaPara = ((RSACryptoServiceProvider)(certificate.PrivateKey)).ExportParameters(true);
            RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(certificate.PrivateKey.KeySize);
            rsa.ImportParameters(rsaPara);
            Byte[] target = Encoding.UTF8.GetBytes(headerEncoded + "." + payloadEncoded);
            byte[] signBytes = rsa.SignData(target, new SHA256Managed());
            var signEncoded = Base64UrlEncode(signBytes);

            // concatinate header + payload + signature
            return headerEncoded + "." + payloadEncoded + "." + signEncoded;

        }

        // occur following error.
        //    IDX10630: The 'System.IdentityModel.Tokens.X509AsymmetricSecurityKey' for signing cannot be smaller than '2048' bits.
        // cause: Google's certificate key length is 1024bits, but JwtSecurityTokenHandler/WriteToken requires 2048bits key.
        static string NG_RS256()
        {
            // signing certificate
            // pfx(.p12) file which is downloaded from Google Developer Console
            var certificate = new X509Certificate2(
                CERTIFICATE_FILE,
                CERTIFICATE_PWD,
                X509KeyStorageFlags.Exportable);
            var credentials = new X509SigningCredentials(certificate);

            // token lifetime
            var issuedTime = DateTime.UtcNow;
            var expiresTime = issuedTime.AddMinutes(55);

            // additional claims
            var claims = new[]
            {
                // add scope claim
                new Claim("scope", SCOPE)
            };

            // create jwt token
            var token = new JwtSecurityToken(
                SERVICE_ACCOUNT, // iss
                TOKEN_ENDPOINT, // aud
                claims, // additional claims
                issuedTime,
                expiresTime,
                credentials);

            var handler = new JwtSecurityTokenHandler();

            // exception in WriteToken method
            return handler.WriteToken(token);

        }

        static void Main(string[] args)
        {
            string request_jwt = null;

            // NG : use System.IdentityModel.Tokens.jwt
            // Cause : JwtSecurityTokenHandler only supports 2048bit certificate for signature.
            //request_jwt = NG_RS256();

            // OK : use custom logic
            request_jwt = OK_RSA256();

            Console.WriteLine("Access token request jwt :\n{0}", request_jwt);

            Task task = Task.Factory.StartNew(async () =>
            {
                string result = await Post(
                    TOKEN_ENDPOINT,
                    new Dictionary<string, string>() { 
                        { "assertion" , request_jwt },
                        { "grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer" }});

                Console.WriteLine("Access Token request result :\n{0}", result);
            }).Unwrap();
            task.Wait();
            Console.WriteLine("End");
            Console.ReadKey();

        }

        private static async Task<string> Post(string url, Dictionary<string, string> param)
        {
            string result = "";
            try
            {
                HttpClient httpClient = new HttpClient();
                httpClient.MaxResponseContentBufferSize = int.MaxValue;
                HttpContent content = new FormUrlEncodedContent(param);
                var response = await httpClient.PostAsync(url, content);
                String text = await response.Content.ReadAsStringAsync();
                result = text;
            }
            catch (Exception Err)
            {
                result = "ERROR: " + Err.Message;
            }
            return result;
        }
    }

}

[JWT/OAuth]Service Accountを使ってGoogle APIを利用する①

今回はサーバアプリケーションからAPIを利用する際の認可の話をGoogleAppsでのOAuth(JWT)の使い方の例をベースに紹介したいと思います。

◆アプリケーションがAPIを利用する際の課題とOAuth2.0
Forefront Identity Manager(FIM)などのサーバアプリケーションからGoogle Admin Directory APIなどを利用する際、サーバアプリケーションに管理者ユーザのIDやパスワードを保持すると様々なデメリットがあります。
例)

  • 管理者のパスワードを変更する際、サーバアプリケーションの設定を変える必要が出てくる
  • サーバアプリケーションに管理者パスワードを保存しておく必要がありセキュリティ上の問題となる可能性がある

※実際、現在codeplex上に公開しているFIM用GoogleApps管理エージェント(Management Agent/MA)ではMA設定にGoogleAppsの管理者ユーザのIDとパスワードを保持しています。

 FIM 2010 GoogleApps MA
 https://fim2010gapps.codeplex.com/


また、サーバアプリケーションに限らずアプリケーションにIDやパスワードを持たせてユーザの代わりにAPIへアクセスするのはよろしくない、ということでOAuth2.0を使った認可(アプリケーションへの権限委譲)が主流になってきています。

(フローの例)
①認可サーバでユーザがAPI(保護対象リソース)へWebアプリケーションがアクセスすることを許可
②認可サーバより発行される認可コードをWebアプリケーションへ渡す
③Webアプリケーションが認可サーバで認可コードとアクセストークンを交換する
④発行されたアクセストークンを使ってAPIを利用


◆サーバアプリケーションがOAuthを使う際の課題と対応
しかし、一般的なWebアプリケーションとは異なりサーバアプリケーションではリソースオーナーであるユーザが一切介在することなくAPIを利用することが求められます。

このようなケースに対応し、Azure Active DirectoryやGoogleApps、Salesforce.comなどの主要クラウドベンダはサーバアプリケーション向けにJWT(JSON Web Token)やJWS(JSON Web Signature)を使ったAPI利用フローを用意しています。

 Azure ADの例(少し古いです)
  http://idmlab.eidentity.jp/2012/09/waad-rest-client-graph-api.html

 GoogleAppsの例(公式ドキュメント)
  https://developers.google.com/accounts/docs/OAuth2ServiceAccount


今回はちょうど先にあげたCodeplexに上げているFIM 2010 GoogleApps MAが使っているGoogleAppsのProvisioning APIの提供が終了するので、代わりに提供されているDirectory APIへ対応させるために改修を行う過程で行ったGoogleApps APIへのアクセス認可の方法を紹介していきます。


◆GoogleAPIへのアクセス用トークンを取得する
さて、実際にGoogleAppsの場合のアクセストークンの取得方法を解説していきます。
先のGoogleのドキュメントをみるとサービスアカウントを使ったAPI利用、というケースが今回のユースケースに該当します。流れとしてはJWTにRSA-SHA256で署名したものをOAuthのトークンエンドポイントへPOSTすればアクセストークが取得できる、とあります。
以下が公式ドキュメントに書いてあるフローです。



【準備】
まずは、JWTを生成する上で必要な設定をGoogle側に入れていきます。

必要なのは、以下の4点です。
①必要なAPIを有効化する
 ⇒GoogleAppsでは最初からすべてのAPIが有効になっているわけではないので、必要なAPIを有効化します。
②クライアントIDを生成する
 ⇒Googleにクライアント情報を登録し、署名に使う証明書を発行してもらう
③GoogleAppsのテナントのAPIアクセスを有効化する
 ⇒初期状態ではAPIアクセスは無効なので有効化する必要があります。
④生成したクライアントから利用できるAPIの範囲(スコープ)を設定する
 ⇒②で生成したクライアントが利用できるAPIをあらかじめ定義しておきます。


順番に見ていきます。①②はGoogle Developer Consoleから、③④はGoogleAppsの管理ダッシュボードのセキュリティメニューから設定します。

①必要なAPIを有効化する
 Google Developer Console(https://console.developers.google.com)にアクセスするとプロジェクトを作成する画面になりますので、まだプロジェクトを作成したことのない人はプロジェクトを作成してください。
 作成が終わったら左ペインの[APIと認証]メニューよりAPIを選択するとAPI一覧の画面になりますので、今回使いたいDirectory APIが含まれるAdmin SDKを探してステータス列の[OFF]のボタンをクリックし有効化を行います。


 確認・同意画面がポップアップするので[同意]をクリックしてAPIを有効化します。


 有効なAPI一覧にAdmin SDKが出てきます。



②クライアントIDを生成する
 次に、同じくDeveloper Consoleの[認証情報]メニューを開き、新しいクライアントIDを作成します。


 作成するクライアントIDの種類を聞かれるので[サービスアカウント]を選択します。


 作成が成功すると、公開鍵/秘密鍵のペアが生成され、秘密鍵の入った証明書ファイル(.p12ファイル)が自動的にダウンロードされます。このファイルは後で使うので大切に保管しておいてください。秘密鍵のパスワードは[notasecret]で固定みたいですが、その値の通り特に秘密事項ではありませんので合言葉として覚えておけばよいです。


 その後、画面上にサービスID情報が表示されます。
 ここで表示されるサービスIDおよびメールアドレス(サービスアカウントのメールアドレス)は後で使いますので、保存しておいてください。



 これでDeveloper Consoleは終わりです。

③GoogleAppsのテナントのAPIアクセスを有効化する
 次はGoogleAppsの管理ダッシュボードの設定です。
 ダッシュボードよりセキュリティメニューを開き、[APIリファレンス]を開くと[APIアクセス]が出てきますので、ここで[APIアクセスを有効にする]にチェックを入れておきます。これでこのGoogleAppsテナントへAPI経由でのアクセスが出来るようになります。


④生成したクライアントから利用できるAPIの範囲(スコープ)を設定する
 同じくセキュリティメニューの[詳細設定]を開きます。ちなみに詳細設定が表示されていない場合は[もっと見る]というリンクがシングルサインオン設定メニューの下にありますので、そちらをクリックしてください。
 サブメニューの中の[APIクライアントアクセスを管理する]をクリックして実際の設定を行います。


 クライアント名に②で生成したクライアントID、APIの範囲に使いたいAPIに対応したスコープを設定します。
 ちなみにDirectory APIの中で今回はユーザを管理したいので、以下を設定します。
  https://www.googleapis.com/auth/admin.directory.user


 スコープの一覧は以下に記載されています。
  https://developers.google.com/admin-sdk/directory/v1/guides/authorizing


ここまでで準備は終わりです。
次回は実際にJWTを生成してアクセストークンを取得するところを解説します。
(.Net標準でSystem.IdentityModel.Tokens.jwtのJwtSecurityTokenHandlerがあるのでせっかくなので使おうとしましたが、結果的にGoogleとはかなり相性が悪く、ほぼスクラッチでコードを書きました...orz。詳しくは次回)