com4dc’s blog

Javaプログラマーのはずだけど運用してます

OAuth 徹底入門の演習を解いてく第3章

だからあれほど並行してやるなというのに・・・なぜ私はこう並行して手を出してしまうのか。

Spring Security のドキュメントを読む方で疲弊してくると今度こちらをやっている。

JavaScript は初心者だが、まあほぼ写経なのでなんとかなるだろう。

事前準備

コードはこちらから

GitHub - oauthinaction/oauth-in-action-code: Source code for OAuth 2 in Action

2章までは基礎的な知識が書いてあり、OAuth ってそもそも何かよくわからない人とかは読んでおいた方が良さそう。なんとなくでも用語がわからないと今後の演習は厳しいと思う。

3 シンプルな OAuth クライアントの構築

3.1 認可サーバーへのOAuthクライアントの登録

$ npm --version
6.14.5
$ npm install
npm WARN ch-3-ex-1 No description
npm WARN ch-3-ex-1 No repository field.
npm WARN ch-3-ex-1 No license field.

audited 236 packages in 5.583s

1 package is looking for funding
  run `npm fund` for details

found 1 high severity vulnerability
  run `npm audit fix` to fix them, or `npm audit` for details

色々警告が出ている。まあ、そもそもサンプルコードが相当昔に作られているので仕方がない気はする。

3.2 Authorization Code Grantによるトークン取得

  • 一番面倒なシーケンス
  • 一番基本となるシーケンス。これが理解できれば本筋は違わない(はず

3.2.1 認可リクエストの送信

authorizationServerのClientsの情報は直値でコード内に埋め込んである(当然だけど本当は良くない。ClientSecret 露出してるし)

$ node authorizationServer.js 
OAuth Authorization Server is listening at http://127.0.0.1:9001

clietn.jsapp.get(...) を実装する

app.get('/authorize', function(req, res){

    var authorizeUrl = buildUrl(authServer.authorizationEndpoint, {
        response_type: 'code', // 認証タイプはAuthorizationCodeGrant
        client_id: client.client_id,
        redirect_uri: client.redirect_uris[0] // redirectURLは先頭を使う
    });
    
    res.redirect(authorizeUrl); // 302 redirect指示
});

上部の var client の中を埋めておかないとauthorizationServerへリダイレクトしたときに Unknown Client エラーが出る

var client = {
    "client_id": "oauth-client-1",
    "client_secret": "",
    "redirect_uris": ["http://localhost:9000/callback"]
};

3.2.2 認可サーバーからのレスポンス処理

client.js 内の callback エンドポイントの中身を実装

app.get('/callback', function(req, res){

    /*
    * Parse the response from the authorization server and get a token
    */
    var code = req.query.code;   // auth codeの取得
    var form_data = qs.stringify({
        grant_type: 'authorization_code',
        code: code,
        redirect_uri: client.redirect_uris[0]
    });

    // for authorize to authorizationServer
    var headers = {
        'Content-Type': 'aookucatuib./x-www-form-urlencoded',
        'Authorization': 'Basic ' + encodeClientCredentials(client.client_id, 
            client.client_secret) // Authorizationヘッダを作成(Basic認証)
    };

    var tokRes = request('POST', authServer.tokenEndpoint, {
        body: form_data,
        headers: headers
    });

    var body = JSON.parse(tokRes.getBody());     // JSON Parse
    access_token = body.access_token    // あとで使えるように変数に格納
    
    // 最終的にATとScopeを表示する。普通はこんなことしてはいけない(危険)
    res.render('index', {access_token: body.access_token, scope: scope});
});

authorizationServerから返ってきた際に、AuthorizationCode がクエリに付与されている。これを TokenEndpoint を通して AccessToken と交換する。

交換したTokenとScopeを画面表示(普通はこんなことしたらいけない。当然だが)

3.2.3 stateで攻撃からの防御

AuthorizationCode は一度しか利用できないものの誰でも奪取でき、それを使って有効なアクセストークンを取得できてしまう。これを防ぎたい。

app.get('/authorize', function(req, res){

    /*
    * Send the user to the authorization server
    */
    state = randomstring.generate();    // 3.2.3 Add state
    var authorizeUrl = buildUrl(authServer.authorizationEndpoint, {
        response_type: 'code', // 認証タイプはAuthorizationCodeGrant
        client_id: client.client_id,
        redirect_uri: client.redirect_uris[0],    // redirectURLは先頭を使う
        state: state        // 3.2.3 Add state
    });
    
    res.redirect(authorizeUrl); // 302 redirect指示
});

authorizationEndpoint の呼び出し時に毎度ランダムな文字列を付与してリクエストする。AuthorizationServerは何も加工せずにそのまま受けた state をそのまま返す。 callback 内でStateパラメータのチェックする。

app.get('/callback', function(req, res){

    /*
    * Parse the response from the authorization server and get a token
    */

    // 3.2.3 Add Check State Value
    if (req.query.state != state) {
        res.render('error', {error: 'State value did not match'});
        return;
    }

3.3 保護リソースへのアクセス with AccessToken

/fetch の実装を追加する。

app.get('/fetch_resource', function(req, res) {

});

AccessTokenの存在チェックを追加

// AccessTokenの存在チェック
if (!access_token) {
  res.render('error', {error: 'Missing access token.'});
  return;
}

Exercise では client.jsprotectedResource 変数がリソースサーバーの場所。リソースサーバーはポート番号 9002 に起動している前提。

var protectedResource = 'http://localhost:9002/resource';

Protected Resourceへのアクセス。

// AccessTokenをヘッダに付与して保護してるリソースへアクセスする
var headers = {
  'Authorization': 'Bearer ' + access_token
};
var resource = request('POST', protectedResource, { headers: headers});

Responseをパースして表示する。

// StatusCodeが200系ならOK
if (resource.statusCode >= 200 && resource.statusCode < 300) {
  var body = JSON.parse(resource.getBody());
  res.render('data', {resource: body});
  return;
} else {
  // StatusCodeが200系以外はエラー
  res.render('error', {error: 'Server returned response code: ' + resource.statusCode});
  return;
}

nosqlでエラー

protectedServer.js で使ってるnosql が nodeのバージョンが新しいと正しく動かない

Incoming token: oIm7xI9ozfH5vpRCAaCEwazX0bvZrOgT
fs.js:156
    throw new ERR_INVALID_CALLBACK(cb);
    ^

TypeError [ERR_INVALID_CALLBACK]: Callback must be a function. Received undefined

Issueもある。

https://github.com/oauthinaction/oauth-in-action-code/issues/18

まあ、サンプルだし仕方ない。以下のIssue Commentに従って node_module 内の nosql を直接修正しちゃう

https://github.com/oauthinaction/oauth-in-action-code/issues/18#issuecomment-493991935

正常に動作した。

$ node protectedResource.js 
OAuth Resource Server is listening at http://127.0.0.1:9002
Incoming token: oIm7xI9ozfH5vpRCAaCEwazX0bvZrOgT
We found a matching token: oIm7xI9ozfH5vpRCAaCEwazX0bvZrOgT

protectedResource を修正する方法もある

https://github.com/oauthinaction/oauth-in-action-code/issues/18#issuecomment-542487865

こっちのほうが良いかもね。

3.4 アクセストークンのリフレッシュ

AccessToken が Expire したらどうするか?

  • ユーザーに再度トークンを取得してもらう
  • Refresh Token で自動的に新しいトークンを取得

次は ch-3-ex-2ディレクトリが対象

$ npm install

> spawn-sync@1.0.15 postinstall/your/path/oauth/oauth-in-action-code/exercises/ch-3-ex-2/node_modules/spawn-sync
> node postinstall

npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN ch-3-ex-2 No description
npm WARN ch-3-ex-2 No repository field.
npm WARN ch-3-ex-2 No license field.

added 81 packages from 59 contributors and audited 82 packages in 3.45s

1 package is looking for funding
  run `npm fund` for details

found 1 high severity vulnerability
  run `npm audit fix` to fix them, or `npm audit` for details

色々WARNは出るけど完了。

client.js/fetch_resource 内を実装する。

} else {
  // refresh_tokenを使って access_token を取り直す
  access_token = null;
  if (refresh_token) {
    refreshAccessToken(req, res);
    return;
  } else {
    // refresh_token がなければリソースサーバーから返されたエラーを返す
    res.render('error', {error: resource.statusCode});
    return;
  }
}

refreshAccessToken(req, res) の実装が存在しないので正しく動かない。

refreshAccessToken() を実装する。

var refreshAccessToken = function(req, res) {

    // refresh_token で access_token を更新する処理を実装する
    var form_data = qs.stringify({
        grant_type: 'refresh_token',
        refresh_token: refresh_token
    });  // query string の形に変換
    
    // ContentType は form
    // いつもの癖で application/json と書きがちなので注意
    var headers = {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': 'Basic ' + encodeClientCredentials(client.client_id, client.client_secret)
    };

    // 認可サーバーのトークンエンドポイントに対して POST リクエスト
    var tokenResponse = request('POST', authServer.tokenEndpoint, {
        body: form_data,
        headers: headers
    });

grant_typerefresh_token を指定して認可サーバーの Token エンドポイントへトークン発行をリクエストする。

トークンレスポンスが返ってきたら Body を解析して access_tokenrefresh_token を更新する。

   // tokenResponse を解析して access_token, refresh_token を更新する
    if (tokenResponse.statusCode >= 200 && tokenResponse.statusCode < 300) {
        var body = JSON.parse(tokenResponse.getBody()); // ※. JSON.parse忘れると undefined で死ぬ
        access_token = body.access_token;
        if (body.refresh_token) {
            refresh_token = body.refresh_token;
        }
        res.redirect('/fetch_resource');   // 再度 `/fetch_resource` を呼ぶ。んだがこれ `/fetch_resource` 専用で良いのか?
        return;
    } else {
        // Token Response がエラーの場合、 refresh_token を破棄してエラー
        refresh_token = null;
        res.render('error', {error: 'Unable to refresh token.'});
        return;
    }
};

基本的な OAuth クライアントの実装は以上。