express-openid-connect+keycloakでステップアップ認証

　特別なweb api(e.g. /issue)へのアクセス時には<b>ステップアップ認証</b>、それ以外のweb api(e.g. /status)へのアクセス時には<b>普通の認証</b>ですませたいときに、express-openid-connectを使ってどう実装すればいいかに関して試行錯誤したので、その過程を紹介したいと思います。

参考にした記事は、以下の記事です。
- [Red Hat SSO(Keycloak)でステップアップ認証してみる(アプリはQuarkusで)](https://qiita.com/takashyan/items/58bd1765cc3b69443a2f)
	- ステップアップ認証をするための、keycloak側の設定内容が詳しい。
- [OAuth 2.0 Step-up Authentication Challenge Protocol](https://qiita.com/TakahikoKawasaki/items/2ce43d30c48bec4a1153)
	- 理論的な話はこちらが詳しい。acr_valuesを使ってステップアップ認証が行われているのがよくわかります。

## Step 1 keycloakの設定
- [keycloak 18.0.2](https://github.com/keycloak/keycloak/releases/download/18.0.2/keycloak-18.0.2.tar.gz)をdownloadして展開(← 実践keycloakで紹介されているバージョンなので)
- bin/kc.sh start-devでkeycloakを起動し、adminのpasswordを設定
- myrealmを作成し、その中にmyclientを作る。(webサーバ側のドメインをhttp://127.0.0.1:8080/とした場合、以下のような画面イメージ) ![keycloak.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624321/764431f3-4efa-90b3-118e-9e835eac3a6f.png)
- ユーザを生成。(ユーザ名はtest) ![keycloak2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624321/bc0e72ff-fad1-22c8-d963-5d23e2b4635e.png)
- [8.3.4. ステップアップメカニズムを使用したブラウザーログインフローの作成](https://access.redhat.com/documentation/ja-jp/red_hat_single_sign-on/7.6/html-single/server_administration_guide/index#step-up-flow)に従って、"Browser Incl Step Up mechanism"を作る。![keycloak3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624321/c222252b-4880-ac86-0b72-8076ea46a657.png)
- authentication-> Bindingsにて、Browser Flowに自分で作ったflowを選択![keycloak5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624321/78052da6-e87a-450a-0b0d-087b39337ee4.png)
- myrealに移動し、LoA1やLoA2に対してACR valueを割り当てる。ここでは、LoA1にsilver、LoA2にgoldを割り当てている。![keycloak6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624321/1b64758c-719c-305a-13ac-2d8c7e4497ae.png)
## Step 2 webページの準備
- issue api/status apiを叩くwebページを用意する。
``` index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset='utf-8'>
  </head>
  <body>
    <div align=center>
      <div id="title">[registration]</div>
      <input type="button" value="資格の発行" onclick="issue()"/><br><br>
      <input type="button" value="status" onclick="status()"/>
      <p><div id=qrmsg></div>
      <p><div id=qr></div>
    </div>
  </body>
  <script type="text/javascript">

  function sendData_fetch(url, data, type, cb){
    fetch(url, {
      method: 'POST',
      headers:{'Content-Type': type},
      body: data
    })
    .then(async response => {
      const res =await response.text()
      cb(res);
    })
    .catch(error => {
       console.error(error);
       return;
    });
  }


  function issue() {
     let setting={"xx": "1111"};
     sendData_fetch("./issue", JSON.stringify(setting), 'application/json', 
       function(data){
         document.getElementById("qr").innerHTML=data;
       }
     );
  }

  function status() {
     let setting={"status": "xxxx"};
     sendData_fetch("./status", JSON.stringify(setting), 'application/json', 
       function(data){
         document.getElementById("qrmsg").innerHTML=data;
       }
     );
  }
  </script>
</html>
```
## Step 3 web apiサーバの準備
- step2のindex.htmlを、viewsフォルダの下に置いて、以下のようなファイル構成のweb apiサーバを作る
 ```
 +--server.mjs
 +-- .env
 +-views
    |-- index.html
 ```
- .envは、step1で設定したkeycloakの設定値が入る。
``` .env
ISSUER_BASE_URL=http://127.0.0.1:8080/realms/myrealm
CLIENT_ID=myclient
BASE_URL=http://127.0.0.1:8888
CLIENT_SECRET=< client secret>
SECRET= <session secret: any word ok>
```
- server.mjsは以下の通り。express-openid-connectを使ってapiごとに認証をかける方法をコーディング。具体的にはapp.use(auth({.,authRequired: false,..}))を設定し、各apiの前処理にrequiresAuth()を入れた。
``` server.mjs
import pkg from 'express-openid-connect';
const { auth, requiresAuth } = pkg;
import * as dotenv from "dotenv";
import QRCode from 'qrcode';
import  express from 'express';
import crypto from 'crypto';
import  http from 'http';
import path from 'path';
import { fileURLToPath } from 'url';
import bodyParser from 'body-parser';

//************
let hostname = 'localhost';
const port=8888;
//*************
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();
app.use('/', express.static(__dirname + '/views/'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

/////////////////////
//  keycloak/openid setting
//////////////////
dotenv.config();
app.use(
  auth({
    authRequired: false,
    idpLogout: true,
    authorizationParams: {
      response_type: "code",
      acr_values:"silver"
    },
  })
);

const httpServer = http.createServer(app);
const server = httpServer.listen(port, () => {
	console.log('\n web page start: listening on port %s...', server.address().port);
});

let task= new Array();

app.post('/issue',  requiresAuth(), async function(req,res){
  console.log("\n====POST====");
  console.log("cmd:"+JSON.stringify(req.params));
  console.log("query:"+JSON.stringify(req.query));
  console.log("body:"+JSON.stringify(req.body));
  console.log("====POST====");

  let sessNo = crypto.randomBytes(8).toString("hex");
  task[sessNo] = req.body ;
  let inviteUrl = "openid-vc://?request_uri=https://"+hostname+"/.issuanceRequests/"+sessNo;
  QRCode.toDataURL(inviteUrl, function (err, url) {
    console.log("-> "+inviteUrl);
    res.send(`<img src="${url}">`);
  });
})

app.post('/status',  requiresAuth(), async function(req,res){
  console.log("\n====POST====");
  console.log("STATUS");
  console.log(req.oidc.accessToken);
  if (req.oidc.isAuthenticated()){
    res.send("OK");
  }else{
    res.send("NG");
  }
})
```
## step 4 デバッグ
- 上記内容で、status apiにアクセスを試してみると、ブラウザのconsole上に下記エラーが吐き出される
>Access to fetch at 'http://127.0.0.1:8080/realms/myrealm/protocol/openid-connect/auth?client_id=myclient&scope=openid%20profile%20email&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A8888%2Fcallback&acr_values=silver&nonce=fVBYfEFciHdd9Mo6NZ1wTEEEsAVBFAg9GotehuPy-SA&state=eyJyZXR1cm5UbyI6Imh0dHA6Ly8xMjcuMC4wLjE6ODg4OCJ9&code_challenge_method=S256&code_challenge=_Xn5VUuHnokllNro9Nf-lzpPhocdvbRNvd0Vkv0_dyw' (redirected from 'http://127.0.0.1:8888/status') from origin 'http://127.0.0.1:8888' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
- keycloakの認証画面にredirectしようとしてくれているようだが、fetchでは処理できないようである。
- なので、fetch apiでredirectを感知したら、ブラウザの子windowsをopen関数で開いて認証処理を行いaccess tokenを取得してから再度fetch apiを叩くように修正。
- 子windowが叩くapiは```/auth?ref=＜url＞```で、新たにweb apiサーバ側に用意する必要がある。ref値は、どのapiから呼び出されたかを示しており、それによって認証方法を変える。
``` index.htmlの変更部分
  function login(ref,cb){
    let name = window.location.origin;
    let wid = window.open("./auth?ref="+ref, "authentication","width=500,height=400,scrollbars=yes");
    let timer = setInterval(function () {
      if (wid.document.location.href == name + "/auth" ) {
        clearInterval(timer);
        wid.close();
        cb();
      }
    }, 250);
  }

  function sendData_fetch(url, data, type, cb){
    fetch(url, {
      method: 'POST',
      redirect: 'manual',
      headers:{'Content-Type': type},
      body: data
    })
    .then(async response => {
      const res =await response.text()
      if (response.type == "opaqueredirect"){
        login(url, function(){
          console.log("--> re-request");
          sendData_fetch(url, data, type, cb);
        })
       }else{
          console.log('fetch: POST: ' + url + ': ' + res);
          cb(res);
       }
    })
    .catch(error => {
       console.error('sendData:');
       console.error(error);
       return;
    });
  }
```
- server.mjsに/auth apiを作り、それに応じてissue, status apiも少し変更したのが下記である。
``` server.mjsの変更部分
app.get('/auth',  (req,res) => {
  console.log("\n====GET(/auth)====");
  console.log("cmd:"+JSON.stringify(req.params));
  console.log("query:"+JSON.stringify(req.query));
  console.log("====GET====");

  if (typeof req.query.ref !== "undefined"){
    if (req.query.ref.indexOf("issue") !=-1 ) {
      console.log("need acr=gold")
      res.oidc.login({
        returnTo: '/auth',
        authorizationParams: {
          acr_values:"gold"
        }
      })
    }else{
      console.log("acr=silver")
      res.oidc.login({
        returnTo: '/auth',
        authorizationParams: {
          acr_values:"silver"
        }
      })
    }
  }else{
    if (req.oidc.isAuthenticated()){
      res.send("authenticated");
    }else{
      res.send("un authenticated");
    }
  }
})

app.post('/issue',  async function(req,res){
  console.log("\n====POST====");
  console.log("cmd:"+JSON.stringify(req.params));
  console.log("query:"+JSON.stringify(req.query));
  console.log("body:"+JSON.stringify(req.body));
  console.log("====POST====");

  if (req.oidc.isAuthenticated() && req.oidc.user.acr=="gold" 
       && !req.oidc.accessToken.isExpired()){
    console.log(req.oidc.user);
    let sessNo = crypto.randomBytes(8).toString("hex");
    task[sessNo] = req.body ;
    let inviteUrl = "openid-vc://?request_uri=https://"+hostname+"/.issuanceRequests/"+sessNo;
    QRCode.toDataURL(inviteUrl, function (err, url) {
      console.log("-> "+inviteUrl);
      res.send(`<img src="${url}">`);
    });
  }else{
    console.log("error.  need to be autheticated with acr level gold");
    res.redirect('/auth');
  }
})

app.post('/status',  requiresAuth(), async function(req,res){
  console.log("\n====POST====");
  console.log("STATUS");
  if (req.oidc.isAuthenticated()){
    res.send("OK");
  }else{
    res.send("NG");
  }
})
```
## テスト
- ソフトウェアを起動(```bin/kc.sh start-dev```, ```node server.mjs```)し、ブラウザでhttp://127.0.0.1:8080/にアクセス
- 下記画面が出てくるので、statusボタンを押す ![qiita-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624321/9976c163-0d14-685f-253b-189f8ce623d8.png)
- keycloakによる認証画面が出てくるので、keycloakに登録したid/passwordでsign-in ![qiita-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624321/975bbe75-37dc-0096-3669-90822107080a.png)
- 認証が通りOKが表示されているのを確認出来る
![qiita-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624321/dfe06d0c-ed83-5d11-eac4-959450a8e32a.png)
- 次に資格の発行ボタンを押す。するとステップアップ認証が実行され、すでにid/password認証は通っているので、OTP認証だけが実行される
![qiita-4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624321/2223c3cf-b0f4-644a-d58d-e3c908d40879.png)
- OTP認証が通れば、QRコードが表示される
![qiita-5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624321/63d22a7c-ae73-d85a-dc90-1aa3a25ea67f.png)