My External Storage

Apr 28, 2019 - 8 minute read - Comments - go

ory/fositeから読み解くGoにおけるStrategy/FactoryMethodパターン

ory/fositeというGoのSDKの中でいくつかのデザインパターンが利用されていたので、それを読み解いてみる。

TL;DR

  • ory/fositeはOAuth2.0に対応した認可サーバを作るためのGoのSDK
  • トークンの払い出しなどの実装はStrategyパターンとFactoryMehotdパターンを利用している
  • 上記のデザインパターン以外にも、Functional optionsパターンや型アサーションを組み合わせた、実践的な設計アプローチがなされている
  • ソースコードを読んでみることでGoにおけるデザインパターンの実装を学ぶことができた

なお、本記事で参照しているory/fositeのコードは2019/04/28時点で最新のv0.29.6になる。

ory/fositeとは

ory/fositeはGoで実装されたOAuth2.0のサーバを構築するためのフレームワークだ。

ORY自体はドイツの会社で、他にもory/hydraライブラリなど、OAuth2.0やOpenID Connectに関するプロダクトを複数提供している。

GoでOAuth2.0のサーバを構築するライブラリは他にもopenshift/osinRichardKnop/go-oauth2-serverが存在する。 が、openshift/osinは既にDuprecatedになっており、Duprecatedを周知するIssueの中でory/fositeが勧められている。

ory/fositeで活用されているデザインパターン

この記事で触れるory/fositeのコードでは主に次のデザインパターンを利用している。

  • FactoryMethodパターン
  • Strategyパターン
  • Functional optionsパターン

FactroyMethodパターン、StrategyパターンはオーソドックスなGoFのデザインパターンなので概要や利点の説明は省く。

Functional optionsパターン

Functional OptionsパターンはGoで多用されるオプションパターンだ。次のサンプルコードは原典であるRob Pike氏の記事からの引用だ。

type option func(*Foo) interface{}

// Verbosity sets Foo's verbosity level to v.
func Verbosity(v int) option {
    return func(f *Foo) interface{} {
        previous := f.verbosity
        f.verbosity = v
        return previous
    }
}

// Option sets the options specified.
// It returns the previous value of the last argument.
func (f *Foo) Option(opts ...option) (previous interface{}) {
    for _, opt := range opts {
        previous = opt(f)
    }
    return previous
}

func FooOption() option関数を複数定義し、ビルド関数に可変長引数でoption型を受けるようにしておくことで、ビルド関数の引数が増えるのを防ぐことが出来る。

ory/fositeのコードでどのようにデザインパターンが用いられているか

実際にory/fositeでどのようにデザインパターンが利用されているか確認する。

strategyパターンが使われているory/fositeの基本構造

まず、ory/fositeを使うときの基本操作からデザインパターンを見てみる。ここではStrategyパターンによる実処理の移譲が行われている。
ory/fositeの利用者が扱う基本的なインターフェースはfosite.OAuth2Providerだ。

// https://github.com/ory/fosite/blob/v0.29.6/oauth2.go#L42-L161

// OAuth2Provider is an interface that enables you to write OAuth2 handlers with only a few lines of code.
// Check fosite.Fosite for an implementation of this interface.
type OAuth2Provider interface {
	NewAuthorizeRequest(ctx context.Context, req *http.Request) (AuthorizeRequester, error)

	NewAuthorizeResponse(ctx context.Context, requester AuthorizeRequester, session Session) (AuthorizeResponder, error)

	WriteAuthorizeError(rw http.ResponseWriter, requester AuthorizeRequester, err error)

	WriteAuthorizeResponse(rw http.ResponseWriter, requester AuthorizeRequester, responder AuthorizeResponder)
	
	NewAccessRequest(ctx context.Context, req *http.Request, session Session) (AccessRequester, error)

	// ...
}

SDK利用者はHTTPサーバを立ち上げたあと、各HTTPハンドラの中でfosite.OAuth2Providerインターフェースを使って認可コードを発行したり、トークンを払い出したりする処理を実装していく(OAuth2.0についての説明は主旨と異なるのでしない)。

このインターフェースの具象型はfosite.Fosite型だ。

// https://github.com/ory/fosite/blob/v0.29.6/fosite.go#L85-L105

// Fosite implements OAuth2Provider.
type Fosite struct {
	Store                      Storage
	AuthorizeEndpointHandlers  AuthorizeEndpointHandlers
	TokenEndpointHandlers      TokenEndpointHandlers
	TokenIntrospectionHandlers TokenIntrospectionHandlers
	RevocationHandlers         RevocationHandlers
	Hasher                     Hasher
	ScopeStrategy              ScopeStrategy
	AudienceMatchingStrategy   AudienceMatchingStrategy
	JWKSFetcherStrategy        JWKSFetcherStrategy
	HTTPClient                 *http.Client

	// TokenURL is the the URL of the Authorization Server's Token Endpoint.
	TokenURL string

	// SendDebugMessagesToClients if set to true, includes error debug messages in response payloads. Be aware that sensitive
	// data may be exposed, depending on your implementation of Fosite. Such sensitive data might include database error
	// codes or other information. Proceed with caution!
	SendDebugMessagesToClients bool
}

内部に複数のFooHandlersを持っている。FooHandlers型は[]FooHandlerに名前付けした型だ。fosite.Fosite型はFooHandlersに実処理を移譲している。

例えば、fosite.OAuth2Providerインターフェースに定義されていて、fosite.Fositeで実装されているNewAccessRequestメソッドの実装をみてみる。

// https://github.com/ory/fosite/blob/v0.29.6/access_request_handler.go#L87-L101

func (f *Fosite) NewAccessRequest(ctx context.Context, r *http.Request, session Session) (AccessRequester, error) {
	// 前処理
	
	var found bool = false
	for _, loader := range f.TokenEndpointHandlers {
		if err := loader.HandleTokenEndpointRequest(ctx, accessRequest); err == nil {
			found = true
		} else if errors.Cause(err).Error() == ErrUnknownRequest.Error() {
			// do nothing
		} else if err != nil {
			return accessRequest, err
		}
	}

	if !found {
		return nil, errors.WithStack(ErrInvalidRequest)
	}
	return accessRequest, nil
}

fosite.Fosite型のメソッド内ではロジックの実装は行われていない。 実際の処理はfosite.Fosite型のTokenEndpointHandlersフィールドで保持しているTokenEndpointHandlerオブジェクトが行なっている。
if文の中の処理を説明すると、いずれかのTokenEndpointHandlerオブジェクトがリクエストを処理できればNewAccessRequestメソッドは正常終了する。 TokenEndpointHandlerオブジェクトがErrUnknownRequestエラーを返した場合、それはそのTokenEndpointHandlerオブジェクトでは未サポートのリクエストだったことになる。いずれかのTokenEndpointHandlerオブジェクトがErrUnknownRequest以外のエラーを返した場合、、あるいは全てのTokenEndpointHandlerオブジェクトが処理できなかった場合は異常なリクエストだったとしてエラーを返して終わる。

では、次にfosite.FositeFooHandlersフィールドに実処理を行なう各Handlerを格納する実装を見てみる。

Functional Optionsパターンを利用したStrategyパターンの初期化とFactoryメソッドパターン

fosite.Fositeオブジェクトのフィールドは外部に公開されている。そのため、地道に各オブジェクトを設定していくこともできるが、ory/fositeでは初期化用のcompose.Compose関数が用意されている。

https://github.com/ory/fosite/blob/v0.29.6/compose/compose.go#L33-L91

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


// Compose takes a config, a storage, a strategy and handlers to instantiate an OAuth2Provider:
// ...
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(),
	}

	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関数はFunctional OptionsパターンとFactoryMethodパターンを利用している。
引数で受けた可変長引数のfactoriesは後述するFooHnadlerオブジェクトを初期化するFooFactory関数が満たしているFactory型の関数定義だ。 Goはダックタイピングなので、Factoryが生成したFooHandlerオブジェクトの実体を知らずとも利用できる。型アサーションでfactory関数から生成されたオブジェクトがどのインターフェースを満たしているか確認し、インターフェースを満たしていれば、fosite.Fositeオブジェクトに登録していく。

factory型を満たすファクトリーメソッド自体は、以下のような関数群が定義されている。

// https://github.com/ory/fosite/blob/v0.29.6/compose/compose_oauth2.go

// OAuth2ClientCredentialsGrantFactory creates an OAuth2 client credentials grant handler and registers
// an access token, refresh token and authorize code validator.
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(),
	}
}
// https://github.com/ory/fosite/blob/v0.29.6/compose/compose_openid.go

// OpenIDConnectRefreshFactory creates a handler for refreshing openid connect tokens.
//
// **Important note:** You must add this handler *after* you have added an OAuth2 authorize code handler!
func OpenIDConnectRefreshFactory(config *Config, storage interface{}, strategy interface{}) interface{} {
	return &openid.OpenIDConnectRefreshHandler{
		IDTokenHandleHelper: &openid.IDTokenHandleHelper{
			IDTokenStrategy: strategy.(openid.OpenIDConnectTokenStrategy),
		},
	}
}

ory/fositeユーザは自分が利用したい認証・認可形式のファクトリーメソッドを使ってcompose.Compose関数を呼び出すことで、任意の実装形式が設定されたfosite.Oauth2Providorオブジェクトを取得することができる。

以下は提供されている全てのFactoryを使ってcompose.Compose関数を呼び出してfosite.OAuth2Providerの初期化を行なうcompose.ComposeAllEnabledの定義だ。

// https://github.com/ory/fosite/blob/v0.29.6/compose/compose.go#L93-L122

// ComposeAllEnabled returns a fosite instance with all OAuth2 and OpenID Connect handlers enabled.
func ComposeAllEnabled(config *Config, storage interface{}, secret []byte, key *rsa.PrivateKey) fosite.OAuth2Provider {
	return Compose(
		config,
		storage,
		&CommonStrategy{
			CoreStrategy:               NewOAuth2HMACStrategy(config, secret, nil),
			OpenIDConnectTokenStrategy: NewOpenIDConnectStrategy(config, key),
			JWTStrategy: &jwt.RS256JWTStrategy{
				PrivateKey: key,
			},
		},
		nil,

		OAuth2AuthorizeExplicitFactory,
		OAuth2AuthorizeImplicitFactory,
		OAuth2ClientCredentialsGrantFactory,
		OAuth2RefreshTokenGrantFactory,
		OAuth2ResourceOwnerPasswordCredentialsFactory,

		OpenIDConnectExplicitFactory,
		OpenIDConnectImplicitFactory,
		OpenIDConnectHybridFactory,
		OpenIDConnectRefreshFactory,

		OAuth2TokenIntrospectionFactory,

		OAuth2PKCEFactory,
	)
}

まとめ

GoのOSS SDKどのようにデザインパターンが活用されているかコードリーディングした。
デザインパターンの紹介記事はよくあるが、自動車とタイヤの簡単なコードだったりで実際のプロダクトのコードに適用するときとはだいぶ乖離がある。それなりのコード規模のSDKの中でどのように使われているか読むことで、自分の設計スキルに大きくプラスになった。
また、古典的なGoFのデザインパターンと、Goの言語仕様やGo独特(?)のデザインパターンを組み合わせたハイブリットな設計パターンだったのも良かった。 ただ、Goはダックタイピングなので、あるHandler型がどのインターフェースを満たしているのかパット見わからない。 実際にはどれが呼び出されているのか?を確認するのは少し時間がかかった。言語仕様上は具象型とインターフェースは疎結合でも、実体は密結合な利用方法をとるので、コメントに実装インターフェースを記載しておくなどの配慮が必要そうだ。

参考

関連記事