🎩

OAuth2.0認可サーバーを実装したので解説する③【実装編】

12 min read

OAuth2.0とは

認可のフローを標準化したフレームワークです。
APIを外部に公開する等の際、OAuth2の仕様に従って認可サーバーを実装すれば高いセキュリティを確保出来ます。

RFC6749The OAuth 2.0 Authorization Framework に仕様が定義されてます。
OpenID Foundation Japan 様より、日本語訳もされているのでこちらも参考になると思います。

実装編について

こちらの記事では、サンプルアプリの実装を見ながら解説していきます。
今回はNode.jsで実装しました。

認可のフロー

以下はサンプルアプリのシーケンス図です。この図を見ながら読み進めるとよりイメージが湧きやすいと思います。

OAuth2フロー

認可

認可サーバーの実装はoauth2-example-jsoauth2ディレクトリ下にあります。

認可ページの表示は、/oauth2/authorizeにアクセスします。
認可ページでユーザーが承認すると、同じく**/oauth2/authorize**にGETリクエストを受け取ります。
サンプルアプリではresponse_typecodeのみに対応しています。
stateはCSRF対策のためのパラメータです。

OAuth2Route.js
router.get('/authorize', async (req, res) => {
  const {
    client_id: clientName,
    redirect_uri: redirectUri,
    response_type: responseType,
    email,
    pass,
    authorize,
    scope,
    state
  } = req.query
  const clientService = ClientService.sharedInstance
  const { client, scopes, redirectUris } = await clientService.findByName(
    clientName
  )
  if (!client) {
    res.status(403)
    res.send('Forbidden')
    return
  }
  if (!authorize) {
    // 認可ページ表示
    const appService = AppService.sharedInstance
    const app = await appService.findByClient(client)
    res.render('authorize', {
      app,
      scope,
      clientName,
      responseType,
      redirectUri,
      state
    })
    return
  }
  if (!redirectUris.map(({ uri }) => uri).includes(redirectUri)) {
    res.status(400)
    res.send('Invalid redirect_uri')
    return
  }
  const requestScopes = (typeof scope === 'string' ? scope : '').split(' ')
  const scopeChecked = (() => {
    const having = scopes.map(({ scope }) => scope)
    return requestScopes.every((v) => having.includes(v))
  })()
  if (!scopeChecked || responseType !== 'code') {
    const query = querystring.stringify({
      error: !scopeChecked
        ? 'invalid_scope'
        : responseType !== 'code'
          ? 'unsupported_response_type'
          : 'invalid_request'
    })
    res.redirect(`${redirectUri}?${query}`)
    return
  }
  const user = await LoginUsecase.execute({ email, pass })
  if (!user) {
    res.redirect('back')
    return
  }
  const authorizationService = AuthorizationService.sharedInstance
  const created = await authorizationService.createAuthorizationCode(
    client,
    requestScopes,
    user
  )
  if (!created) {
    const query = querystring.stringify({ error: 'invalid_request' })
    res.redirect(`${redirectUri}?${query}`)
    return
  }
  const { code } = created
  const query = querystring.stringify({ code, ...(state ? { state } : {}) })
  res.redirect(`${redirectUri}?${query}`)
})

client_idが正しいか、redirect_uriscopeがクライアントに登録されている値か、を検証します。(client_idは内部実装ではclientNameという変数名で扱ってる点に注意してください。)

検証に成功したらcodeを発行します。

codeの発行に成功したら、指定されたredirect_uricodeをクエリに含めてリダイレクトします。

リクエストが不正の場合、Errorをリダイレクト先に通知します。ErrorレスポンスはOAuth2の仕様でいくつか定義されています。RFC6749 4.1.2.1. Error Response

redirect_uriが不正な場合、そのredirect_uriにリダイレクトさせてはいけないので注意しましょう。

AccessTokenの取得

にゃーんサーバーにリダイレクトされcodeを受け取ります。(codeはクエリで受け渡しします。)
認可コードからaccess_tokenを要求する実装はnyanディレクトリ下のroutes/OAuth2Route.jsにあります。
access_tokenの取得に成功したら、取得したaccess_tokenを使ってつぶやきったーのAPIの/meにGETリクエストしユーザー情報を取得しています。

OAuth2Route.js
router.get('/callback', async (req, res) => {
  const { code, error } = req.query
  if (error) {
    res.send(error)
    return
  }
  const tsubuyakiService = TsubuyakiService.sharedInstance
  const data = await tsubuyakiService.getAccessToken(code)
  console.log('access_token', data)
  const user = await tsubuyakiService.getMe(data['access_token'])
  console.log('user', user)
  req.session.tsubuyakiAccessToken = data['access_token']
  req.session.tsubuyakiRefreshToken = data['refresh_token']
  // 有効期限はjsで取り扱いやすいようにミリ秒で持つ
  req.session.tsubuyakiAccessTokenExpires =
    Date.now() + data['expires_in'] * 1000
  res.redirect('/')
})

AccessTokenを発行

access_token発行の実装はoauth2ディレクトリのroutes/OAuth2Route.jsにあります。

サンプルアプリでは、grant_typeauthorization_coderefresh_tokenに対応しています。
authorization_codeは、認可コードによるaccess_token取得で、refresh_tokenリフレッシュトークンによるaccess_tokenの更新です。

OAuth2Route.js
router.post('/token', async (req, res) => {
  const {
    grant_type: grantType,
    code,
    refresh_token: refreshToken,
    client_id: clientName,
    client_secret: clientSecret,
    redirect_uri: redirectUri
  } = req.body
  if (grantType === 'authorization_code') {
    if (!code || !clientName || !clientSecret || !redirectUri) {
      res.status(400)
      res.json({ error: 'invalid_request' })
      return
    }
    const created = await GenerateAccessTokenUsecase.execute({
      clientName,
      clientSecret,
      redirectUri,
      code
    })
    if (!created) {
      res.status(400)
      res.json({ error: 'invalid_client' })
      return
    }
    const { accessToken, refreshToken, scopes } = created
    res.json({
      token_type: 'beare',
      access_token: accessToken.token,
      expires_in: ACCESS_TOKEN_EXPIRES_IN,
      refresh_token: refreshToken.token,
      scope: scopes
        .sort((a, b) => a - b)
        .map(({ scope }) => scope)
        .join(' ')
    })
  } else if (grantType === 'refresh_token') {
    if (!refreshToken || !clientName || !clientSecret) {
      res.status(400)
      res.json({ error: 'invalid_request' })
      return
    }
    const refreshed = await RefreshAccessTokenUsecase.execute({
      refreshToken,
      clientName,
      clientSecret
    })
    if (!refreshed) {
      res.status(400)
      res.send('invalid_request')
      return
    }
    const { accessToken, scopes } = refreshed
    res.json({
      token_type: 'beare',
      access_token: accessToken.token,
      expires_in: ACCESS_TOKEN_EXPIRES_IN,
      scope: scopes
        .sort((a, b) => a - b)
        .map(({ scope }) => scope)
        .join(' ')
    })
  } else {
    res.status(400)
    res.json({ error: 'unsupported_grant_type' })
  }
})

access_tokenの発行には、クライアントのクレデンシャルを受け取りクライアントを認証します。そして、認可コードがその認証されたクライアントに紐づくものであるか確認します。
このチェックにより、攻撃者に認可コードが盗まれても、クライアントのクレデンシャルが分からないとaccess_tokenを盗み出すことが出来なくなります。(逆にこのチェックが抜けてしまうとaccess_tokenを不正に取得することが可能になってしまい重大なセキュリティホールになってしまうので注意しましょう。)

リクエストが不正な場合などエラーレスポンスはapplication/jsonで返します。ErrorレスポンスはOAuth2の仕様でいくつか定義されています。RFC6749 5.2. Error Response

AccessTokenを使ってつぶやきったーに投稿する

いよいよ実際にaccess_tokenを使って、つぶやきったーのAPIを叩きます。
にゃーんボタンが押された時、にゃーんサーバーのAPIにブラウザからPOSTリクエストを送ります。
そこで受け取ったmessageつぶやきったーのサーバーにaccess_tokenをリクエストに付与して送信します。

NyanRoute.js
router.post('/', refreshAccessTokenIfNeed(), async (req, res) => {
  const { message } = req.body
  const accessToken = req.session.tsubuyakiAccessToken
  if (!accessToken) {
    res.status(401)
    res.send('Unauthorized')
    return
  }
  const tsubuyakiService = TsubuyakiService.sharedInstance
  const { data } = await tsubuyakiService.send(message, accessToken)
  console.log('Successfully sent to Tsubuyaki API', data)
  res.status(204)
  res.send()
})

つぶやきったーのAPIの実装は以下です。tsubuyakiディレクトリ下のroutes/api/IndexRoute.jsにあります。
受け取ったaccess_tokenwrite権限があるかのチェックを行い、あれば、access_tokenに紐づけられているユーザーでつぶやきをDBに保存します。
受け取ったaccess_tokenの情報の取得方法は以下で説明しています。

IndexRoute.js
router.post(
  '/status',
  authorization({ required: ['read', 'write'] }),
  async (req, res) => {
    const { user } = req.authorization
    const { body } = req.body
    const status = await SaveStatusUsecase.execute({ user, status: { body } })
    res.json(status)
  }
)

AccessTokenの情報を取得する

access_tokenの情報を取得するエンドポイントは/introspectionです。
その実装はoauth2ディレクトリのroutes/IndexRoute.jsにあります。
(注意点としてこのエンドポイントは内部向けにのみ公開されるべきです。本番運用では外部のネットワークとは切り離しましょう。)

IndexRoute.js
/* 内部向けAPI */
router.post('/introspection', async (req, res) => {
  const { token } = req.body
  const introspection = await IntrospectionUsecase.execute({ token })
  if (introspection) {
    res.json({
      active: true,
      ...introspection
    })
  } else {
    res.json({
      active: false
    })
  }
})

/introspectionにcurlしてみると以下のようなレスポンスが返ります。

$ curl -X POST -d token=<アクセストークン> http://localhost:9001/introspection
{
  "active":true,
  "sub":"1",
  "aud":"http://tsubuyaki.test/",
  "iss":"http://oauth2.nyan.test/",
  "exp":1636835816,
  "iat":1636832216,
  "scope":"read write"
}

access_tokenの情報の取得はmiddlewareで実装しています。
その実装は、tsubuyakiディレクトリ下のmiddleware/AuthorizationMiddleware.jsにあります。
上で紹介した/introspectionエンドポイントに対してPOSTリクエストでaccess_tokenを渡し、access_token情報を取得します。

非アクティブなtokenだった場合やサーバーに存在しないtokenだった場合、 { active: false } だけ返し、それ以外情報は返すべきではないです。 RFC7662 2.3. Error Response

OAuth2の Token Introspection については、RFC7662 に仕様が定義されているので、詳しくはそちらを参照すると良いです。

取得した値はそれぞれ以下を示しています。

キー 説明
sub リソースオーナー(ユーザーの識別子)
aud アクセストークンの受け手
iss アクセストークンの発行者(認可サーバーのURL)
exp アクセストークンの有効期限
iat アクセストークンが発行時のタイムスタンプ
scope 認可された権限をスペース区切りで表示
AuthorizationMiddleware.js
const authorization = (options) => {
  const requiredScopes = options?.required
  return async (req, res, next) => {
    const { token } = ((authorization) => {
      const kv = new Map([authorization?.split(' ') ?? []])
      return { token: kv.get('Bearer') }
    })(req.headers['authorization'])
    if (!!requiredScopes && !token) {
      res.status(401)
      res.send('Unauthorized')
      return
    }
    if (!requiredScopes && !token) {
      next()
      return
    }
    const { active, sub, scope } = await tokenIntrospection(token)
    if (!active && !!requiredScopes) {
      res.status(403)
      res.send('Forbidden')
      return
    }
    if (!active) {
      next()
      return
    }
    const user = await models.User.findByPk(sub)
    if (!user && !!requiredScopes) {
      res.status(403)
      res.send('Forbidden')
      return
    }
    if (!user) {
      next()
      return
    }
    const having = scope.split(' ')
    if (!requiredScopes.every((scope) => having.includes(scope))) {
      res.status(403)
      res.send('Forbidden')
      return
    }
    req.authorization = {
      user,
      scope: having
    }
    next()
  }
}

const tokenIntrospection = async (token) => {
  const res = await axios.post(
    'http://oauth2:3000/introspection',
    querystring.stringify({ token })
  )
  return res.data
}

以上が認可サーバーに必要な実装です。

記事一覧

https://zenn.dev/akhr_s/articles/11331882be7b8d

https://zenn.dev/akhr_s/articles/bc75705d3438fc

https://zenn.dev/akhr_s/articles/78ca9d20907f6b

Discussion

ログインするとコメントできます