Repository: ory/fosite (go)

Issue:
It seems DeleteOpenIDConnectSession is never called?
**Describe the bug**

I see that `DeleteOpenIDConnectSession` is defined and implemented for in-memory storage, but it is never called. This leads me to think that those session entries are never removed from the storage?

Pull request description:
docs: Document that DeleteOpenIDConnectSession is deprecated
## Related Issue or Design Document

Fixes #538.

## Checklist

- [x] I have read the [contributing guidelines](../blob/master/CONTRIBUTING.md)
      and signed the CLA.
- [x] I have referenced an issue containing the design document if my change
      introduces a new feature.
- [x] I have read the [security policy](../security/policy).
- [x] I confirm that this pull request does not address a security
      vulnerability. If this pull request addresses a security vulnerability, I
      confirm that I got green light (please contact
      [security@ory.sh](mailto:security@ory.sh)) from the maintainers to push
      the changes.
- [x] I have added tests that prove my fix is effective or that my feature
      works.
- [x] I have added necessary documentation within the code base (if
      appropriate).

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index af122dd..bdea2be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@
 
 **Table of Contents**
 
-- [0.0.0 (2021-10-13)](#000-2021-10-13)
+- [0.0.0 (2021-10-29)](#000-2021-10-29)
   - [Bug Fixes](#bug-fixes)
   - [Code Refactoring](#code-refactoring)
   - [Documentation](#documentation)
@@ -355,7 +355,7 @@
 
 <!-- END doctoc generated TOC please keep comment here to allow auto update -->
 
-# [0.0.0](https://github.com/ory/fosite/compare/v0.40.2...v0.0.0) (2021-10-13)
+# [0.0.0](https://github.com/ory/fosite/compare/v0.40.2...v0.0.0) (2021-10-29)
 
 ### Bug Fixes
 
@@ -376,6 +376,7 @@
 - Add client secret rotation support ([#608](https://github.com/ory/fosite/issues/608)) ([a4ce354](https://github.com/ory/fosite/commit/a4ce3544c2996a99b65350d4b200967df9fc0d45)), closes [#590](https://github.com/ory/fosite/issues/590)
 - Add prettier and format ([d682bdf](https://github.com/ory/fosite/commit/d682bdf51c22c211ee1aceb06fb7c4a7e43db326))
 - Add ResponseModeHandler to support custom response modes ([#592](https://github.com/ory/fosite/issues/592)) ([10ec003](https://github.com/ory/fosite/commit/10ec003fb414fd3fcbd3e2e6d250cb2da51a0304)), closes [#591](https://github.com/ory/fosite/issues/591)
+- I18n support added ([#627](https://github.com/ory/fosite/issues/627)) ([cf02af9](https://github.com/ory/fosite/commit/cf02af977681fd667b33f8e131891f6746d0b9da)), closes [#615](https://github.com/ory/fosite/issues/615)
 - Support jose.opaquesigner for JWTs ([#611](https://github.com/ory/fosite/issues/611)) ([1121a0a](https://github.com/ory/fosite/commit/1121a0aa4155e9216abb989ab008df8cff67830d))
 
 # [0.40.2](https://github.com/ory/fosite/compare/v0.40.1...v0.40.2) (2021-05-28)
diff --git a/README.md b/README.md
index 9480aac..fbb415f 100644
--- a/README.md
+++ b/README.md
@@ -243,6 +243,24 @@ looking at some examples:
 - `users.read` does not match `users.write`
 - `users.read` does not match `users.write.own`
 
+### Globalization
+
+Fosite does not natively carry translations for error messages and hints, but offers an interface that allows the consumer to define catalog bundles and an implementation to translate. This is available through the [MessageCatalog](i18n/i18n.go) interface. The functions defined are self-explanatory. The `DefaultMessageCatalog` illustrates this. Compose config has been extended to take in an instance of the `MessageCatalog`.
+
+#### Building translated files
+
+There are three possible "message key" types:
+
+1. Value of `RFC6749Error.ErrorField`: This is a string like `invalid_request` and correlates to most errors produced by Fosite.
+2. Hint identifier passed into `RFC6749Error.WithHintIDOrDefaultf`: This func is not used extensively in Fosite but, in time, most `WithHint` and `WithHintf` will be replaced with this function.
+3. Free text string format passed into `RFC6749Error.WithHint` and `RFC6749Error.WithHintf`: This function is used in Fosite and Hydra extensively and any message catalog implementation can use the format string parameter as the message key.
+
+An example of a message catalog can be seen in the [i18n_test.go](i18n/i18n_test.go).
+
+#### Generating the `en` messages file
+
+This is a WIP at the moment, but effectively any scripting language can be used to generate this. It would need to traverse all files in the source code and extract the possible message identifiers based on the different message key types.
+
 ### Quickstart
 
 Instantiating fosite by hand can be painful. Therefore we created a few
diff --git a/access_error.go b/access_error.go
index 09335dd..0b828f6 100644
--- a/access_error.go
+++ b/access_error.go
@@ -27,17 +27,21 @@ import (
 	"net/http"
 )
 
-func (f *Fosite) WriteAccessError(rw http.ResponseWriter, _ AccessRequester, err error) {
-	f.writeJsonError(rw, err)
+func (f *Fosite) WriteAccessError(rw http.ResponseWriter, req AccessRequester, err error) {
+	f.writeJsonError(rw, req, err)
 }
 
-func (f *Fosite) writeJsonError(rw http.ResponseWriter, err error) {
+func (f *Fosite) writeJsonError(rw http.ResponseWriter, requester AccessRequester, err error) {
 	rw.Header().Set("Content-Type", "application/json;charset=UTF-8")
 	rw.Header().Set("Cache-Control", "no-store")
 	rw.Header().Set("Pragma", "no-cache")
 
 	rfcerr := ErrorToRFC6749Error(err).WithLegacyFormat(f.UseLegacyErrorFormat).WithExposeDebug(f.SendDebugMessagesToClients)
 
+	if requester != nil {
+		rfcerr = rfcerr.WithLocalizer(f.MessageCatalog, getLangFromRequester(requester))
+	}
+
 	js, err := json.Marshal(rfcerr)
 	if err != nil {
 		if f.SendDebugMessagesToClients {
diff --git a/access_request_handler.go b/access_request_handler.go
index 2c373dd..263a3c9 100644
--- a/access_request_handler.go
+++ b/access_request_handler.go
@@ -26,6 +26,7 @@ import (
 	"net/http"
 	"strings"
 
+	"github.com/ory/fosite/i18n"
 	"github.com/ory/x/errorsx"
 
 	"github.com/pkg/errors"
@@ -58,6 +59,7 @@ import (
 //   in Section 3.2.1.
 func (f *Fosite) NewAccessRequest(ctx context.Context, r *http.Request, session Session) (AccessRequester, error) {
 	accessRequest := NewAccessRequest(session)
+	accessRequest.Request.Lang = i18n.GetLangFromRequest(f.MessageCatalog, r)
 
 	ctx = context.WithValue(ctx, RequestContextKey, r)
 	ctx = context.WithValue(ctx, AccessRequestContextKey, accessRequest)
diff --git a/access_response_writer.go b/access_response_writer.go
index 0b3f302..78071a6 100644
--- a/access_response_writer.go
+++ b/access_response_writer.go
@@ -49,7 +49,7 @@ func (f *Fosite) NewAccessResponse(ctx context.Context, requester AccessRequeste
 	}
 
 	if response.GetAccessToken() == "" || response.GetTokenType() == "" {
-		return nil, errorsx.WithStack(ErrServerError.WithHint("An internal server occurred while trying to complete the request.").WithDebug("Access token or token type not set by TokenEndpointHandlers."))
+		return nil, errorsx.WithStack(ErrServerError.WithHint("An internal server occurred while trying to complete the request.").WithDebug("Access token or token type not set by TokenEndpointHandlers.").WithLocalizer(f.MessageCatalog, getLangFromRequester(requester)))
 	}
 
 	return response, nil
diff --git a/authorize_error.go b/authorize_error.go
index 197b93b..c6f9002 100644
--- a/authorize_error.go
+++ b/authorize_error.go
@@ -36,7 +36,7 @@ func (f *Fosite) WriteAuthorizeError(rw http.ResponseWriter, ar AuthorizeRequest
 		return
 	}
 
-	rfcerr := ErrorToRFC6749Error(err).WithLegacyFormat(f.UseLegacyErrorFormat).WithExposeDebug(f.SendDebugMessagesToClients)
+	rfcerr := ErrorToRFC6749Error(err).WithLegacyFormat(f.UseLegacyErrorFormat).WithExposeDebug(f.SendDebugMessagesToClients).WithLocalizer(f.MessageCatalog, getLangFromRequester(ar))
 	if !ar.IsRedirectURIValid() {
 		rw.Header().Set("Content-Type", "application/json;charset=UTF-8")
 
diff --git a/authorize_request_handler.go b/authorize_request_handler.go
index 0b80a14..e117b8f 100644
--- a/authorize_request_handler.go
+++ b/authorize_request_handler.go
@@ -28,6 +28,7 @@ import (
 	"net/http"
 	"strings"
 
+	"github.com/ory/fosite/i18n"
 	"github.com/ory/fosite/token/jwt"
 	"github.com/ory/x/errorsx"
 	"gopkg.in/square/go-jose.v2"
@@ -276,6 +277,7 @@ func (f *Fosite) validateResponseMode(r *http.Request, request *AuthorizeRequest
 
 func (f *Fosite) NewAuthorizeRequest(ctx context.Context, r *http.Request) (AuthorizeRequester, error) {
 	request := NewAuthorizeRequest()
+	request.Request.Lang = i18n.GetLangFromRequest(f.MessageCatalog, r)
 
 	ctx = context.WithValue(ctx, RequestContextKey, r)
 	ctx = context.WithValue(ctx, AuthorizeRequestContextKey, request)
diff --git a/compose/compose.go b/compose/compose.go
index f01b07c..d999edb 100644
--- a/compose/compose.go
+++ b/compose/compose.go
@@ -73,6 +73,7 @@ func Compose(config *Config, storage interface{}, strategy interface{}, hasher f
 		UseLegacyErrorFormat:         config.UseLegacyErrorFormat,
 		ClientAuthenticationStrategy: config.GetClientAuthenticationStrategy(),
 		ResponseModeHandlerExtension: config.ResponseModeHandlerExtension,
+		MessageCatalog:               config.MessageCatalog,
 	}
 
 	for _, factory := range factories {
diff --git a/compose/config.go b/compose/config.go
index d5e9e34..0de92d2 100644
--- a/compose/config.go
+++ b/compose/config.go
@@ -26,6 +26,7 @@ import (
 	"time"
 
 	"github.com/ory/fosite"
+	"github.com/ory/fosite/i18n"
 )
 
 type Config struct {
@@ -117,6 +118,9 @@ type Config struct {
 
 	// ResponseModeHandlerExtension provides a handler for custom response modes
 	ResponseModeHandlerExtension fosite.ResponseModeHandler
+
+	// MessageCatalog is the message bundle used for i18n
+	MessageCatalog i18n.MessageCatalog
 }
 
 // GetScopeStrategy returns the scope strategy to be used. Defaults to glob scope strategy.
diff --git a/errors.go b/errors.go
index 87d55bc..4bb5559 100644
--- a/errors.go
+++ b/errors.go
@@ -28,7 +28,9 @@ import (
 	"net/url"
 	"strings"
 
+	"github.com/ory/fosite/i18n"
 	"github.com/ory/x/errorsx"
+	"golang.org/x/text/language"
 
 	stderr "errors"
 
@@ -267,6 +269,12 @@ type (
 		cause            error
 		useLegacyFormat  bool
 		exposeDebug      bool
+
+		// Fields for globalization
+		hintIDField string
+		hintArgs    []interface{}
+		catalog     i18n.MessageCatalog
+		lang        language.Tag
 	}
 	stackTracer interface {
 		StackTrace() errors.StackTrace
@@ -373,15 +381,43 @@ func (e *RFC6749Error) Cause() error {
 }
 
 func (e *RFC6749Error) WithHintf(hint string, args ...interface{}) *RFC6749Error {
-	return e.WithHint(fmt.Sprintf(hint, args...))
+	err := *e
+	if err.hintIDField == "" {
+		err.hintIDField = hint
+	}
+
+	err.hintArgs = args
+	err.HintField = fmt.Sprintf(hint, args...)
+	return &err
 }
 
 func (e *RFC6749Error) WithHint(hint string) *RFC6749Error {
 	err := *e
+	if err.hintIDField == "" {
+		err.hintIDField = hint
+	}
+
 	err.HintField = hint
 	return &err
 }
 
+// WithHintIDOrDefaultf accepts the ID of the hint message
+func (e *RFC6749Error) WithHintIDOrDefaultf(ID string, def string, args ...interface{}) *RFC6749Error {
+	err := *e
+	err.hintIDField = ID
+	err.hintArgs = args
+	err.HintField = fmt.Sprintf(def, args...)
+	return &err
+}
+
+// WithHintTranslationID accepts the ID of the hint message and should be paired with
+// WithHint and WithHintf to add a default message and vaargs.
+func (e *RFC6749Error) WithHintTranslationID(ID string) *RFC6749Error {
+	err := *e
+	err.hintIDField = ID
+	return &err
+}
+
 func (e *RFC6749Error) Debug() string {
 	return e.DebugField
 }
@@ -402,6 +438,13 @@ func (e *RFC6749Error) WithDescription(description string) *RFC6749Error {
 	return &err
 }
 
+func (e *RFC6749Error) WithLocalizer(catalog i18n.MessageCatalog, lang language.Tag) *RFC6749Error {
+	err := *e
+	err.catalog = catalog
+	err.lang = lang
+	return &err
+}
+
 // Sanitize strips the debug field
 //
 // Deprecated: Use WithExposeDebug instead.
@@ -420,7 +463,8 @@ func (e *RFC6749Error) WithExposeDebug(exposeDebug bool) *RFC6749Error {
 
 // GetDescription returns a more description description, combined with hint and debug (when available).
 func (e *RFC6749Error) GetDescription() string {
-	description := e.DescriptionField
+	description := i18n.GetMessageOrDefault(e.catalog, e.ErrorField, e.lang, e.DescriptionField)
+	e.computeHintField()
 	if e.HintField != "" {
 		description += " " + e.HintField
 	}
@@ -499,3 +543,11 @@ func (e *RFC6749Error) ToValues() url.Values {
 
 	return values
 }
+
+func (e *RFC6749Error) computeHintField() {
+	if e.hintIDField == "" {
+		return
+	}
+
+	e.HintField = i18n.GetMessageOrDefault(e.catalog, e.hintIDField, e.lang, e.HintField, e.hintArgs...)
+}
diff --git a/fosite.go b/fosite.go
index f4dd848..d197532 100644
--- a/fosite.go
+++ b/fosite.go
@@ -25,6 +25,8 @@ import (
 	"html/template"
 	"net/http"
 	"reflect"
+
+	"github.com/ory/fosite/i18n"
 )
 
 // AuthorizeEndpointHandlers is a list of AuthorizeEndpointHandler
@@ -115,6 +117,9 @@ type Fosite struct {
 	ClientAuthenticationStrategy ClientAuthenticationStrategy
 
 	ResponseModeHandlerExtension ResponseModeHandler
+
+	// MessageCatalog is the catalog of messages used for i18n
+	MessageCatalog i18n.MessageCatalog
 }
 
 const MinParameterEntropy = 8
diff --git a/go.mod b/go.mod
index 5fda974..ff57d4d 100644
--- a/go.mod
+++ b/go.mod
@@ -27,6 +27,7 @@ require (
 	golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c
 	golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4
 	golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
+	golang.org/x/text v0.3.3
 	gopkg.in/square/go-jose.v2 v2.5.2-0.20210529014059-a5c7eec3c614
 )
 
diff --git a/handler/openid/storage.go b/handler/openid/storage.go
index 6f912f6..c69f26d 100644
--- a/handler/openid/storage.go
+++ b/handler/openid/storage.go
@@ -40,6 +40,7 @@ type OpenIDConnectRequestStorage interface {
 	// - or an arbitrary error if an error occurred.
 	GetOpenIDConnectSession(ctx context.Context, authorizeCode string, requester fosite.Requester) (fosite.Requester, error)
 
-	// DeleteOpenIDConnectSession removes an open id connect session from the store.
+	// Deprecated: DeleteOpenIDConnectSession is not called from anywhere.
+	// Originally, it should remove an open id connect session from the store.
 	DeleteOpenIDConnectSession(ctx context.Context, authorizeCode string) error
 }
diff --git a/i18n/default_catalog.go b/i18n/default_catalog.go
new file mode 100644
index 0000000..d2ecb88
--- /dev/null
+++ b/i18n/default_catalog.go
@@ -0,0 +1,93 @@
+package i18n
+
+import (
+	"net/http"
+
+	"golang.org/x/text/language"
+	"golang.org/x/text/message"
+)
+
+// DefaultMessage is a single message in the locale bundle
+// identified by 'ID'.
+type DefaultMessage struct {
+	ID               string `json:"id"`
+	FormattedMessage string `json:"msg"`
+}
+
+// DefaultLocaleBundle is a bundle of messages for the specified
+// locale. The language tag can be arbitrary to allow for
+// unsupported/unknown languages used by custom clients.
+type DefaultLocaleBundle struct {
+	LangTag  string            `json:"lang"`
+	Messages []*DefaultMessage `json:"messages"`
+}
+
+// defaultMessageCatalog is a catalog of all locale bundles.
+type defaultMessageCatalog struct {
+	Bundles []*DefaultLocaleBundle
+
+	matcher language.Matcher
+}
+
+func NewDefaultMessageCatalog(bundles []*DefaultLocaleBundle) MessageCatalog {
+	c := &defaultMessageCatalog{
+		Bundles: bundles,
+	}
+
+	for _, v := range c.Bundles {
+		if err := v.Init(); err != nil {
+			continue
+		}
+	}
+
+	c.makeMatcher()
+	return c
+}
+
+// Init initializes the default catalog with the
+// list of messages. The lang tag must parse, otherwise this
+// func will panic.
+func (l *DefaultLocaleBundle) Init() error {
+	tag := language.MustParse(l.LangTag)
+	for _, m := range l.Messages {
+		if err := message.SetString(tag, m.ID, m.FormattedMessage); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (c *defaultMessageCatalog) GetMessage(ID string, tag language.Tag, v ...interface{}) string {
+	matchedTag, _, _ := c.matcher.Match(tag)
+	p := message.NewPrinter(matchedTag)
+
+	result := p.Sprintf(ID, v...)
+	if result == ID && tag != language.English {
+		return c.GetMessage(ID, language.English, v...)
+	}
+
+	return result
+}
+
+func (c *defaultMessageCatalog) GetLangFromRequest(r *http.Request) language.Tag {
+	lang, _ := r.Cookie("lang")
+	accept := r.Header.Get("Accept-Language")
+	tag, _ := language.MatchStrings(c.matcher, lang.String(), accept)
+
+	return tag
+}
+
+func (c *defaultMessageCatalog) makeMatcher() {
+	result := []language.Tag{language.English}
+	defLangs := message.DefaultCatalog.Languages()
+	// remove "en" if was already in the list of languages
+	for i, t := range defLangs {
+		if t == language.English {
+			result = append(result, defLangs[:i]...)
+			result = append(result, defLangs[i+1:]...)
+		}
+	}
+
+	c.matcher = language.NewMatcher(defLangs)
+}
diff --git a/i18n/i18n.go b/i18n/i18n.go
new file mode 100644
index 0000000..acbc937
--- /dev/null
+++ b/i18n/i18n.go
@@ -0,0 +1,43 @@
+package i18n
+
+import (
+	"net/http"
+
+	"golang.org/x/text/language"
+)
+
+// MessageCatalog declares the interface to get globalized messages
+type MessageCatalog interface {
+	GetMessage(ID string, tag language.Tag, v ...interface{}) string
+	GetLangFromRequest(r *http.Request) language.Tag
+}
+
+// GetMessage is a helper func to get the translated message based on
+// the message ID and lang. If no matching message is found, it uses
+// ID as the message itself.
+func GetMessage(c MessageCatalog, ID string, tag language.Tag, v ...interface{}) string {
+	return GetMessageOrDefault(c, ID, tag, ID, v...)
+}
+
+// GetMessageOrDefault is a helper func to get the translated message based on
+// the message ID and lang. If no matching message is found, it returns the
+// 'def' message.
+func GetMessageOrDefault(c MessageCatalog, ID string, tag language.Tag, def string, v ...interface{}) string {
+	if c != nil {
+		if s := c.GetMessage(ID, tag, v...); s != ID {
+			return s
+		}
+	}
+
+	return def
+}
+
+// GetLangFromRequest is a helper func to get the language tag based on the
+// HTTP request and the constructed message catalog.
+func GetLangFromRequest(c MessageCatalog, r *http.Request) language.Tag {
+	if c != nil {
+		return c.GetLangFromRequest(r)
+	}
+
+	return language.English
+}
diff --git a/i18n_helper.go b/i18n_helper.go
new file mode 100644
index 0000000..9402155
--- /dev/null
+++ b/i18n_helper.go
@@ -0,0 +1,44 @@
+package fosite
+
+import (
+	"github.com/ory/fosite/i18n"
+	"github.com/ory/x/errorsx"
+	"github.com/pkg/errors"
+	"golang.org/x/text/language"
+)
+
+// AddLocalizerToErr augments the error object with the localizer
+// based on the language set in the requester object. This is primarily
+// required for response writers like introspection that do not take in
+// the requester in the Write* function that produces the translated
+// message.
+// See - WriteIntrospectionError, for example.
+func AddLocalizerToErr(catalog i18n.MessageCatalog, err error, requester Requester) error {
+	return AddLocalizerToErrWithLang(catalog, getLangFromRequester(requester), err)
+}
+
+// AddLocalizerToErrWithLang augments the error object with the localizer
+// based on the language passed in. This is primarily
+// required for response writers like introspection that do not take in
+// the requester in the Write* function that produces the translated
+// message.
+// See - WriteIntrospectionError, for example.
+func AddLocalizerToErrWithLang(catalog i18n.MessageCatalog, lang language.Tag, err error) error {
+	var e RFC6749Error
+	if errors.As(err, &e) {
+		return e.WithLocalizer(catalog, lang)
+	} else if errors.As(errorsx.Cause(err), &e) {
+		return e.WithLocalizer(catalog, lang)
+	}
+	return err
+}
+
+func getLangFromRequester(requester Requester) language.Tag {
+	lang := language.English
+	g11nContext, ok := requester.(G11NContext)
+	if ok {
+		lang = g11nContext.GetLang()
+	}
+
+	return lang
+}
diff --git a/introspection_request_handler.go b/introspection_request_handler.go
index 2dd896d..1183b9f 100644
--- a/introspection_request_handler.go
+++ b/introspection_request_handler.go
@@ -28,6 +28,7 @@ import (
 	"strings"
 
 	"github.com/ory/x/errorsx"
+	"golang.org/x/text/language"
 )
 
 // NewIntrospectionRequest initiates token introspection as defined in
@@ -183,6 +184,7 @@ type IntrospectionResponse struct {
 	AccessRequester AccessRequester `json:"extra"`
 	TokenUse        TokenUse        `json:"token_use,omitempty"`
 	AccessTokenType string          `json:"token_type,omitempty"`
+	Lang            language.Tag    `json:"-"`
 }
 
 func (r *IntrospectionResponse) IsActive() bool {
diff --git a/introspection_response_writer.go b/introspection_response_writer.go
index 6205f7e..95ec761 100644
--- a/introspection_response_writer.go
+++ b/introspection_response_writer.go
@@ -57,7 +57,7 @@ func (f *Fosite) WriteIntrospectionError(rw http.ResponseWriter, err error) {
 
 	// Inactive token errors should never written out as an error.
 	if !errors.Is(err, ErrInactiveToken) && (errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrRequestUnauthorized)) {
-		f.writeJsonError(rw, err)
+		f.writeJsonError(rw, nil, err)
 		return
 	}
 
diff --git a/oauth2.go b/oauth2.go
index 66316d8..9d5d6f3 100644
--- a/oauth2.go
+++ b/oauth2.go
@@ -26,6 +26,8 @@ import (
 	"net/http"
 	"net/url"
 	"time"
+
+	"golang.org/x/text/language"
 )
 
 type TokenUse = TokenType
@@ -325,3 +327,9 @@ type AuthorizeResponder interface {
 	// AddParameter adds key value pair to the response
 	AddParameter(key, value string)
 }
+
+// G11NContext is the globalization context
+type G11NContext interface {
+	// GetLang returns the current language in the context
+	GetLang() language.Tag
+}
diff --git a/request.go b/request.go
index 2a51b48..4e9f6a8 100644
--- a/request.go
+++ b/request.go
@@ -26,19 +26,21 @@ import (
 	"time"
 
 	"github.com/pborman/uuid"
+	"golang.org/x/text/language"
 )
 
 // Request is an implementation of Requester
 type Request struct {
-	ID                string     `json:"id" gorethink:"id"`
-	RequestedAt       time.Time  `json:"requestedAt" gorethink:"requestedAt"`
-	Client            Client     `json:"client" gorethink:"client"`
-	RequestedScope    Arguments  `json:"scopes" gorethink:"scopes"`
-	GrantedScope      Arguments  `json:"grantedScopes" gorethink:"grantedScopes"`
-	Form              url.Values `json:"form" gorethink:"form"`
-	Session           Session    `json:"session" gorethink:"session"`
-	RequestedAudience Arguments  `json:"requestedAudience"`
-	GrantedAudience   Arguments  `json:"grantedAudience"`
+	ID                string       `json:"id" gorethink:"id"`
+	RequestedAt       time.Time    `json:"requestedAt" gorethink:"requestedAt"`
+	Client            Client       `json:"client" gorethink:"client"`
+	RequestedScope    Arguments    `json:"scopes" gorethink:"scopes"`
+	GrantedScope      Arguments    `json:"grantedScopes" gorethink:"grantedScopes"`
+	Form              url.Values   `json:"form" gorethink:"form"`
+	Session           Session      `json:"session" gorethink:"session"`
+	RequestedAudience Arguments    `json:"requestedAudience"`
+	GrantedAudience   Arguments    `json:"grantedAudience"`
+	Lang              language.Tag `json:"-"`
 }
 
 func NewRequest() *Request {
@@ -193,3 +195,7 @@ func (a *Request) Sanitize(allowedParameters []string) Requester {
 
 	return b
 }
+
+func (a *Request) GetLang() language.Tag {
+	return a.Lang
+}
diff --git a/storage/memory.go b/storage/memory.go
index f27310f..8a2c1d0 100644
--- a/storage/memory.go
+++ b/storage/memory.go
@@ -165,6 +165,7 @@ func (s *MemoryStore) GetOpenIDConnectSession(_ context.Context, authorizeCode s
 	return cl, nil
 }
 
+// DeleteOpenIDConnectSession is not really called from anywhere and it is deprecated.
 func (s *MemoryStore) DeleteOpenIDConnectSession(_ context.Context, authorizeCode string) error {
 	s.idSessionsMutex.Lock()
 	defer s.idSessionsMutex.Unlock()


Test patch:
diff --git a/authorize_response_writer_test.go b/authorize_response_writer_test.go
index 650e3fa..9a7cf62 100644
--- a/authorize_response_writer_test.go
+++ b/authorize_response_writer_test.go
@@ -29,6 +29,7 @@ import (
 	"github.com/pkg/errors"
 	"github.com/stretchr/testify/assert"
 
+	"github.com/ory/fosite"
 	. "github.com/ory/fosite"
 	. "github.com/ory/fosite/internal"
 )
@@ -100,7 +101,7 @@ func TestNewAuthorizeResponse(t *testing.T) {
 				ar.EXPECT().GetResponseTypes().Return([]string{"token", "code"})
 			},
 			isErr:     true,
-			expectErr: ErrUnsupportedResponseMode.WithHintf("Insecure response_mode '%s' for the response_type '%s'.", ResponseModeQuery, []string{"token", "code"}),
+			expectErr: ErrUnsupportedResponseMode.WithHintf("Insecure response_mode '%s' for the response_type '%s'.", ResponseModeQuery, fosite.Arguments{"token", "code"}),
 		},
 	} {
 		c.mock()
diff --git a/errors_test.go b/errors_test.go
index 9f0a262..d0bf8f9 100644
--- a/errors_test.go
+++ b/errors_test.go
@@ -24,8 +24,10 @@ package fosite
 import (
 	"testing"
 
+	"github.com/ory/fosite/i18n"
 	"github.com/pkg/errors"
 	"github.com/stretchr/testify/assert"
+	"golang.org/x/text/language"
 )
 
 func TestRFC6749Error(t *testing.T) {
@@ -44,3 +46,62 @@ func TestRFC6749Error(t *testing.T) {
 		assert.Empty(t, wrap.StackTrace())
 	})
 }
+
+func TestErrorI18N(t *testing.T) {
+	catalog := i18n.NewDefaultMessageCatalog([]*i18n.DefaultLocaleBundle{
+		{
+			LangTag: "en",
+			Messages: []*i18n.DefaultMessage{
+				{
+					ID:               "access_denied",
+					FormattedMessage: "The resource owner or authorization server denied the request.",
+				},
+				{
+					ID:               "badRequestMethod",
+					FormattedMessage: "HTTP method is '%s', expected 'POST'.",
+				},
+			},
+		},
+		{
+			LangTag: "es",
+			Messages: []*i18n.DefaultMessage{
+				{
+					ID:               "access_denied",
+					FormattedMessage: "El propietario del recurso o el servidor de autorización denegó la solicitud.",
+				},
+				{
+					ID:               "HTTP method is '%s', expected 'POST'.",
+					FormattedMessage: "El método HTTP es '%s', esperado 'POST'.",
+				},
+				{
+					ID:               "Unable to parse HTTP body, make sure to send a properly formatted form request body.",
+					FormattedMessage: "No se puede analizar el cuerpo HTTP, asegúrese de enviar un cuerpo de solicitud de formulario con el formato adecuado.",
+				},
+				{
+					ID:               "badRequestMethod",
+					FormattedMessage: "El método HTTP es '%s', esperado 'POST'.",
+				},
+			},
+		},
+	})
+
+	t.Run("case=legacy", func(t *testing.T) {
+		err := ErrAccessDenied.WithLocalizer(catalog, language.Spanish).WithHintf("HTTP method is '%s', expected 'POST'.", "GET")
+		assert.EqualValues(t, "El propietario del recurso o el servidor de autorización denegó la solicitud. El método HTTP es 'GET', esperado 'POST'.", err.GetDescription())
+	})
+
+	t.Run("case=unsupported_locale_legacy", func(t *testing.T) {
+		err := ErrAccessDenied.WithLocalizer(catalog, language.Afrikaans).WithHintf("HTTP method is '%s', expected 'POST'.", "GET")
+		assert.EqualValues(t, "The resource owner or authorization server denied the request. HTTP method is 'GET', expected 'POST'.", err.GetDescription())
+	})
+
+	t.Run("case=simple", func(t *testing.T) {
+		err := ErrAccessDenied.WithLocalizer(catalog, language.Spanish).WithHintIDOrDefaultf("badRequestMethod", "HTTP method is '%s', expected 'POST'.", "GET")
+		assert.EqualValues(t, "El propietario del recurso o el servidor de autorización denegó la solicitud. El método HTTP es 'GET', esperado 'POST'.", err.GetDescription())
+	})
+
+	t.Run("case=unsupported_locale", func(t *testing.T) {
+		err := ErrAccessDenied.WithLocalizer(catalog, language.Afrikaans).WithHintIDOrDefaultf("badRequestMethod", "HTTP method is '%s', expected 'POST'.", "GET")
+		assert.EqualValues(t, "The resource owner or authorization server denied the request. HTTP method is 'GET', expected 'POST'.", err.GetDescription())
+	})
+}
diff --git a/i18n/i18n_test.go b/i18n/i18n_test.go
new file mode 100644
index 0000000..63db8e0
--- /dev/null
+++ b/i18n/i18n_test.go
@@ -0,0 +1,45 @@
+package i18n
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"golang.org/x/text/language"
+)
+
+func TestSimpleTranslation(t *testing.T) {
+	catalog := NewDefaultMessageCatalog([]*DefaultLocaleBundle{
+		{
+			LangTag: "en",
+			Messages: []*DefaultMessage{
+				{
+					ID:               "badRequestMethod",
+					FormattedMessage: "HTTP method is '%s', expected 'POST'.",
+				},
+				{
+					ID:               "badRequestBody",
+					FormattedMessage: "Unable to parse HTTP body, make sure to send a properly formatted form request body.",
+				},
+			},
+		},
+		{
+			LangTag: "es",
+			Messages: []*DefaultMessage{
+				{
+					ID:               "badRequestMethod",
+					FormattedMessage: "El método HTTP es '%s', esperado 'POST'.",
+				},
+				{
+					ID:               "badRequestBody",
+					FormattedMessage: "No se puede analizar el cuerpo HTTP, asegúrese de enviar un cuerpo de solicitud de formulario con el formato adecuado.",
+				},
+			},
+		},
+	})
+
+	msg := GetMessage(catalog, "badRequestMethod", language.Spanish, "GET")
+	assert.Equal(t, msg, "El método HTTP es 'GET', esperado 'POST'.")
+
+	msg = GetMessage(catalog, "badRequestBody", language.English, "GET")
+	assert.Equal(t, msg, "Unable to parse HTTP body, make sure to send a properly formatted form request body.")
+}
diff --git a/i18n_helper_test.go b/i18n_helper_test.go
new file mode 100644
index 0000000..9f5bbdd
--- /dev/null
+++ b/i18n_helper_test.go
@@ -0,0 +1,48 @@
+package fosite
+
+import (
+	"testing"
+
+	"github.com/ory/fosite/i18n"
+	"github.com/stretchr/testify/assert"
+	"golang.org/x/text/language"
+)
+
+func TestErrorTranslation(t *testing.T) {
+	catalog := i18n.NewDefaultMessageCatalog([]*i18n.DefaultLocaleBundle{
+		{
+			LangTag: "en",
+			Messages: []*i18n.DefaultMessage{
+				{
+					ID:               "badRequestMethod",
+					FormattedMessage: "HTTP method is '%s', expected 'POST'.",
+				},
+				{
+					ID:               "invalid_request",
+					FormattedMessage: "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.",
+				},
+			},
+		},
+		{
+			LangTag: "es",
+			Messages: []*i18n.DefaultMessage{
+				{
+					ID:               "badRequestMethod",
+					FormattedMessage: "El método HTTP es '%s', esperado 'POST'.",
+				},
+				{
+					ID:               "invalid_request",
+					FormattedMessage: "A la solicitud le falta un parámetro obligatorio, incluye un valor de parámetro no válido, incluye un parámetro más de una vez o tiene un formato incorrecto.",
+				},
+			},
+		},
+	})
+
+	errWithNoCatalog := ErrInvalidRequest.WithHintIDOrDefaultf("badRequestMethod", "HTTP method is '%s', expected 'POST'.", "GET")
+	errWithCatalog := errWithNoCatalog.WithLocalizer(catalog, language.Spanish)
+
+	assert.Equal(t, "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. HTTP method is 'GET', expected 'POST'.",
+		errWithNoCatalog.GetDescription(), "Message does not match when no catalog is specified")
+	assert.Equal(t, "A la solicitud le falta un parámetro obligatorio, incluye un valor de parámetro no válido, incluye un parámetro más de una vez o tiene un formato incorrecto. El método HTTP es 'GET', esperado 'POST'.",
+		errWithCatalog.GetDescription(), "Message does not match when catalog is specified")
+}