Authn: Add client for api keys (#60339)

* AuthN: Add functionallity to test if auth client should be used

* AuthN: Add bolierplate client for api keys and register it

* AuthN: Add tests for api key client

* Inject service

* AuthN: Update client names

* ContextHandler: Set authn service

* AuthN: Implement authentication for api key client

* ContextHandler: Use authn service for api keys if flag is enabled

* AuthN: refactor authentication method to return additional value to
indicate if client could perform authentication

* update prefixes

* Add namespaced id to identity

* AuthN: Expand the Identity struct to include required fields from signed
in user

* Add error for disabled service account

* Add function to write error response based on errutil.Error

* Add error to log

* Return errors based on errutil.Error

* pass error

* update log message

* Fix namespaced ids

* Add tests

* Lint
This commit is contained in:
Karl Persson 2022-12-19 09:22:11 +01:00 committed by GitHub
parent cc4d18f626
commit 2e53a58bc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 572 additions and 36 deletions

View File

@ -3,43 +3,115 @@ package authn
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
)
const (
ClientAnonymous = "auth.anonymous"
ClientAPIKey = "auth.client.api-key" // #nosec G101
ClientAnonymous = "auth.client.anonymous"
)
type Service interface {
Authenticate(ctx context.Context, client string, r *Request) (*Identity, error)
// Authenticate is used to authenticate using a specific client
Authenticate(ctx context.Context, client string, r *Request) (*Identity, bool, error)
}
type Client interface {
// Authenticate performs the authentication for the request
Authenticate(ctx context.Context, r *Request) (*Identity, error)
// Test should return true if client can be used to authenticate request
Test(ctx context.Context, r *Request) bool
}
type Request struct {
HTTPRequest *http.Request
}
const (
APIKeyIDPrefix = "api-key:"
ServiceAccountIDPrefix = "service-account:"
)
type Identity struct {
ID string
OrgID int64
OrgCount int
OrgName string
IsAnonymous bool
OrgRoles map[int64]org.RoleType
Login string
Name string
Email string
AuthID string
AuthModule string
IsGrafanaAdmin bool
IsDisabled bool
HelpFlags1 user.HelpFlags1
LastSeenAt time.Time
Teams []int64
}
func (i *Identity) Role() org.RoleType {
return i.OrgRoles[i.OrgID]
}
// IsAnonymous will return true if no ID is set on the identity
func (i *Identity) IsAnonymous() bool {
return i.ID == ""
}
// SignedInUser is used to translate Identity into SignedInUser struct
func (i *Identity) SignedInUser() *user.SignedInUser {
return &user.SignedInUser{
u := &user.SignedInUser{
UserID: 0,
OrgID: i.OrgID,
OrgName: i.OrgName,
OrgRole: i.Role(),
IsAnonymous: i.IsAnonymous,
ExternalAuthModule: i.AuthModule,
ExternalAuthID: i.AuthID,
Login: i.Login,
Name: i.Name,
Email: i.Email,
OrgCount: i.OrgCount,
IsGrafanaAdmin: i.IsGrafanaAdmin,
IsAnonymous: i.IsAnonymous(),
IsDisabled: i.IsDisabled,
HelpFlags1: i.HelpFlags1,
LastSeenAt: i.LastSeenAt,
Teams: i.Teams,
}
// For now, we need to set different fields of the signed-in user based on the identity "type"
if strings.HasPrefix(i.ID, APIKeyIDPrefix) {
id, _ := strconv.ParseInt(strings.TrimPrefix(i.ID, APIKeyIDPrefix), 10, 64)
u.ApiKeyID = id
} else if strings.HasPrefix(i.ID, ServiceAccountIDPrefix) {
id, _ := strconv.ParseInt(strings.TrimPrefix(i.ID, ServiceAccountIDPrefix), 10, 64)
u.UserID = id
u.IsServiceAccount = true
}
return u
}
func IdentityFromSignedInUser(id string, usr *user.SignedInUser) *Identity {
return &Identity{
ID: id,
OrgID: usr.OrgID,
OrgName: usr.OrgName,
OrgRoles: map[int64]org.RoleType{usr.OrgID: usr.OrgRole},
Login: usr.Login,
Name: usr.Name,
Email: usr.Email,
OrgCount: usr.OrgCount,
IsGrafanaAdmin: usr.IsGrafanaAdmin,
IsDisabled: usr.IsDisabled,
HelpFlags1: usr.HelpFlags1,
LastSeenAt: usr.LastSeenAt,
Teams: usr.Teams,
}
}

View File

@ -5,23 +5,28 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/clients"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"go.opentelemetry.io/otel/attribute"
)
var _ authn.Service = new(Service)
func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, orgService org.Service) *Service {
func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, orgService org.Service, apikeyService apikey.Service, userService user.Service) *Service {
s := &Service{
log: log.New("authn.service"),
cfg: cfg,
clients: make(map[string]authn.Client),
tracer: tracer,
userService: userService,
}
s.clients[authn.ClientAPIKey] = clients.ProvideAPIKey(apikeyService, userService)
if s.cfg.AnonymousEnabled {
s.clients[authn.ClientAnonymous] = clients.ProvideAnonymous(cfg, orgService)
}
@ -35,19 +40,34 @@ type Service struct {
clients map[string]authn.Client
tracer tracing.Tracer
userService user.Service
}
func (s *Service) Authenticate(ctx context.Context, clientName string, r *authn.Request) (*authn.Identity, error) {
func (s *Service) Authenticate(ctx context.Context, client string, r *authn.Request) (*authn.Identity, bool, error) {
ctx, span := s.tracer.Start(ctx, "authn.Authenticate")
defer span.End()
span.SetAttributes("authn.client", clientName, attribute.Key("authn.client").String(clientName))
span.SetAttributes("authn.client", client, attribute.Key("authn.client").String(client))
logger := s.log.FromContext(ctx)
client, ok := s.clients[clientName]
c, ok := s.clients[client]
if !ok {
s.log.FromContext(ctx).Warn("auth client not found", "client", clientName)
logger.Debug("auth client not found", "client", client)
span.AddEvents([]string{"message"}, []tracing.EventValue{{Str: "auth client is not configured"}})
return nil, authn.ErrClientNotFound
return nil, false, nil
}
if !c.Test(ctx, r) {
logger.Debug("auth client cannot handle request", "client", client)
span.AddEvents([]string{"message"}, []tracing.EventValue{{Str: "auth client cannot handle request"}})
return nil, false, nil
}
identity, err := c.Authenticate(ctx, r)
if err != nil {
logger.Warn("auth client could not authenticate request", "client", client, "error", err)
span.AddEvents([]string{"message"}, []tracing.EventValue{{Str: "auth client could not authenticate request"}})
return nil, true, err
}
// FIXME: We want to perform common authentication operations here.
@ -58,5 +78,6 @@ func (s *Service) Authenticate(ctx context.Context, clientName string, r *authn.
// login handler, but if we want to perform basic auth during a request (called from contexthandler) we don't
// want a session to be created.
return client.Authenticate(ctx, r)
logger.Debug("auth client successfully authenticated request", "client", client, "identity", identity)
return identity, true, nil
}

View File

@ -2,6 +2,7 @@ package authnimpl
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
@ -17,6 +18,7 @@ func TestService_Authenticate(t *testing.T) {
type TestCase struct {
desc string
clientName string
expectedOK bool
expectedErr error
}
@ -24,22 +26,35 @@ func TestService_Authenticate(t *testing.T) {
{
desc: "should succeed with authentication for configured client",
clientName: "fake",
expectedOK: true,
},
{
desc: "should fail when client is not configured",
desc: "should return false when client is not configured",
clientName: "gitlab",
expectedErr: authn.ErrClientNotFound,
expectedOK: false,
},
{
desc: "should return true and error when client could be used but failed to authenticate",
clientName: "fake",
expectedOK: true,
expectedErr: errors.New("some error"),
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
svc := setupTests(t, func(svc *Service) {
svc.clients["fake"] = &authntest.FakeClient{}
svc.clients["fake"] = &authntest.FakeClient{
ExpectedErr: tt.expectedErr,
ExpectedTest: tt.expectedOK,
}
})
_, err := svc.Authenticate(context.Background(), tt.clientName, &authn.Request{})
assert.ErrorIs(t, tt.expectedErr, err)
_, ok, err := svc.Authenticate(context.Background(), tt.clientName, &authn.Request{})
assert.Equal(t, tt.expectedOK, ok)
if tt.expectedErr != nil {
assert.Error(t, err)
}
})
}
}

View File

@ -14,9 +14,14 @@ var _ authn.Client = new(FakeClient)
type FakeClient struct {
ExpectedErr error
ExpectedTest bool
ExpectedIdentity *authn.Identity
}
func (f *FakeClient) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
return f.ExpectedIdentity, f.ExpectedErr
}
func (f *FakeClient) Test(ctx context.Context, r *authn.Request) bool {
return f.ExpectedTest
}

View File

@ -36,6 +36,10 @@ func (a *Anonymous) Authenticate(ctx context.Context, r *authn.Request) (*authn.
OrgID: o.ID,
OrgName: o.Name,
OrgRoles: map[int64]org.RoleType{o.ID: org.RoleType(a.cfg.AnonymousOrgRole)},
IsAnonymous: true,
}, nil
}
func (a *Anonymous) Test(ctx context.Context, r *authn.Request) bool {
// If anonymous client is register it can always be used for authentication
return true
}

View File

@ -56,7 +56,7 @@ func TestAnonymous_Authenticate(t *testing.T) {
} else {
require.Nil(t, err)
assert.Equal(t, true, identity.IsAnonymous)
assert.Equal(t, true, identity.ID == "")
assert.Equal(t, tt.org.ID, identity.OrgID)
assert.Equal(t, tt.org.Name, identity.OrgName)
assert.Equal(t, tt.cfg.AnonymousOrgRole, string(identity.Role()))

View File

@ -0,0 +1,180 @@
package clients
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/grafana/grafana/pkg/components/apikeygen"
apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/errutil"
)
const (
basicPrefix = "Basic "
bearerPrefix = "Bearer "
)
var (
ErrAPIKeyInvalid = errutil.NewBase(errutil.StatusUnauthorized, "api-key.invalid", errutil.WithPublicMessage("Invalid API key"))
ErrAPIKeyExpired = errutil.NewBase(errutil.StatusUnauthorized, "api-key.expired", errutil.WithPublicMessage("Expired API key"))
ErrAPIKeyRevoked = errutil.NewBase(errutil.StatusUnauthorized, "api-key.revoked", errutil.WithPublicMessage("Revoked API key"))
ErrServiceAccountDisabled = errutil.NewBase(errutil.StatusUnauthorized, "service-account.disabled", errutil.WithPublicMessage("Disabled service account"))
)
var _ authn.Client = new(APIKey)
func ProvideAPIKey(apiKeyService apikey.Service, userService user.Service) *APIKey {
return &APIKey{
log: log.New(authn.ClientAPIKey),
userService: userService,
apiKeyService: apiKeyService,
}
}
type APIKey struct {
log log.Logger
userService user.Service
apiKeyService apikey.Service
}
func (s *APIKey) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
apiKey, err := s.getAPIKey(ctx, getTokenFromRequest(r))
if err != nil {
if errors.Is(err, apikeygen.ErrInvalidApiKey) {
return nil, ErrAPIKeyInvalid.Errorf("API key is invalid")
}
return nil, err
}
if apiKey.Expires != nil && *apiKey.Expires <= time.Now().Unix() {
return nil, ErrAPIKeyExpired.Errorf("API key has expired")
}
if apiKey.IsRevoked != nil && *apiKey.IsRevoked {
return nil, ErrAPIKeyRevoked.Errorf("Api key is revoked")
}
go func(id int64) {
defer func() {
if err := recover(); err != nil {
s.log.Error("api key authentication panic", "err", err)
}
}()
if err := s.apiKeyService.UpdateAPIKeyLastUsedDate(context.Background(), id); err != nil {
s.log.Warn("failed to update last use date for api key", "id", id)
}
}(apiKey.Id)
// if the api key don't belong to a service account construct the identity and return it
if apiKey.ServiceAccountId == nil || *apiKey.ServiceAccountId < 1 {
return &authn.Identity{
ID: fmt.Sprintf("%s%d", authn.APIKeyIDPrefix, apiKey.Id),
OrgID: apiKey.OrgId,
OrgRoles: map[int64]org.RoleType{apiKey.OrgId: apiKey.Role},
}, nil
}
usr, err := s.userService.GetSignedInUserWithCacheCtx(ctx, &user.GetSignedInUserQuery{
UserID: *apiKey.ServiceAccountId,
OrgID: apiKey.OrgId,
})
if err != nil {
return nil, err
}
if usr.IsDisabled {
return nil, ErrServiceAccountDisabled.Errorf("Disabled service account")
}
return authn.IdentityFromSignedInUser(fmt.Sprintf("%s%d", authn.ServiceAccountIDPrefix, *apiKey.ServiceAccountId), usr), nil
}
func (s *APIKey) getAPIKey(ctx context.Context, token string) (*apikey.APIKey, error) {
fn := s.getFromToken
if !strings.HasPrefix(token, apikeygenprefix.GrafanaPrefix) {
fn = s.getFromTokenLegacy
}
apiKey, err := fn(ctx, token)
if err != nil {
return nil, err
}
return apiKey, nil
}
func (s *APIKey) getFromToken(ctx context.Context, token string) (*apikey.APIKey, error) {
decoded, err := apikeygenprefix.Decode(token)
if err != nil {
return nil, err
}
hash, err := decoded.Hash()
if err != nil {
return nil, err
}
return s.apiKeyService.GetAPIKeyByHash(ctx, hash)
}
func (s *APIKey) getFromTokenLegacy(ctx context.Context, token string) (*apikey.APIKey, error) {
decoded, err := apikeygen.Decode(token)
if err != nil {
return nil, err
}
// fetch key
keyQuery := apikey.GetByNameQuery{KeyName: decoded.Name, OrgId: decoded.OrgId}
if err := s.apiKeyService.GetApiKeyByName(ctx, &keyQuery); err != nil {
return nil, err
}
// validate api key
isValid, err := apikeygen.IsValid(decoded, keyQuery.Result.Key)
if err != nil {
return nil, err
}
if !isValid {
return nil, apikeygen.ErrInvalidApiKey
}
return keyQuery.Result, nil
}
func (s *APIKey) Test(ctx context.Context, r *authn.Request) bool {
return looksLikeApiKey(getTokenFromRequest(r))
}
func looksLikeApiKey(token string) bool {
return token != ""
}
func getTokenFromRequest(r *authn.Request) string {
// api keys are only supported through http requests
if r.HTTPRequest == nil {
return ""
}
header := r.HTTPRequest.Header.Get("Authorization")
if strings.HasPrefix(header, bearerPrefix) {
return strings.TrimPrefix(header, bearerPrefix)
}
if strings.HasPrefix(header, basicPrefix) {
username, password, err := util.DecodeBasicAuthHeader(header)
if err == nil && username == "api_key" {
return password
}
}
return ""
}

View File

@ -0,0 +1,207 @@
package clients
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"testing"
"github.com/grafana/grafana/pkg/components/apikeygen"
apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/apikey/apikeytest"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/stretchr/testify/assert"
)
var (
revoked = true
secret, hash = genApiKey(false)
)
func TestAPIKey_Authenticate(t *testing.T) {
type TestCase struct {
desc string
req *authn.Request
expectedKey *apikey.APIKey
expectedUser *user.SignedInUser
expectedErr error
expectedIdentity *authn.Identity
}
tests := []TestCase{
{
desc: "should success for valid token that is not connected to a service account",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{
"Authorization": {"Bearer " + secret},
},
}},
expectedKey: &apikey.APIKey{
Id: 1,
OrgId: 1,
Key: hash,
Role: org.RoleAdmin,
},
expectedIdentity: &authn.Identity{
ID: "api-key:1",
OrgID: 1,
OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin},
},
},
{
desc: "should success for valid token that is connected to service account",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{
"Authorization": {"Bearer " + secret},
},
}},
expectedKey: &apikey.APIKey{
Id: 1,
OrgId: 1,
Key: hash,
ServiceAccountId: intPtr(1),
},
expectedUser: &user.SignedInUser{
UserID: 1,
OrgID: 1,
IsServiceAccount: true,
OrgCount: 1,
OrgRole: org.RoleViewer,
Name: "test",
},
expectedIdentity: &authn.Identity{
ID: "service-account:1",
OrgID: 1,
OrgCount: 1,
Name: "test",
OrgRoles: map[int64]org.RoleType{1: org.RoleViewer},
},
},
{
desc: "should fail for expired api key",
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{"Authorization": {"Bearer " + secret}}}},
expectedKey: &apikey.APIKey{
Key: hash,
Expires: intPtr(0),
},
expectedErr: ErrAPIKeyExpired,
},
{
desc: "should fail for revoked api key",
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{"Authorization": {"Bearer " + secret}}}},
expectedKey: &apikey.APIKey{
Key: hash,
IsRevoked: &revoked,
},
expectedErr: ErrAPIKeyRevoked,
},
{
desc: "should fail if service account is disabled",
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{"Authorization": {"Bearer " + secret}}}},
expectedKey: &apikey.APIKey{
Key: hash,
ServiceAccountId: intPtr(1),
},
expectedUser: &user.SignedInUser{IsDisabled: true},
expectedErr: ErrServiceAccountDisabled,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
c := ProvideAPIKey(&apikeytest.Service{
ExpectedAPIKey: tt.expectedKey,
}, &usertest.FakeUserService{
ExpectedSignedInUser: tt.expectedUser,
})
identity, err := c.Authenticate(context.Background(), tt.req)
if tt.expectedErr != nil {
assert.Nil(t, identity)
assert.ErrorIs(t, err, tt.expectedErr)
} else {
assert.NoError(t, err)
assert.Equal(t, *tt.expectedIdentity, *identity)
}
})
}
}
func TestAPIKey_Test(t *testing.T) {
type TestCase struct {
desc string
req *authn.Request
expected bool
}
tests := []TestCase{
{
desc: "should succeed when api key is provided in Authorization header as bearer token",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{
"Authorization": {"Bearer 123123"},
},
}},
expected: true,
},
{
desc: "should succeed when api key is provided in Authorization header as basic auth and api_key as username",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{
"Authorization": {encodeBasicAuth("api_key", "test")},
},
}},
expected: true,
},
{
desc: "should fail when no http request is passed",
req: &authn.Request{},
expected: false,
},
{
desc: "should fail when no there is no Authorization header",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{},
}},
expected: false,
},
{
desc: "should fail when Authorization header is not prefixed with Basic or Bearer",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{
"Authorization": {"test"},
},
}},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
c := ProvideAPIKey(&apikeytest.Service{}, usertest.NewUserServiceFake())
assert.Equal(t, tt.expected, c.Test(context.Background(), tt.req))
})
}
}
func intPtr(n int64) *int64 {
return &n
}
func genApiKey(legacy bool) (string, string) {
if legacy {
res, _ := apikeygen.New(1, "test")
return res.ClientSecret, res.HashedKey
}
res, _ := apikeygenprefix.New("test")
return res.ClientSecret, res.HashedKey
}
func encodeBasicAuth(username, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))
}

View File

@ -34,6 +34,7 @@ import (
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/grafana/grafana/pkg/web"
)
@ -68,6 +69,7 @@ func ProvideService(cfg *setting.Cfg, tokenService auth.UserTokenService, jwtSer
orgService: orgService,
oauthTokenService: oauthTokenService,
features: features,
authnService: authnService,
}
}
@ -200,8 +202,8 @@ func (h *ContextHandler) initContextWithAnonymousUser(reqContext *models.ReqCont
defer span.End()
if h.features.IsEnabled(featuremgmt.FlagAuthnService) {
identity, err := h.authnService.Authenticate(ctx, authn.ClientAnonymous, &authn.Request{HTTPRequest: reqContext.Req})
if err != nil {
identity, ok, err := h.authnService.Authenticate(ctx, authn.ClientAnonymous, &authn.Request{HTTPRequest: reqContext.Req})
if !ok || err != nil {
return false
}
reqContext.SignedInUser = identity.SignedInUser()
@ -271,6 +273,26 @@ func (h *ContextHandler) getAPIKey(ctx context.Context, keyString string) (*apik
}
func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bool {
if h.features.IsEnabled(featuremgmt.FlagAuthnService) {
identity, ok, err := h.authnService.Authenticate(reqContext.Req.Context(), authn.ClientAPIKey, &authn.Request{HTTPRequest: reqContext.Req})
if !ok {
return false
}
// include auth header in context
ctx := WithAuthHTTPHeader(reqContext.Req.Context(), "Authorization")
*reqContext.Req = *reqContext.Req.WithContext(ctx)
if err != nil {
writeErr(reqContext, err)
return true
}
reqContext.IsSignedIn = true
reqContext.SignedInUser = identity.SignedInUser()
return true
}
header := reqContext.Req.Header.Get("Authorization")
parts := strings.SplitN(header, " ", 2)
var keyString string
@ -709,6 +731,16 @@ func (h *ContextHandler) initContextWithAuthProxy(reqContext *models.ReqContext,
return true
}
// writeErr will write error response based on errutil.Error.
func writeErr(c *models.ReqContext, err error) {
grfErr := &errutil.Error{}
if !errors.As(err, grfErr) {
c.JsonApiErr(http.StatusInternalServerError, "", err)
return
}
c.JsonApiErr(grfErr.Reason.Status().HTTPStatus(), grfErr.Public().Message, err)
}
type authHTTPHeaderListContextKey struct{}
var authHTTPHeaderListKey = authHTTPHeaderListContextKey{}