Auth0ラボ - その2 : Calling an API

# はじめに
この記事はAuth0のハンズオンラボで[Auth0 Identity Labs](https://auth0.com/docs/identity-labs)を元に作成しています。Node.js + Express.jsで作成されたSample ApplicationとAPIを利用して、Auth0から払い出された認可情報を元にApplicationからAPIを呼び出します。[Auth0ラボ - その1 : Web Sign-In](https://qiita.com/hisashiyamaguchi/items/0e5d764c267eacc50769)が完了していることが前提となっているため、未だの方は[こちら](https://qiita.com/hisashiyamaguchi/items/0e5d764c267eacc50769)からお試しお願いします。


# 検証環境
- **OS** : `macOS Catalina 10.15.2`
- **node** : `10.15.3`
- **npm** : `6.13.2`
- **Git** : `2.23.0`

# ラボ
## Part1
Part1ではApplicationからAccess Tokenを使ってAPIを呼び出すようにします。[Git Repo](https://github.com/auth0/identity-102-exercises/)をローカルにクローンします。

```console
$ git clone https://github.com/auth0/identity-102-exercises.git
$ cd identity-102-exercises/lab-02/begin
$ ls
api webapp
```

apiディレクトリに移動してNode.jsのパッケージをインストール、環境変数定義ファイルを作成します。

```console
$ pwd
~/identity-102-exercises/lab-02/begin/api
$ npm install
$ cp .env-sample .env
```

webappディレクトリに移動してNode.jsのパッケージをインストール、環境変数定義ファイルを作成します。

```console
$ pwd
~/identity-102-exercises/lab-02/begin/webapp
$ npm install
$ cp .env-sample .env
```

api, webappを起動してChromeで`http://localhost:3000`にアクセスします。任意のユーザでSign-upして"Expenses"をクリックするとAPIが呼び出されて経費情報が表示されます。
**<font color="DarkOrange">この時点ではAPIは認可情報を元に保護されていないため、誰でもアクセスできてしまいます。後続の手順で認可情報を含むトークン(Access Token)でAPIにアクセスできるように修正します。</font>**

```console
$ pwd
~/identity-102-exercises/lab-02/begin/api
$ npm start &
$ cd ../webapp
$ npm start &
```

<img src="https://i.imgur.com/iAjx98i.png" width=70%>

webapp/server.jsを修正してAuth0からAccess Tokenを受け取るようにします。以下、修正後のコードです。
**<font color="DarkOrange">"response_type: 'code id_token'"を指定することで、Expressミドルウェアが認可コードを元にAccess TokenをApplicationに払い出します。"audience", "scope"はアクセス対象のAPI/ScopeをExpressミドルウェアに渡しています。</font>**


```jsx:webapp/server.js
require('dotenv').config();
const express = require('express');
const http = require('http');
const morgan = require('morgan');
const session = require('cookie-session');
const request = require('request-promise');
const {auth, requiresAuth} = require('express-openid-connect');

const appUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT}`;

const app = express();

app.set('view engine', 'ejs');

app.use(morgan('combined'));

app.use(session({
  name: 'identity102-lab-02',
  secret: process.env.COOKIE_SECRET,
}));

app.use(express.urlencoded({ extended: false }));

/* 以下をコメントアウト
app.use(auth({
  required: false,
  auth0Logout: true
}));
*/

// 下記を9行を追加
app.use(auth({
  required: false,
  auth0Logout: true,
  authorizationParams: {
    response_type: 'code id_token',
    audience: process.env.API_AUDIENCE,
    scope: 'openid profile email read:reports'
  }
}));

app.get('/', (req, res) => {
  res.render('home', { user: req.openid && req.openid.user });
});

app.get('/user', requiresAuth(), (req, res) => {
  res.render('user', { user: req.openid && req.openid.user });
});

app.get('/expenses', requiresAuth(), async (req, res, next) => {
  try {
    const expenses = await request(process.env.API_URL, {
      json: true
    });

    res.render('expenses', {
      user: req.openid && req.openid.user,
      expenses,
    });
  } catch (err) {
    next(err);
  }
});

app.get('/logout', (req, res) => {
  req.session = null;
  res.redirect('/');
});

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send(err);
});

http.createServer(app).listen(process.env.PORT, () => {
  console.log(`listening on ${appUrl}`);
});
```

webapp/server.jsを修正してAuth0から払い出されたAccess Tokenを使ってAPIにアクセスできるようにします。以下、修正後のコードです。
**<font color="DarkOrange">これによって、Applicationにログインしたユーザに割り当てられた認可情報を持ったAccess Tokenを使って、ユーザの代わりにApplicationがAPIを呼び出せるようになります。</font>**

```jsx:webapp/server.js
require('dotenv').config();
const express = require('express');
const http = require('http');
const morgan = require('morgan');
const session = require('cookie-session');
const request = require('request-promise');
const {auth, requiresAuth} = require('express-openid-connect');

const appUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT}`;

const app = express();

app.set('view engine', 'ejs');

app.use(morgan('combined'));

app.use(session({
  name: 'identity102-lab-02',
  secret: process.env.COOKIE_SECRET,
}));

app.use(express.urlencoded({ extended: false }));

app.use(auth({
  required: false,
  auth0Logout: true,
  authorizationParams: {
    response_type: 'code id_token',
    audience: process.env.API_AUDIENCE,
    scope: 'openid profile email read:reports'
  }
}));

app.get('/', (req, res) => {
  res.render('home', { user: req.openid && req.openid.user });
});

app.get('/user', requiresAuth(), (req, res) => {
  res.render('user', { user: req.openid && req.openid.user });
});

app.get('/expenses', requiresAuth(), async (req, res, next) => {
  try {
    /* 以下をコメントアウト
    const expenses = await request(process.env.API_URL, {
      json: true
    });
    */

    // 以下5行を追加
    const tokenSet = req.openid.tokens;
    const expenses = await request(process.env.API_URL, {
      headers: { authorization: "Bearer " + tokenSet.access_token },
      json: true
    });

    res.render('expenses', {
      user: req.openid && req.openid.user,
      expenses,
    });
  } catch (err) {
    next(err);
  }
});

app.get('/logout', (req, res) => {
  req.session = null;
  res.redirect('/');
});

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send(err);
});

http.createServer(app).listen(process.env.PORT, () => {
  console.log(`listening on ${appUrl}`);
});
```

webapp/.envを修正してAPIの識別子とApplicationのSecretを追加します。以下、修正後の.envです。
**<font color="DarkOrange">この時点でAPIはAuth0に登録されていないため識別子は任意で構いません。Part2でAPIをAuth0に登録する際に必要となるため控えておいて下さい。ApplicationのSecretはAuth0 Dashboardの"Applications"->"その1で作成したApplication"->"Settings"から確認できます。</font>**

```:webapp/.env
ISSUER_BASE_URL=https://kiriko.auth0.com
CLIENT_ID=xxxx
API_URL=http://localhost:3001
BASE_URL=http://localhost:3000
PORT=3000
COOKIE_SECRET=xxxx
API_AUDIENCE=https://expenses-api
CLIENT_SECRET=xxxx
```

<img src="https://i.imgur.com/Hk28KbB.png" width=70%>

Chromeに戻りログアウト後、再度ログインを押します。"message": "access_denied (Service not found: `https://expenses-api`)"が表示されれば成功です。
**<font color="DarkOrange">この時点でAPIはAuth0に登録されていないためエラーになります。</font>**


## Part2
Part2ではAPIをAuth0に登録してAuth0がAccess Tokenを払い出せるようにします。Auth0 Dashboardの左ペイン"APIs"をクリック、右上の"CREATE API"を押します。

<img src="https://i.imgur.com/AOtzUNp.png" width=70%>

"Name"に任意の名前を入力し、"Identifier"に"`https://expenses-api`"を入力して"CREATE"を押します。
**<font color="DarkOrange">IdentifierはPar1で.envに指定した識別子と同一である必要があります。</font>**
<img src="https://i.imgur.com/hwR7w6M.png" width=70%>

"Permissions"タブをクリック、"Define all the permissionsxxx"に"read:reports"を、"Description"に任意の説明文を入力して"ADD"を押します。
**<font color="DarkOrange">書式はOAuth2.0で規定されているxxxx:xxxxに従う必要があります。</font>**
<img src="https://i.imgur.com/6VCAYK1.png" width=70%>

ターミナルに戻りwebapp, apiを再起動してChromeから`http://localhost:3000`にアクセス、任意のユーザでログイン後、コンセント画面が表示されれば成功です。
**<font color="DarkOrange">Applicationが認可サーバが管理しているAPIにアクセスしようとしていますがよろしいですか？とリソースオーナーであるユーザに確認しています。</font>**

```console
$ pwd
~/identity-102-exercises/lab-02/begin/api
$ npm start &
$ cd ../webapp
$ npm start &
```
<img src="https://i.imgur.com/t5eelw9.png" width=70%>

Chromeから直接API(`http://localhost:3001`)にアクセスして見ます。下記のメッセージが表示されます。
**<font color="DarkOrange">この時点では、APIがAccess Tokenに含まれているScopeをチェックしていないためアクセスできてしまいます。</font>**

```json
[
  {
    "date": "2019-12-25T03:02:25.637Z",
    "description": "Pizza for a Coding Dojo session.",
    "value": 102
  },
  {
    "date": "2019-12-25T03:02:25.637Z",
    "description": "Coffee for a Coding Dojo session.",
    "value": 42
  }
]
```

APIディレクトリでexpress-oauth2-bearerパッケージをインストールします。
**<font color="DarkOrange">このパッケージでApplicationから送信されてくるリクエストに含まれるAccess Tokenのチェックを行います。</font>**

```console
$ pwd
~/identity-102-exercises/lab-02/begin/api
$ npm install express-oauth2-bearer
```

api/api-server.jsを修正してexpress-oauth2-bearerを読み込みます。以下、修正後のコードです。

```jsx:api/api-server.js
require('dotenv').config();
// 以下1行を追加
const { auth, requiredScopes } = require('express-oauth2-bearer');
const express = require('express');
const http = require('http');

const appUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT}`;

const app = express();

app.get('/', (req, res) => {
  res.send([
    {
      date: new Date(),
      description: 'Pizza for a Coding Dojo session.',
      value: 102,
    },
    {
      date: new Date(),
      description: 'Coffee for a Coding Dojo session.',
      value: 42,
    }
  ]);
});

http.createServer(app).listen(process.env.PORT, () => {
  console.log(`listening on ${appUrl}`);
});
```

api/api-server.jsを修正してExpressミドルウェアの認証処理を追加します。以下、修正後のコードです。

```jsx:api/api-server.js
require('dotenv').config();
const { auth, requiredScopes } = require('express-oauth2-bearer');
const express = require('express');
const http = require('http');

const appUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT}`;

const app = express();

// 以下1行を追加
app.use(auth());

app.get('/', (req, res) => {
  res.send([
    {
      date: new Date(),
      description: 'Pizza for a Coding Dojo session.',
      value: 102,
    },
    {
      date: new Date(),
      description: 'Coffee for a Coding Dojo session.',
      value: 42,
    }
  ]);
});

http.createServer(app).listen(process.env.PORT, () => {
  console.log(`listening on ${appUrl}`);
});
```

api/api-server.jsを修正して、End Pointに送信されてくるAccess Tokenをチェックするようにします。以下、修正後のコードです。
**<font color="DarkOrange">これ以降、End Pointに送信されてくるAccess Tokenは必ずチェックされ、有効期限が切れている、Scopeが合っていない等の不正がある場合はエラーが返ってくるようになります。</font>**

```jsx:api/api-server.js
require('dotenv').config();
const { auth, requiredScopes } = require('express-oauth2-bearer');
const express = require('express');
const http = require('http');

const appUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT}`;

const app = express();

app.use(auth());

// 以下をコメントアウト
// app.get('/', (req, res) => {
// 以下1行を追加
app.get('/', requiredScopes('read:reports'), (req, res) => {
  res.send([
    {
      date: new Date(),
      description: 'Pizza for a Coding Dojo session.',
      value: 102,
    },
    {
      date: new Date(),
      description: 'Coffee for a Coding Dojo session.',
      value: 42,
    }
  ]);
});

http.createServer(app).listen(process.env.PORT, () => {
  console.log(`listening on ${appUrl}`);
});
```

api/.envのISSUER_BASE_URLをAuth0のテナントドメイン名に修正します。

```:api/.env
PORT=3001
ISSUER_BASE_URL=https://kiriko.auth0.com
ALLOWED_AUDIENCES=https://expenses-api
```
ターミナルに戻りwebapp, apiを再起動してChromeから`http://localhost:3000`にアクセス、任意のユーザでログイン後、APIにアクセスできることを確認します。Chromeからダイレクトに`http://localhost:3001`にアクセスします。以下のエラーが返ってくれば成功です。
**<font color="DarkOrange">Access Tokenがリクエストに含まれていないためエラーになります。</font>**

```console
UnauthorizedError: bearer token is missing
    at Object.createInvalidTokenError (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express-oauth2-bearer/lib/errors.js:15:12)
    at /Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express-oauth2-bearer/index.js:43:26
    at Layer.handle [as handle_request] (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/layer.js:95:5)
    at trim_prefix (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/index.js:317:13)
    at /Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/index.js:284:7
    at Function.process_params (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/index.js:335:12)
    at next (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/index.js:275:10)
    at expressInit (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/middleware/init.js:40:5)
    at Layer.handle [as handle_request] (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/layer.js:95:5)
    at trim_prefix (/Users/hisashiyamaguchi/identity-102-exercises/lab-02/begin/api/node_modules/express/lib/router/index.js:317:13)
```

## Part3
Part3ではRefresh Tokenを利用してID Token, Access Tokenをリフレッシュできるようにします。ID TokenやAccess Tokenはネットワークを経由して複数のサービスで使いまわされるため常に漏洩の危険があります。各々に適切な有効期限を設けて、有効期限が切れた場合はRefresh Tokenを認可サーバのToken End Pointに送信し、Tokenを再発行するようにして漏洩を防止します。

Auth0 Dashbord左ペインの"APIs"->"作成したAPI"->"Settings"の"Allow Offline Access"フリップスイッチをオンにして"SAVE"を押します。
**<font color="DarkOrange">ユーザが認証されたタイミングでID Token, Access Tokenと一緒にRefresh Tokenが払い出されます。</font>**

<img src="https://i.imgur.com/VNtHA48.png" width=70%>

webapp/server.jsを修正してExpressミドルウェアに渡すScopeを変更します。以下、修正後のコードです。

```jsx:webapp/server.js
require('dotenv').config();
const express = require('express');
const http = require('http');
const morgan = require('morgan');
const session = require('cookie-session');
const request = require('request-promise');
const {auth, requiresAuth} = require('express-openid-connect');

const appUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT}`;

const app = express();

app.set('view engine', 'ejs');

app.use(morgan('combined'));

app.use(session({
  name: 'identity102-lab-02',
  secret: process.env.COOKIE_SECRET,
}));

app.use(express.urlencoded({ extended: false }));

app.use(auth({
  required: false,
  auth0Logout: true,
  authorizationParams: {
    response_type: 'code id_token',
    audience: process.env.API_AUDIENCE,
    // 以下1行を修正
    scope: 'openid profile email read:reports offline_access'
  }
}));

app.get('/', (req, res) => {
  res.render('home', { user: req.openid && req.openid.user });
});

app.get('/user', requiresAuth(), (req, res) => {
  res.render('user', { user: req.openid && req.openid.user });
});

app.get('/expenses', requiresAuth(), async (req, res, next) => {
  try {
    /*
    const expenses = await request(process.env.API_URL, {
      json: true
    });
    */
    const tokenSet = req.openid.tokens;
    const expenses = await request(process.env.API_URL, {
      headers: { authorization: "Bearer " + tokenSet.access_token },
      json: true
    });

    res.render('expenses', {
      user: req.openid && req.openid.user,
      expenses,
    });
  } catch (err) {
    next(err);
  }
});

app.get('/logout', (req, res) => {
  req.session = null;
  res.redirect('/');
});

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send(err);
});

http.createServer(app).listen(process.env.PORT, () => {
  console.log(`listening on ${appUrl}`);
});
```

webapp/server.jsを修正して、/expenses End PointにTokenの有効期限をチェックするコードを追加します。以下、修正後のコードです。

```jsx:webapp/server.js
require('dotenv').config();
const express = require('express');
const http = require('http');
const morgan = require('morgan');
const session = require('cookie-session');
const request = require('request-promise');
const {auth, requiresAuth} = require('express-openid-connect');

const appUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT}`;

const app = express();

app.set('view engine', 'ejs');

app.use(morgan('combined'));

app.use(session({
  name: 'identity102-lab-02',
  secret: process.env.COOKIE_SECRET,
}));

app.use(express.urlencoded({ extended: false }));

app.use(auth({
  required: false,
  auth0Logout: true,
  authorizationParams: {
    response_type: 'code id_token',
    audience: process.env.API_AUDIENCE,
    scope: 'openid profile email read:reports offline_access'
  }
}));

app.get('/', (req, res) => {
  res.render('home', { user: req.openid && req.openid.user });
});

app.get('/user', requiresAuth(), (req, res) => {
  res.render('user', { user: req.openid && req.openid.user });
});

app.get('/expenses', requiresAuth(), async (req, res, next) => {
  try {
    /*
    const expenses = await request(process.env.API_URL, {
      json: true
    });
    */
    // 以下1行をコメントアウト
    // const tokenSet = req.openid.tokens;
    // 以下6行を追加
    let tokenSet = req.openid.tokens;
    if (tokenSet.expired()) {
      tokenSet = await req.openid.client.refresh(tokenSet);
      tokenSet.refresh_token = req.openid.tokens.refresh_token;
      req.openid.tokens = tokenSet;
    }

    const expenses = await request(process.env.API_URL, {
      headers: { authorization: "Bearer " + tokenSet.access_token },
      json: true
    });

    res.render('expenses', {
      user: req.openid && req.openid.user,
      expenses,
    });
  } catch (err) {
    next(err);
  }
});

app.get('/logout', (req, res) => {
  req.session = null;
  res.redirect('/');
});

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send(err);
});

http.createServer(app).listen(process.env.PORT, () => {
  console.log(`listening on ${appUrl}`);
});
```

Auth0 Dashboardの"APIs"->"作成したAPI"->"Settings"の"Token Expiration (Seconds)", "Token Expiration For Browser Flows (Seconds)"を各々10にして"SAVE"を押します。
**<font color="DarkOrange">この記事ではRefresh Tokenを使ったTokenの再発行を試すため有効期限を極端に短くしています。実際は、サービスの要件に従って適切な有効期限を設定して下さい。</font>**

<img src="https://i.imgur.com/POPU9dS.png" width=70%>

api/api-server.jsを修正してIssu At Time(Tokenが発行された時間)をターミナルに出力します。以下、修正後のコードです。

```jsx:api/api-server.js
require('dotenv').config();
const { auth, requiredScopes } = require('express-oauth2-bearer');
const express = require('express');
const http = require('http');

const appUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT}`;

const app = express();

app.use(auth());

// app.get('/', (req, res) => {
app.get('/', requiredScopes('read:reports'), (req, res) => {
  // 以下1行を追加
  console.log(new Date(req.auth.claims.iat * 1000));
  res.send([
    {
      date: new Date(),
      description: 'Pizza for a Coding Dojo session.',
      value: 102,
    },
    {
      date: new Date(),
      description: 'Coffee for a Coding Dojo session.',
      value: 42,
    }
  ]);
});

http.createServer(app).listen(process.env.PORT, () => {
  console.log(`listening on ${appUrl}`);
});
```

ターミナルに戻りwebapp, apiを再起動してChromeから`http://localhost:3000`にアクセス、任意のユーザでログイン後、expenses End Pointにアクセスできることを確認します。何度かEnd Pointにアクセスして、ターミナルに出力されるIssue At Timeが異なる=Refresh Tokenを使ってID, Access Tokenが再発行されている, ことが確認できたら成功です。

```console
2019-12-25T05:07:51.000Z
2019-12-25T05:08:11.000Z
2019-12-25T05:08:11.000Z
2019-12-25T05:08:26.000Z
2019-12-25T05:08:26.000Z
```

# おわりに
最後までお付き合い頂きありがとうございます。Micro Service Architecture全盛の昨今、フロントエンドアプリケーションからバックエンドの複数のリソースサーバを呼び出すタイプのサービスアーキテクチャが標準になっており、可搬性・拡張性に優れたTokenベースの認証は不可欠かと思います。Auth0はTokenの有効期限を設定したり、Refresh Tokenを使ってTokenを再発行したりする機能を準備しているので、Application開発者様はTokenの管理を気にするこなく本来の開発作業に集中頂くことができます。