しばやん雑記

Azure Serverless とメイドさんが大好きなフリーランスのプログラマーのブログ

Static Web Apps に追加されたカスタムロールの割り当て機能を試した

Static Web Apps には全てのプランで使える組み込み認証 + ロール管理機能と、Standard 以上で使えるカスタム認証機能がありますが、Azure AD B2C や OpenID Connect Provider を使ったカスタム認証の場合は、ロールは認証済みしか使えなかったので高度なアクセス制限は実装出来ませんでした。

これまでロールベースで管理していたアプリケーションは SWA の利用が難しかったのですが、プレビューでカスタム認証でのロール割り当てが自由に行える機能が公開されました。

仕組みとしては簡単で、ログインの度に呼び出される API を用意して、その API がロール情報を返せば反映されるというものになっています。

ドキュメントは既に十分なものが用意されているので、これを読めば簡単に実装出来ます。

API には ID トークンに含まれる情報 + アクセストークンが含まれているという感じです。アクセストークンが含まれているので、Azure AD の場合は scope の設定を行っておけば、アクセストークンを使って別の API を実行してロールを返すこともできるわけです。

実際にチュートリアルでは渡されるアクセストークンを使って、Graph API を呼び出してユーザーの所属するグループを取得してロールとして返しています。

しかしユーザーの所属するグループはわざわざ Graph API を叩かなくても、Azure AD アプリケーションの設定で ID トークンに含めることができるので、こちらのアプローチでカスタムロールを試しました。

適当に Azure AD でセキュリティグループを作成して、ユーザーに割り当てます。ID トークンには Object ID が含まれるので、面倒ですが API でマッピングを持つか Graph API で解決するかのどちらかが必要です。

f:id:shiba-yan:20211019175339p:plain

f:id:shiba-yan:20211019175347p:plain

Graph API で解決する場合は Managed Identity を有効化して、そちらに Graph API の実行権限を付けるほうがスマートです。実現方法が気になる方は以下のエントリを参照してください。

ユーザーをグループに追加すれば、後は Azure AD アプリケーションの設定から ID トークンにセキュリティグループの情報を含めるように設定します。

f:id:shiba-yan:20211019175355p:plain

さらに詳しく ID トークンにグループ情報を含める方法を知りたい方は、以下のドキュメントを参照してください。細かい設定が多いので一度目を通しておくとよいかと思います。

ここまでの設定でログインした時の ID トークンにグループ情報が含まれるようになったので、後はロール情報を返す Azure Function を作成して、それの API を Static Web App 側に設定する流れになります。

ロール情報を返す Function は C# を使って以下のように実装しました。マッピングはチュートリアルと同様にコード側に持つようにして簡略化していますが、この Function から SQL Database や Cosmos DB にアクセスしてロール情報を取得しても良いので、ロールに関しては完全に自由に制御できるようになっています。

public static class GetRoles
{
    private static readonly Dictionary<string, string> _roleGroupMappings = new Dictionary<string, string>
    {
        { "496e06f7-7493-4ab3-8218-821fd6e1f2bb", "Admin" }
    };

    [FunctionName("GetRoles")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest req,
        ILogger log)
    {
        var body = await new StreamReader(req.Body).ReadToEndAsync();

        var request = JsonConvert.DeserializeObject<RolesSourceRequest>(body);
        
        var roles = new List<string>();

        foreach (var claim in request.Claims.Where(x => x.Typ == "groups"))
        {
            if (_roleGroupMappings.TryGetValue(claim.Val, out var roleName))
            {
                roles.Add(roleName);
            }
        }

        return new OkObjectResult(new RolesSourceResponse { Roles = roles });
    }
}

public class RolesSourceRequest
{
    public string IdentityProvider { get; set; }
    public string UserId { get; set; }
    public string UserDetails { get; set; }
    public IReadOnlyList<Claim> Claims { get; set; }
    public string AccessToken { get; set; }
}

public class RolesSourceResponse
{
    public IReadOnlyList<string> Roles { get; set; }
}

public class Claim
{
    public string Typ { get; set; }
    public string Val { get; set; }
}

注意点としては claims は単純な Key-Value で入ってくるので、groups のような複数値があるものは同じキー名で渡されます。LINQ でフィルタするのが簡単ですね。リクエストのデシリアライズが冗長になっていますが、何故かバインディングだと正しく動作しなかったので、このように書いています。

最後に staticwebapp.config.jsonrolesSource を追加してデプロイすると完了です。この API は / から始まる必要があるので、外部ホストの API は指定できませんが BYOF でも問題なく動きました。

{
  "$schema": "https://json.schemastore.org/staticwebapp.config.json",
  "auth": {
    "rolesSource": "/api/GetRoles",
    "identityProviders": {
      "azureActiveDirectory": {
        "enabled": true,
        "registration": {
          "openIdIssuer": "https://login.microsoftonline.com/<TENANT_ID>/v2.0",
          "clientIdSettingName": "AAD_CLIENT_ID",
          "clientSecretSettingName": "AAD_CLIENT_SECRET"
        }
      }
    }
  }
}

デプロイ後に Azure AD でのログインを行い、お馴染みの /.auth/me にアクセスしてみると、セキュリティグループと同名のロールが割り当てられていることが確認できます。

f:id:shiba-yan:20211019175851p:plain

Application Insights を確認すると rolesSource に指定した API がログインの度に実行されているのが分かります。この API はログインの度に呼び出されるため応答が遅いとユーザー体験が悪くなってしまうので、実装には注意したいところです。

f:id:shiba-yan:20211019180026p:plain

API が失敗してもログインが失敗することはないですが、当然ながらロールは割り当てられません。

このロール情報を利用するには、これまでの組み込みロールと同様に staticwebapp.config.jsonallowedRoles で指定すれば問題ないです。以下のような定義を用意すれば /admin ページには Admin ロールを持ったユーザーのみアクセス出来るようになります。

{
  "routes": [
    {
      "route": "/admin",
      "allowedRoles": ["Admin"]
    }
  ]
}

API の実装は必要になりますが、完全にログインユーザーのロールをカスタマイズできるようになったので、複雑なロールを持つアプリケーションにも十分対応できるようになりました。

注意点としてはログイン時のみ呼び出されるので、途中でロール情報を変更しても反映されるのは明示的なログアウトか、ログインセッションが切れてからになることぐらいでしょうか。反映されるまでの時間を短くするためには、セッションの有効期限を変更する必要がありますが、SWA では未対応です。