💋

Go 製の認可サーバー、IdP 実装用ライブラリ Fosite

2021/12/20に公開

本記事は Digital Identity技術勉強会 #iddance のアドベントカレンダー20日目の記事です。

ory/fosite について

ory/fosite: Extensible security first OAuth 2.0 and OpenID Connect SDK for Go.

OAuth 2.0/OIDC の 認可サーバー、IdP 実装のための Go のライブラリです。
エンドポイントそのものは直接実装せず、プロトコルに関連するリクエストを http のハンドラー経由でライブラリに渡してあげるとパースしたり必要なものを永続化したりレスポンスを生成してくれます。

Motivation によると、そもそも Hydra 用のライブラリとして開発されたようです。
Hydra については以前 Hydra による OAuth 2.0 の認可サーバー/ OIDC の IdP の実装イメージで紹介させていただきました。

検証環境

$ go version
go version go1.17.2 darwin/arm64
$ cat go.mod
github.com/ory/fosite v0.41.0

とりあえずクイックスタートを見ながら使ってみる

とりあえずクイックスタートサンプル実装を見ながら認可エンドポイント、トークンエンドポイント、イントロスペクションを実装して動かしてみます。

初期設定

package oauth2

import (
	"crypto/rand"
	"crypto/rsa"

	"github.com/ory/fosite/compose"
	"github.com/ory/fosite/storage"
)

var secret = []byte("my super secret signing password")
var privateKey, _ = rsa.GenerateKey(rand.Reader, 2048)
var config = &compose.Config{}
var oauth2Provider = compose.ComposeAllEnabled(config, storage.NewExampleStore(), secret, privateKey)

認可エンドポイントを定義

ログイン処理は自前で実装します。今回はクエリパラメーターに指定したユーザーがログインしているものとします。

package oauth2

import (
	"net/http"

	"github.com/ory/fosite"
)

func AuthorizationEndpoint(rw http.ResponseWriter, req *http.Request) {

	ctx := req.Context()
	ar, err := oauth2Provider.NewAuthorizeRequest(ctx, req)
	if err != nil {
		oauth2Provider.WriteAuthorizeError(rw, ar, err)
		return
	}

	if req.URL.Query().Get("username") == "" {
		rw.Write([]byte(`set username as query parameter.`))
		return
	}

	mySessionData := &fosite.DefaultSession{
		Username: req.Form.Get("username"),
	}

	response, err := oauth2Provider.NewAuthorizeResponse(ctx, ar, mySessionData)
	if err != nil {
		oauth2Provider.WriteAuthorizeError(rw, ar, err)
		return
	}

	oauth2Provider.WriteAuthorizeResponse(rw, ar, response)
}

トークンエンドポイントを定義

package oauth2

import (
	"net/http"

	"github.com/ory/fosite"
)

func TokenEndpoint(rw http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	mySessionData := new(fosite.DefaultSession)
	accessRequest, err := oauth2Provider.NewAccessRequest(ctx, req, mySessionData)
	if err != nil {
		oauth2Provider.WriteAccessError(rw, accessRequest, err)
		return
	}
	response, err := oauth2Provider.NewAccessResponse(ctx, accessRequest)
	if err != nil {
		oauth2Provider.WriteAccessError(rw, accessRequest, err)
		return
	}

	oauth2Provider.WriteAccessResponse(rw, accessRequest, response)
}

イントロスペクションエンドポイントを定義

package oauth2

import (
	"log"
	"net/http"

	"github.com/ory/fosite"
)

func IntrospectionEndpoint(rw http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	ir, err := oauth2Provider.NewIntrospectionRequest(ctx, req, new(fosite.DefaultSession))
	if err != nil {
		log.Printf("Error occurred in NewIntrospectionRequest: %+v", err)
		oauth2Provider.WriteIntrospectionError(rw, err)
		return
	}
	oauth2Provider.WriteIntrospectionResponse(rw, ir)
}

ハンドラーを定義

package main

import (
	"log"
	"net/http"

	"github.com/inabajunmr/fosite-oauth-server-sample/oauth2"
)

func main() {
	http.HandleFunc("/oauth2/auth", oauth2.AuthorizationEndpoint)
	http.HandleFunc("/oauth2/token", oauth2.TokenEndpoint)
	http.HandleFunc("/oauth2/introspect", oauth2.IntrospectionEndpoint)
	log.Fatal(http.ListenAndServe(":3846", nil))
}

動かしてみる

認可リクエストしてみます。

http://localhost:3846/oauth2/auth?client_id=my-client&response_type=code&state=64aa6f2d-52d1-ec96-04b7-832f8720e7a7&username=inaba&redirect_uri=http://localhost:3846/callback

認可レスポンスが返ってきました。

http://localhost:3846/callback?code=NbAK7SwO-zzwOD-u_Qr8_hlVJfwelvMtq4287QvBtoI.CHnA8zT8occbmyux3Fo9iyL_sP_-EeJLmr16u6REm-E&scope=&state=64aa6f2d-52d1-ec96-04b7-832f8720e7a7

トークンリクエストしてみます。

curl -d 'grant_type=authorization_code' -d 'redirect_uri=http://localhost:3846/callback' -d 'code=NbAK7SwO-zzwOD-u_Qr8_hlVJfwelvMtq4287QvBtoI.CHnA8zT8occbmyux3Fo9iyL_sP_-EeJLmr16u6REm-E' -u 'my-client:foobar' localhost:3846/oauth2/token

トークンレスポンスが返ってきました。

{"access_token":"KSQutKDWU0kL1gZW2kDUQoPDeGjuJOgCUkdeUs8orkc.IEyAMdYP2H9gPpTaZXLQ1KSU-_WrcMbB7SMq1ktjDFs","expires_in":3600,"scope":"","token_type":"bearer"}

イントロスペクションしてみます。

curl -d 'token=KSQutKDWU0kL1gZW2kDUQoPDeGjuJOgCUkdeUs8orkc.IEyAMdYP2H9gPpTaZXLQ1KSU-_WrcMbB7SMq1ktjDFs' -u 'my-client:foobar' localhost:3846/oauth2/introspect

イントロスペクションできました。

{"active":true,"client_id":"my-client","exp":1639206763,"iat":1639203162,"username":"inaba"}

ストレージをサンプルから自前の実装に切り替える

さきほどのサンプルでは以下のように初期化を行っていました。

var secret = []byte("my super secret signing password")
var privateKey, _ = rsa.GenerateKey(rand.Reader, 2048)
var config = &compose.Config{}
var oauth2Provider = compose.ComposeAllEnabled(config, storage.NewExampleStore(), secret, privateKey)

ここでは compose.ComposeAllEnabled で全機能を有効可してかつ永続化層の実装にサンプル実装を利用しています。
全機能というのはつまり「特定のグラントタイプだけ提供する」とか「イントロスペクションを提供する」とかそういったものを選べるのですが、それらを全部有効にする、という意味です。
永続化層の実装にサンプル実装は MemoryStore で、ここではインメモリの実装 & デフォルトで設定されたクライアントを利用、といった構造になっています。

実際に利用する場合はおそらくインメモリではない実装が必要になるのでこれを自前で用意して上げる必要がありますが、これをやってみます。(ただしここでの実装は結局インメモリです)
全機能有効だと実装量が多くて大変なので「クライアントクレデンシャルでのアクセストークン発行」と「イントロスペクション」だけ有効にして実装します。

実際に書いたコードは こちら を参照ください。

何を実装すればよいのか?

compose.Compose を利用して初期化する場合、こんな感じになります。構造は後述しますが、最後の引数に渡している XxxFactory によって有効にする機能が決まります。

var oauth2Provider = compose.Compose(
	config,
	storage.NewInMemoryStorage(defaultClient()),
	&compose.CommonStrategy{
		CoreStrategy:               compose.NewOAuth2HMACStrategy(config, secret, nil),
		OpenIDConnectTokenStrategy: compose.NewOpenIDConnectStrategy(config, privateKey),
		JWTStrategy: &jwt.RS256JWTStrategy{
			PrivateKey: privateKey,
		},
	},
	nil,

	compose.OAuth2ClientCredentialsGrantFactory,
	compose.OAuth2TokenIntrospectionFactory,
)

この場合、「クライアントクレデンシャルでのアクセストークン発行」と「イントロスペクション」だけ有効となります。

OAuth2ClientCredentialsGrantFactory の実装を追ってみると以下のようになっています。

func OAuth2ClientCredentialsGrantFactory(config *Config, storage interface{}, strategy interface{}) interface{} {
	return &oauth2.ClientCredentialsGrantHandler{
		HandleHelper: &oauth2.HandleHelper{
			AccessTokenStrategy: strategy.(oauth2.AccessTokenStrategy),
			AccessTokenStorage:  storage.(oauth2.AccessTokenStorage),
			AccessTokenLifespan: config.GetAccessTokenLifespan(),
		},
		ScopeStrategy:            config.GetScopeStrategy(),
		AudienceMatchingStrategy: config.GetAudienceStrategy(),
	}
}

OAuth2ClientCredentialsGrantFactory より引用)

ここからクライアントクレデンシャルのためには AccessTokenStorage を実装する必要があることがわかります。

type AccessTokenStorage interface {
	CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error)

	GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error)

	DeleteAccessTokenSession(ctx context.Context, signature string) (err error)
}

AccessTokenStorage より引用)

イントロスペクションも同様に追いかけると CoreStorage のインターフェースを実装する必要があるようです。

type CoreStorage interface {
	AuthorizeCodeStorage
	AccessTokenStorage
	RefreshTokenStorage
}

// AuthorizeCodeStorage handles storage requests related to authorization codes.
type AuthorizeCodeStorage interface {
	// GetAuthorizeCodeSession stores the authorization request for a given authorization code.
	CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) (err error)

	// GetAuthorizeCodeSession hydrates the session based on the given code and returns the authorization request.
	// If the authorization code has been invalidated with `InvalidateAuthorizeCodeSession`, this
	// method should return the ErrInvalidatedAuthorizeCode error.
	//
	// Make sure to also return the fosite.Requester value when returning the fosite.ErrInvalidatedAuthorizeCode error!
	GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (request fosite.Requester, err error)

	// InvalidateAuthorizeCodeSession is called when an authorize code is being used. The state of the authorization
	// code should be set to invalid and consecutive requests to GetAuthorizeCodeSession should return the
	// ErrInvalidatedAuthorizeCode error.
	InvalidateAuthorizeCodeSession(ctx context.Context, code string) (err error)
}

type AccessTokenStorage interface {
	CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error)

	GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error)

	DeleteAccessTokenSession(ctx context.Context, signature string) (err error)
}

type RefreshTokenStorage interface {
	CreateRefreshTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error)

	GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error)

	DeleteRefreshTokenSession(ctx context.Context, signature string) (err error)
}

CoreStorage より引用)

実装する

サンプルとあんまり変わりがないですが、インメモリで全部安直に実装するとこんな感じになりました。認可コード周りはインターフェース的には必要なんですが、今回は多分いらない気がしたので実装していません。

package storage

import (
	"context"
	"sync"
	"time"

	"github.com/ory/fosite"
)

type InMemoryStorage struct {
	clients       sync.Map
	accessTokens  sync.Map
	refreshTokens sync.Map
}

func NewInMemoryStorage(defaultClient fosite.Client) *InMemoryStorage {
	s := InMemoryStorage{
		clients:       sync.Map{},
		accessTokens:  sync.Map{},
		refreshTokens: sync.Map{},
	}
	s.CreateClient(context.TODO(), defaultClient)
	return &s
}

func (s *InMemoryStorage) CreateClient(_ context.Context, client fosite.Client) {
	s.clients.Store(client.GetID(), client)
}

func (s *InMemoryStorage) GetClient(_ context.Context, id string) (fosite.Client, error) {
	client, ok := s.clients.Load(id)
	if ok {
		return client.(fosite.Client), nil
	}

	return nil, fosite.ErrNotFound
}

func (s *InMemoryStorage) ClientAssertionJWTValid(_ context.Context, jti string) error {
	return nil
}

func (s *InMemoryStorage) SetClientAssertionJWT(_ context.Context, jti string, exp time.Time) error {
	return nil
}

func (s *InMemoryStorage) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error) {
	s.accessTokens.Store(signature, request)
	return nil
}

func (s *InMemoryStorage) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) {
	at, ok := s.accessTokens.Load(signature)
	if ok {
		return at.(fosite.Requester), nil
	}

	return nil, fosite.ErrNotFound
}

func (s *InMemoryStorage) DeleteAccessTokenSession(ctx context.Context, signature string) (err error) {
	s.accessTokens.Delete(signature)
	return nil
}

func (s *InMemoryStorage) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) (err error) {
	return nil
}

func (s *InMemoryStorage) GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (request fosite.Requester, err error) {
	return nil, nil
}

func (s *InMemoryStorage) InvalidateAuthorizeCodeSession(ctx context.Context, code string) (err error) {
	return nil
}

func (s *InMemoryStorage) CreateRefreshTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error) {
	s.refreshTokens.Store(signature, request)
	return nil
}

func (s *InMemoryStorage) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) {
	at, ok := s.refreshTokens.Load(signature)
	if ok {
		return at.(fosite.Requester), nil
	}

	return nil, fosite.ErrNotFound
}

func (s *InMemoryStorage) DeleteRefreshTokenSession(ctx context.Context, signature string) (err error) {
	s.refreshTokens.Delete(signature)
	return nil
}

実装したコードを動かしてみる

アクセストークンを発行します。

curl -d 'grant_type=client_credentials' -u 'default-client:secret' localhost:3846/oauth2/toke

発行できました。

{"access_token":"uxrxObB9G49rHV6bPfviWHMuiLgimbOt9E9B3MQi6Nc.m1ySRCqfv3ZOxoT0WNlLQ6UGmQmuZEZ7bo8p-6yqruA","expires_in":3599,"scope":"","token_type":"bearer"}%

イントロスペクションします。

curl -d 'token=uxrxObB9G49rHV6bPfviWHMuiLgimbOt9E9B3MQi6Nc.m1ySRCqfv3ZOxoT0WNlLQ6UGmQmuZEZ7bo8p-6yqruA' -u 'default-client:secret' localhost:3846/oauth2/introspect

イントロスペクションできました。

{"active":true,"client_id":"default-client","exp":1639224068,"iat":1639220468}

コードを眺めてみる

認可エンドポイント

fosite を利用して認可エンドポイントを実装する場合、いろいろはしょると以下のようになります。

ar, err := oauth2Provider.NewAuthorizeRequest(ctx, req)
mySessionData := &fosite.DefaultSession{
	Username: "ログインしているユーザー",
}
response, err := oauth2Provider.NewAuthorizeResponse(ctx, ar, mySessionData)
oauth2Provider.WriteAuthorizeResponse(rw, ar, response)

リクエストをまるごと NewAuthorizeRequest に渡すと、fosite はリクエストをパースし OAuth2.0 や OIDC に関連するパラメーターを抽出しながら各パラメーターのバリデーションを行います。

一方 NewAuthorizeResponse ではほとんどの処理をハンドラーに任せています。先程初期化で compose に XxxFactory を渡していましたが、ファクトリが生成するのがこのハンドラーとなります。

例えば OAuth2AuthorizeImplicitFactory を渡すとここで AuthorizeImplicitGrantTypeHandler が実行される、といった具合になります。つまり、ここで初めてグラントタイプやその他(PKCE の利用や OIDC の有無など)の差が出てきます。

ハンドラーではハンドラー特有のバリデーションと必要な情報の永続化を行っているようです。

例えば PKCE 有りの認可コードフローの場合、 OAuth2AuthorizeExplicitFactoryOAuth2PKCEFactory を利用しますが、前者のハンドラーで認可コードの発行と永続化、後者のハンドラーで code_challengecode_challenge_method を永続化したりしています。PKCE 用のパラメーターは認可コードと紐付けて永続化していますが、ハンドラーの順番は自動で制御されないようなので compose.Compose に Factory を渡す順番には気をつける必要があります。おそらく compose.ComposeAllEnabled の順番を参考にすれば問題ないかと思います。

ざっと見たところ、認可エンドポイントに関わらず「NewXxxRequest でバリデーションなどの前処理」を行って「NewXxxResponse で永続化」というパターンになっています。ただし、ハンドラーによる固有の処理がどこにあるのか、はエンドポイントごとに差があります。

トークンエンドポイント

トークンエンドポイントも利用時のコードは似たような感じです。

accessRequest, err := oauth2Provider.NewAccessRequest(ctx, req, new(fosite.DefaultSession))
response, err := oauth2Provider.NewAccessResponse(ctx, accessRequest)
oauth2Provider.WriteAccessResponse(rw, accessRequest, response)

NewAccessRequest で、リクエストのパース、バリデーション、クライアント認証などの処理を行います。認可リクエストと違ってこちらでは共通的な処理の後ハンドラーごとの処理も行います。例えば AuthorizeExplicitGrantHandler であれば、認可コードから認可リクエストを取得したりリダイレクト URI の検証はここで行います。

一方 NewAccessResponse の処理はほとんどハンドラーが行っていて、主にアクセストークンの発行と永続化をしています。

初期設定について

var oauth2Provider = compose.ComposeAllEnabled(config, storage.NewExampleStore(), secret, privateKey)

で初期化される oauthProvider の実態は各種ハンドラーが設定された fosite.Fosite です。
compose.Compose による fosite.Fosite の初期化は以下のようになっています。

func Compose(config *Config, storage interface{}, strategy interface{}, hasher fosite.Hasher, factories ...Factory) fosite.OAuth2Provider {
	if hasher == nil {
		hasher = &fosite.BCrypt{WorkFactor: config.GetHashCost()}
	}

	f := &fosite.Fosite{
		Store:                        storage.(fosite.Storage),
		AuthorizeEndpointHandlers:    fosite.AuthorizeEndpointHandlers{},
		TokenEndpointHandlers:        fosite.TokenEndpointHandlers{},
		TokenIntrospectionHandlers:   fosite.TokenIntrospectionHandlers{},
		RevocationHandlers:           fosite.RevocationHandlers{},
		Hasher:                       hasher,
		ScopeStrategy:                config.GetScopeStrategy(),
		AudienceMatchingStrategy:     config.GetAudienceStrategy(),
		SendDebugMessagesToClients:   config.SendDebugMessagesToClients,
		TokenURL:                     config.TokenURL,
		JWKSFetcherStrategy:          config.GetJWKSFetcherStrategy(),
		MinParameterEntropy:          config.GetMinParameterEntropy(),
		UseLegacyErrorFormat:         config.UseLegacyErrorFormat,
		ClientAuthenticationStrategy: config.GetClientAuthenticationStrategy(),
		ResponseModeHandlerExtension: config.ResponseModeHandlerExtension,
		MessageCatalog:               config.MessageCatalog,
	}

	for _, factory := range factories {
		res := factory(config, storage, strategy)
		if ah, ok := res.(fosite.AuthorizeEndpointHandler); ok {
			f.AuthorizeEndpointHandlers.Append(ah)
		}
		if th, ok := res.(fosite.TokenEndpointHandler); ok {
			f.TokenEndpointHandlers.Append(th)
		}
		if tv, ok := res.(fosite.TokenIntrospector); ok {
			f.TokenIntrospectionHandlers.Append(tv)
		}
		if rh, ok := res.(fosite.RevocationHandler); ok {
			f.RevocationHandlers.Append(rh)
		}
	}

	return f
}

compose.Compose より引用)

config はいわゆる設定、storage は先程実装した InMemoryStorage のような、永続化層の実装となり、これらを使ってfosite.Fosite を組み立てた後、渡したファクトリーによってハンドラーを生成しています。

Factory は以下のようなインターフェースになっています。

type Factory func(config *Config, storage interface{}, strategy interface{}) interface{}

例えばクライアントクレデンシャルの処理を行うハンドラーを生成するファクトリーは以下のようになっています。

func OAuth2ClientCredentialsGrantFactory(config *Config, storage interface{}, strategy interface{}) interface{} {
	return &oauth2.ClientCredentialsGrantHandler{
		HandleHelper: &oauth2.HandleHelper{
			AccessTokenStrategy: strategy.(oauth2.AccessTokenStrategy),
			AccessTokenStorage:  storage.(oauth2.AccessTokenStorage),
			AccessTokenLifespan: config.GetAccessTokenLifespan(),
		},
		ScopeStrategy:            config.GetScopeStrategy(),
		AudienceMatchingStrategy: config.GetAudienceStrategy(),
	}
}

OAuth2ClientCredentialsGrantFactory より引用)

Strategy と Config について

有効化する機能は compose.Compose にわたすファクトリーによってコントロール可能ですが、さらに細かい挙動は Strategy と Config によって設定します。厳密な基準はよくわからないのですが、内部的に実装の差し替えを行うものは Strategy、分岐や数値が変わるだけのものは Config で設定できるようになっているようです。
例えば Strategy ではアクセストークンの形式(乱数 or JWT)や、IDトークンの署名アルゴリズムを選択できて、Config ではトークンの有効期間や PKCE を強制するかどうかなどの設定ができます。
可能な設定の一覧は見たところドキュメントがなさそうなので実装を見る必要がありそうです。

ドキュメントについて

https://github.com/ory/fosite/issues/566 にもあるのですが、ドキュメントだけ読んで利用できるかというとちょっと厳しいような気がします。
現時点で実際に利用する場合、少なくとも compose.Compose 周りやストレージのサンプル実装などを見る必要がありそうです。
Storage の実装で返すエラーが暗黙的に指定されていたりするところがあるので、利用するエンドポイントとハンドラーのコードは一通り確認してから利用したほうが無難な気がします。

参考資料

GitHubで編集を提案

Discussion