OpenID Provider(OP)をServerlessで提供する

OpenID ConnectOpenID Provider(OP)をAWSを利用したサーバレスアーキテクチャで提供するためのserverless-oidc-providerを開発しました。

serverless-oidc-providerはnode-oidc-providerAWS Lambdaで実行し、ユーザ管理・認証をCognito User Poolに移譲、データ永続化にDynamoDBを利用することで、サーバを準備することなく迅速に、OpenID Provider(OP)を提供することができます。

詳しい利用方法はREADME.mdやソースコードを確認していただくのが早いとは思いますが、コンセプトや、どのようにユーザ管理を切り離しているのか、またAPI Gatewayのカスタムオーソライザーでの利用について、説明をしていきます。

コンセプト

OAuth 2.0 / OIDC 実装の新アーキテクチャーで述べられている、「ユーザー認証と認可の分離」、「アイデンティティー管理と認可の分離」と「API管理と認可の分離」といった考え方に大きな影響を受けています。
それらを達成しつつ、サービスごとの個別の要件に対応できる柔軟性の確保、スケーラビリティや耐障害性といったサーバレスのメリットを享受できることを目指しています。

OpenID Provider(OP)としてのコアの機能

node-oidc-providerに全面的に依存する構成を取っています。
エンドポイントごとにLambdaに最適化した実装を行った方が、パフォーマンス上有利ではあるのですが、OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語るで述べられているように実装難易度が高いこと、多くの機能についてnode-oidc-providerがOpenID Certificationの認証を受けていることから、Lambda上でnode-oidc-providerをWebアプリとして実行する選択をしました。

なお、node-oidc-providerはnode 8.0以上、内部で利用しているkoaはnode v7.6以上を要求します。
Lambdaで利用可能なnodeは6.10であるため、babel-registerを利用する必要があるのですが、現行のbabel-registerはLambda上で動作しません。参考
そのため、現在β版のv7系のbabelを利用しています。
新しいバージョンの@babel/registerはシンプルにrequire()を拡張可能なpiratesを利用するように実装の変更が行われており、Lambda上でも利用することが可能です。

require('@babel/polyfill')

require('./shims/url')
require('./shims/util')

require('@babel/register')({
  presets: [
    ["@babel/preset-env", {
      "targets": { "node": "6.10" }
    }]
  ],
  ignore: [filename => {
    if (filename.match(/node_modules\/oidc-provider/)) {
      return false
    } else if (filename.match(/node_modules\/koa/)) {
      return false
    } else {
      return true
    }
  }],
  cache: false,
  babelrc: false
})

@babel/registerは通常、node_modulesに対しては変換を行わないようになっています。そこで、上記のコードのように、oidc-providerとkoaに関連するモジュールに対して、require時に変換を行えるようにしています。

ユーザ認証

Cognito User Poolを利用していますが、Adminとしてのユーザログイン、ユーザ情報の取得のみの機能を利用しています。
ユーザの登録や管理は、各種SDKを利用したアプリケーションで行う想定としています。これは、可能な限りユーザ管理に関わる固有の実装を分離することを意図したものです。
具体的な実装はsrc/app/oidc/account/index.jsであり、同じインタフェースを備えるクラスを実装すれば、Cognito User Pool以外のユーザ認証の仕組みを利用することが可能です。
また、このクラスを利用するsrc/app/oidc/actions/interaction.jsと合わせて変更することで、任意の方法でのユーザの認証・特定の仕組みを利用することができます。

API Gateway カスタムオーソライザー

src/services/authorizeは、API Gatewayのカスタムオーソライザーとして指定可能なLambda関数のサンプルです。
RFC7662: OAuth 2.0 Token Introspectionに準拠したイントロスペクションエンドポイントを用いたカスタムオーソライザーの例であり、もちろんnode-oidc-providerの提供するエンドポイントでも利用することが可能です。
具体的な実装はsrc/app/authorize以下のコードとなります。

const { getToken } = require('./helpers/get-token')
const { generatePolicy } = require('./helpers/generate-policy')
const { introspect } = require('./introspection/introspect')

const authorizerHandler = async (event, context, callback) => {
  const token = getToken(event.authorizationToken)

  if (!token) {
    callback('Unauthorized')
  }

  const introspectionResult = await introspect(token)

  if (!introspectionResult.active) {
    callback('Unauthorized')
  }

  const { principalId, effect, resource, authContext } = await confirmPermission(introspectionResult, event.methodArn)

  const policy = generatePolicy(principalId, effect, resource, authContext)

  callback(undefined, policy)
}

/**
 * From the introspection result, confirm the authority to the access target.
 * If you are doing your own permission management, use this method to verify.
 */
const confirmPermission = async (introspectionResult, resource) => {
  // Currently it is an implementation that allows access if it is a valid token.
  return {
    principalId: (introspectionResult.sub) ? introspectionResult.sub : introspectionResult.client_id,
    effect: 'Allow',
    resource: resource,
    authContext: {
      // The value that can be set in the context is limited to the primitive value, so serialize it.
      introspection: JSON.stringify(introspectionResult)
    }
  }
}

module.exports = {
  authorizerHandler
}

confirmPermission()でイントロスペクションエンドポイントの結果とリソースに基づき、トークンの利用者がリソースにアクセス可能かを判断します。
現在のサンプルは、トークンが有効であればアクセスを許可する実装となっています。
また、保護されたリソースで再度のトークン情報の問い合わせが不要なように、カスタムオーソライザーのレスポンスのcontextに、イントロスペクションの結果をシリアライズしてセットしています。
実際には、イントロスペクションのレスポンスに含まれる、ユーザを一意に特定するsub(Client Credentials Grantでは含まない)とトークンを払い出されたclient_id、要求したscope等の情報を元に、リソースに対するクライアントアプリケーションに対するアクセス制御、ユーザに対するアクセス制御を行います。