Auth0のRBAC(Role-Based Access Control)を使ってAPIのアクセス制御をやってみた

RBACとは

RBAC(Role-Based Access Control)とは、アクセス制御の方法の1つで、アクセスする主体(ユーザやサービス)に直接権限を設定するのではなく、ロールに権限を設定してアクセスする主体にはロールを設定する権限管理方法のことです。こうすることでアクセス主体ごとに権限を管理するよりも、ミスが起きにくく効率的に権限を管理できるなどのメリットがあります。代表的な例として、AWS IAMのロールを想像していただけるとわかりやすいかと思います。

Auth0でできること

注意: 現在、Auth0でRBACを実現する方法として Authorization CoreAuthorization Extension の2つの方法がありますが、公式のアナウンスにて Authorization Core に統合されることが予告されていますので、本記事でも Authorization Core の内容だけを扱います。

Auth0のリソースとして、UsersとRoles、APIsの3つを利用します。

まずAPIsにて、CUSTOM APIを作成し、アクセス権限(Permission)の定義を行います。例えばCUSTOM APIには、タスクを参照するAPIと登録するAPIがあると仮定し、 read:task, write:task のようにそれぞれのAPIの実行を許可する権限(Permission)を定義します。これはアクセス制御を管理するための定義なので、どのような粒度でも構いません。

API 実行を許可するPermission
タスク参照API read:task
タスク登録API write:task

次にRoleを作成し、権限(Permission)を割り当てます。例えば、 Reader, Writer というRoleを作成し、それぞれに上記の read:task, write:task の権限(Permission)を割り当ててみます。

Role Permission
Reader read:task
Writer write:task

最後に、Userを作成し、上記のRoleを割り当てます。例えば、AというUserには Reader ロールを、BというUserには Writer ロールをという具合に割り当てます。

User Role
A Reader
B Writer

こうすることで、AというUserが上記のCUSTOM APIに対するAccessToken(jwt)を要求したとき、そのclaimに permission という属性が追加されます。AというUserは Reader Roleで、 read:task の権限が割り当てられているので、 permission[read:task] となります。

{
  "permissions": [
    "read:task"
  ]
}

API側では、リクエストヘッダーなどに設定されたAccessToken(jwt)から permission を読み取り、実行を許可するかどうかを判定することができます。

[備考] claimにrolesを付与するのとどう違うのか?

Auth0ではRulesという機能を使うことで、Userが要求したAccessToken(jwt)のclaimに、Userが属するRolesの情報を追加することもできます。例えば、上記の例でいうと、claimに roles という属性が追加され、値は [Reader] となると仮定しましょう。

{
  "roles": [
    "Reader"
  ]
}

API側ではこの roles の値を元に実行を許可するかどうかを判定することもできます。一見するとこれでも良さそうに見えますし、実際にRoleが2つしかない状況などではこれでも十分だと思います。

API 実行を許可するRole
タスク参照API Reader
タスク登録API Writer

しかし、もっとRoleの数が多く複雑な権限管理が必要になったときにこの方法では厳しくなってきます。例えば、新しい組織が増えRoleを追加しようとしたときに、この方法ではAPI(アプリケーション)側の修正が必要になりますが、Permissionで管理している場合はAuth0側に新しいRoleを追加して適切なPermissionを設定し、そのRoleを新しい組織のUserに割り当てるだけで良いのです。

やってみる

Auth0の設定

Tenantを登録

Auth0にSign Upし、新しいテナントを作成します。

APIsを登録

管理画面の APIs から、新しいAPIを登録します。

PermissionsタブでPermissionを設定します。

SettingsタブでRBACとAccessTokenへのPermissionの追加を有効化します。

Rolesを登録

管理画面の Users & Roles > Roles から、新しいRoleを登録します。

Usersを登録

管理画面の Users & Roles > Users から、新しいUserを登録します。

Applicationを登録

管理画面の Applications から、新しいApplicationを登録します。

種類は SINGLE PAGE APPLICATION とします。

今回はサンプルアプリケーションを使用しますので、Settingsタブの Allowed Callback URLs, Allowed Web Origins, Allowed Logout URLs に、それぞれ http://localhost:3000 を設定します。

ApplicationでAPIに対するAccessTokenを発行する

Application(SPA)にログインした後、APIを利用するためのAccessTokenを発行します。

ここが今回のはまりポイントでした。Application(SPA)にログインしたときに得られるAccessTokenには permission 属性は設定されないのです。なぜなら Permission は API によって定義されたもので、Application(SPA)の定義ではないからです。 permission 属性を含むAccessTokenを得るには、audienceをAPIとしてAccessTokenを要求する必要があります。(考えてみれば、ApplicationとAPIは異なるaudienceなのでApplicationで得たAccessTokenを使ってAPIにリクエストするのはおかしいですよね。)

サンプルアプリケーションを使ってやってみます。

ApplicationのQuick Startタブから、JavaScriptのサンプルアプリケーションをダウンロードします。

public/js/app.js の120行目辺りに以下のコードを追加します。scopeには openid を指定します。これを指定しないとAccessTokenがjwtになりません。

const options = {
  audience: "http://rbac-example.com/",
  scope: "openid"
};
const accessToken = await auth0.getTokenSilently(options);
console.log("accessToken", accessToken);

アプリケーションを起動し、ブラウザでアクセスしてログインします。

ログイン後、コンソールにaccessToken(jwt)が出力されていれば成功です。

jwt.io で jwt のペイロードを確認して permissions claim があれば成功です。

注意: "Consent required" のエラーになる場合

ブラウザに localhost ドメインでアクセスしてAPIのAccessTokenを取得しようとすると、"Consent required" というエラーになる場合があります。ならないときもあったのですが詳細な条件は不明です。このエラーは 公式のドキュメントでも説明されている のですが、APIのSettingsで"Allow Skipping User Consent"がEnabledになっているときでも、ローカルで起動しているアプリケーションは信頼できないからダメだよっていうことみたいです。回避方法として /etc/hosts を書き換えて適当なドメインを設定する方法が紹介されているのですが、これをすると今度は auth0-spa-js の方が、いやいや 信頼できないドメインでは使えないよ とエラーになるんです。私は ngrok で localhost:3000 に適当な(一応信頼される)ドメインを割り当てて回避することができました。

APIの権限管理を行う

最後にAPIアプリケーションで permissions claim による権限管理を行ってみます。今回はNode.jsで、適当にexpressのアプリケーションを作ります。雛形の作成に express-generator を使うので、 npm install express-generator -g しておきます。express コマンドを実行して雛形が作成されたら、次に必要なコマンドが表示されるので、それも実行しておきます。アプリケーションが起動することを確認してください。

$ express --no-view --git auth0-rbac-example

(中略)

   change directory:
     $ cd auth0-rbac-example

   install dependencies:
     $ npm install

   run the app:
     $ DEBUG=auth0-rbac-example:* npm start

次に、AccessToken(jwt)を検証できるようにミドルウェアを追加していきます。これはAuth0のAPIのQuick StartのNode.jsのサンプルがあるのでそれを、APIアプリケーションの app.js に追加します。express-jwt, jwks-rsa の npm も追加しておきます。

@@ -2,11 +2,25 @@ var express = require('express');
 var path = require('path');
 var cookieParser = require('cookie-parser');
 var logger = require('morgan');
+var jwt = require('express-jwt');
+var jwks = require('jwks-rsa');

 var indexRouter = require('./routes/index');
 var usersRouter = require('./routes/users');

+var jwtCheck = jwt({
+  secret: jwks.expressJwtSecret({
+    cache: true,
+    rateLimit: true,
+    jwksRequestsPerMinute: 5,
+    jwksUri: '<CUSTOM APIのissuer>.auth0.com/.well-known/jwks.json'
+  }),
+  audience: '<CUSTOM APIのaudience>',
+  issuer: '<CUSTOM APIのissuer>',
+  algorithms: ['RS256']
+});
+
 var app = express();

 app.use(logger('dev'));
@@ -15,6 +29,8 @@ app.use(express.urlencoded({ extended: false }));
 app.use(cookieParser());
 app.use(express.static(path.join(__dirname, 'public')));

+app.use(jwtCheck);
+
 app.use('/', indexRouter);

次に同じく app.js にタスク参照・登録APIのルーティングを設定します。

@@ -7,6 +7,7 @@ var jwks = require('jwks-rsa');

 var indexRouter = require('./routes/index');
 var usersRouter = require('./routes/users');
+var tasksRouter = require('./routes/tasks');

 var jwtCheck = jwt({
   secret: jwks.expressJwtSecret({
@@ -32,5 +33,6 @@ app.use(jwtCheck);

 app.use('/', indexRouter);
 app.use('/users', usersRouter);
+app.use('/tasks', tasksRouter);

 module.exports = app;

routes/users.js をコピーして routes/tasks.js を作ります。 GET /tasks エンドポイントは read:task permissionを必要とし、 POST /tasks エンドポイントは write:task permissionを必要とするようにします。express-jwt ミドルウェアが、リクエストの Authorization: Bearer を検証し、 req.user に jwt の claim を展開してくれているので、 req.user.permissions でアクセスが可能です。

var express = require('express');
var router = express.Router();

router.get('/', function(req, res, next) {
  console.log(req.user);
  if (req.user.permissions.includes('read:task')) {
    res.send({ result: 'OK' });
  } else {
    res.status(403);
    res.send({ result: 'Forbidden' });
  }
});

router.post('/', function(req, res, next) {
  console.log(req.user);
  if (req.user.permissions.includes('write:task')) {
    res.send({ result: 'OK' });
  } else {
    res.status(403);
    res.send({ result: 'Forbidden' });
  }
});


module.exports = router;

APIアプリケーションを起動して、先ほど取得しておいたAccessToken(jwt)を使って、リクエストしてみます。

GET /tasks エンドポイント

$ curl -X GET \
  http://localhost:3000/tasks \
  -H 'authorization: Bearer <AccessToken>'
{"result":"OK"}

POST /tasks エンドポイント

$ curl -X POST \
  http://localhost:3000/tasks \
  -H 'authorization: Bearer <AccessToken>'
{"result":"Forbidden"}

permissionによってアクセス制御ができていることが確認できました。

所感

  • API側は必要なpermissionだけ知っていれば良く、Auth0側でUser/Role/Permissionを管理できるので、うまく情報が分離されていて良いと思いました。過去に、User/Roleだけで複雑なアクセス制御を行って大変だった経験があるのでありがたみが分かります。
  • Userに管理対象のリソースごとにRoleが設定されるようなアクセス制御が必要な(例えば、あるUserがリソースAに対しては管理者権限でリソースBに対しては一般権限のような)場合にはそのままは使えませんが、UserのmetadataとRulesを組み合わせれば、リソースごとにpermissionを返すことはできそうです。
  • SPA側でもUserの権限に応じてアクセス制御(表示項目を変えたり)すると思いますが、SPA側のアクセス制御のベストプラクティスがあるのか気になりました。