package authnimpl import ( "context" "errors" "net/http" "strconv" "strings" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/network" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/clients" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) const ( attributeKeyClient = "authn.client" ) var ( errInvalidNamespace = errutil.Forbidden("authn.invalid-namespace", errutil.WithPublicMessage("invalid namespace")) errCantAuthenticateReq = errutil.Unauthorized("auth.unauthorized") errDisabledIdentity = errutil.Unauthorized("identity.disabled") ) // make sure service implements authn.Service interface func ProvideAuthnService(s *Service) authn.Service { return s } // make sure service also implements authn.ServiceAuthenticateOnly interface func ProvideAuthnServiceAuthenticateOnly(s *Service) authn.Authenticator { return s } // make sure service implements authn.IdentitySynchronizer interface func ProvideIdentitySynchronizer(s *Service) authn.IdentitySynchronizer { return s } func ProvideService( cfg *setting.Cfg, tracer tracing.Tracer, sessionService auth.UserTokenService, usageStats usagestats.Service, registerer prometheus.Registerer, authTokenService login.AuthInfoService, ) *Service { stackID, _ := strconv.ParseInt(cfg.StackID, 10, 64) s := &Service{ log: log.New("authn.service"), cfg: cfg, stackID: stackID, clients: make(map[string]authn.Client), clientQueue: newQueue[authn.ContextAwareClient](), idenityResolverClients: make(map[string]authn.IdentityResolverClient), tracer: tracer, metrics: newMetrics(registerer), sessionService: sessionService, authTokenService: authTokenService, preLogoutHooks: newQueue[authn.PreLogoutHookFn](), postAuthHooks: newQueue[authn.PostAuthHookFn](), postLoginHooks: newQueue[authn.PostLoginHookFn](), } usageStats.RegisterMetricsFunc(s.getUsageStats) return s } type Service struct { log log.Logger cfg *setting.Cfg stackID int64 clients map[string]authn.Client clientQueue *queue[authn.ContextAwareClient] idenityResolverClients map[string]authn.IdentityResolverClient tracer tracing.Tracer metrics *metrics sessionService auth.UserTokenService authTokenService login.AuthInfoService // postAuthHooks are called after a successful authentication. They can modify the identity. postAuthHooks *queue[authn.PostAuthHookFn] // postLoginHooks are called after a login request is performed, both for failing and successful requests. postLoginHooks *queue[authn.PostLoginHookFn] // preLogoutHooks are called before a logout request is performed. preLogoutHooks *queue[authn.PreLogoutHookFn] } func (s *Service) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { ctx, span := s.tracer.Start(ctx, "authn.Authenticate") defer span.End() orgID, err := s.orgIDFromRequest(r) if err != nil { return nil, err } r.OrgID = orgID var authErr error for _, item := range s.clientQueue.items { if item.v.Test(ctx, r) { identity, err := s.authenticate(ctx, item.v, r) if err != nil { // Note: special case for token rotation // We don't want to fallthrough in this case if errors.Is(err, authn.ErrTokenNeedsRotation) { return nil, err } authErr = errors.Join(authErr, err) // try next continue } if identity != nil { s.metrics.successfulAuth.WithLabelValues(item.v.Name()).Inc() return identity, nil } } } if authErr != nil { s.metrics.failedAuth.Inc() return nil, authErr } return nil, errCantAuthenticateReq.Errorf("cannot authenticate request") } func (s *Service) authenticate(ctx context.Context, c authn.Client, r *authn.Request) (*authn.Identity, error) { ctx, span := s.tracer.Start(ctx, "authn.authenticate") defer span.End() identity, err := c.Authenticate(ctx, r) if err != nil { span.SetStatus(codes.Error, "authenticate failed on client") span.RecordError(err) s.errorLogFunc(ctx, err)("Failed to authenticate request", "client", c.Name(), "error", err) return nil, err } span.SetAttributes( attribute.String("identity.ID", identity.GetID()), attribute.String("identity.AuthID", identity.GetAuthID()), attribute.String("identity.AuthenticatedBy", identity.GetAuthenticatedBy()), ) if len(identity.ClientParams.FetchPermissionsParams.RestrictedActions) > 0 { span.SetAttributes(attribute.StringSlice("identity.ClientParams.FetchPermissionsParams.RestrictedActions", identity.ClientParams.FetchPermissionsParams.RestrictedActions)) } if len(identity.ClientParams.FetchPermissionsParams.Roles) > 0 { span.SetAttributes(attribute.StringSlice("identity.ClientParams.FetchPermissionsParams.Roles", identity.ClientParams.FetchPermissionsParams.Roles)) } if len(identity.ClientParams.FetchPermissionsParams.AllowedActions) > 0 { span.SetAttributes(attribute.StringSlice("identity.ClientParams.FetchPermissionsParams.AllowedActions", identity.ClientParams.FetchPermissionsParams.AllowedActions)) } if err := s.runPostAuthHooks(ctx, identity, r); err != nil { s.errorLogFunc(ctx, err)("Failed to run post auth hook", "client", c.Name(), "id", identity.ID, "error", err) return nil, err } if identity.IsDisabled { return nil, errDisabledIdentity.Errorf("identity is disabled") } if hc, ok := c.(authn.HookClient); ok { if err := hc.Hook(ctx, identity, r); err != nil { s.errorLogFunc(ctx, err)("Failed to run post client auth hook", "client", c.Name(), "id", identity.ID, "error", err) return nil, err } } return identity, nil } func (s *Service) runPostAuthHooks(ctx context.Context, identity *authn.Identity, r *authn.Request) error { for _, hook := range s.postAuthHooks.items { if err := hook.v(ctx, identity, r); err != nil { return err } } return nil } func (s *Service) RegisterPostAuthHook(hook authn.PostAuthHookFn, priority uint) { s.postAuthHooks.insert(hook, priority) } func (s *Service) Login(ctx context.Context, client string, r *authn.Request) (id *authn.Identity, err error) { ctx, span := s.tracer.Start(ctx, "authn.Login", trace.WithAttributes( attribute.String(attributeKeyClient, client), )) defer span.End() orgID, err := s.orgIDFromRequest(r) if err != nil { return nil, err } r.OrgID = orgID defer func() { for _, hook := range s.postLoginHooks.items { hook.v(ctx, id, r, err) } }() c, ok := s.clients[client] if !ok { s.metrics.failedLogin.WithLabelValues(client).Inc() return nil, authn.ErrClientNotConfigured.Errorf("client not configured: %s", client) } r.SetMeta(authn.MetaKeyIsLogin, "true") id, err = s.authenticate(ctx, c, r) if err != nil { s.metrics.failedLogin.WithLabelValues(client).Inc() return nil, err } // Login is only supported for users if !id.IsIdentityType(types.TypeUser) { s.metrics.failedLogin.WithLabelValues(client).Inc() return nil, authn.ErrUnsupportedIdentity.Errorf("expected identity of type user but got: %s", id.GetIdentityType()) } userID, err := id.GetInternalID() if err != nil { return nil, err } addr := web.RemoteAddr(r.HTTPRequest) ip, err := network.GetIPFromAddress(addr) if err != nil { s.log.FromContext(ctx).Debug("Failed to parse ip from address", "client", c.Name(), "id", id.ID, "addr", addr, "error", err) } externalSession := s.resolveExternalSessionFromIdentity(ctx, id, userID) sessionToken, err := s.sessionService.CreateToken(ctx, &auth.CreateTokenCommand{User: &user.User{ID: userID}, ClientIP: ip, UserAgent: r.HTTPRequest.UserAgent(), ExternalSession: externalSession}) if err != nil { s.metrics.failedLogin.WithLabelValues(client).Inc() s.log.FromContext(ctx).Error("Failed to create session", "client", client, "id", id.ID, "err", err) return nil, err } s.metrics.successfulLogin.WithLabelValues(client).Inc() id.SessionToken = sessionToken return id, nil } func (s *Service) RegisterPostLoginHook(hook authn.PostLoginHookFn, priority uint) { s.postLoginHooks.insert(hook, priority) } func (s *Service) RedirectURL(ctx context.Context, client string, r *authn.Request) (*authn.Redirect, error) { ctx, span := s.tracer.Start(ctx, "authn.RedirectURL", trace.WithAttributes( attribute.String(attributeKeyClient, client), )) defer span.End() c, ok := s.clients[client] if !ok { return nil, authn.ErrClientNotConfigured.Errorf("client not configured: %s", client) } redirectClient, ok := c.(authn.RedirectClient) if !ok { return nil, authn.ErrUnsupportedClient.Errorf("client does not support generating redirect url: %s", client) } return redirectClient.RedirectURL(ctx, r) } func (s *Service) RegisterPreLogoutHook(hook authn.PreLogoutHookFn, priority uint) { s.preLogoutHooks.insert(hook, priority) } func (s *Service) Logout(ctx context.Context, user identity.Requester, sessionToken *auth.UserToken) (*authn.Redirect, error) { ctx, span := s.tracer.Start(ctx, "authn.Logout") defer span.End() redirect := &authn.Redirect{URL: s.cfg.AppSubURL + "/login"} if s.cfg.SignoutRedirectUrl != "" { redirect.URL = s.cfg.SignoutRedirectUrl } if !user.IsIdentityType(types.TypeUser) { return redirect, nil } id, err := user.GetInternalID() if err != nil { s.log.FromContext(ctx).Debug("Invalid user id", "id", id, "err", err) return redirect, nil } for _, hook := range s.preLogoutHooks.items { if err := hook.v(ctx, user, sessionToken); err != nil { s.log.Error("Failed to run pre logout hook. Skipping...", "error", err) } } if authModule := user.GetAuthenticatedBy(); authModule != "" { client := authn.ClientWithPrefix(strings.TrimPrefix(authModule, "oauth_")) c, ok := s.clients[client] if !ok { s.log.FromContext(ctx).Debug("No client configured for auth module", "client", client) goto Default } logoutClient, ok := c.(authn.LogoutClient) if !ok { s.log.FromContext(ctx).Debug("Client do not support specialized logout logic", "client", client) goto Default } clientRedirect, ok := logoutClient.Logout(ctx, user, sessionToken) if !ok { goto Default } redirect = clientRedirect } Default: if err = s.sessionService.RevokeToken(ctx, sessionToken, false); err != nil && !errors.Is(err, auth.ErrUserTokenNotFound) { return nil, err } return redirect, nil } func (s *Service) ResolveIdentity(ctx context.Context, orgID int64, typedID string) (*authn.Identity, error) { ctx, span := s.tracer.Start(ctx, "authn.ResolveIdentity") defer span.End() r := &authn.Request{} r.OrgID = orgID // hack to not update last seen r.SetMeta(authn.MetaKeyIsLogin, "true") identity, err := s.resolveIdenity(ctx, orgID, typedID) if err != nil { if errors.Is(err, types.ErrInvalidTypedID) { return nil, authn.ErrUnsupportedIdentity.Errorf("invalid identity type") } return nil, err } return s.authenticate(ctx, clients.ProvideIdentity(identity), r) } func (s *Service) RegisterClient(c authn.Client) { s.clients[c.Name()] = c if cac, ok := c.(authn.ContextAwareClient); ok { s.clientQueue.insert(cac, cac.Priority()) } if rc, ok := c.(authn.IdentityResolverClient); ok { s.idenityResolverClients[rc.IdentityType().String()] = rc } } func (s *Service) IsClientEnabled(name string) bool { client, ok := s.clients[name] if !ok { return false } return client.IsEnabled() } func (s *Service) GetClientConfig(name string) (authn.SSOClientConfig, bool) { client, ok := s.clients[name] if !ok { return nil, false } ssoSettingsAwareClient, ok := client.(authn.SSOSettingsAwareClient) if !ok { return nil, false } return ssoSettingsAwareClient.GetConfig(), true } func (s *Service) SyncIdentity(ctx context.Context, identity *authn.Identity) error { ctx, span := s.tracer.Start(ctx, "authn.SyncIdentity") defer span.End() r := &authn.Request{OrgID: identity.OrgID} // hack to not update last seen on external syncs r.SetMeta(authn.MetaKeyIsLogin, "true") return s.runPostAuthHooks(ctx, identity, r) } func (s *Service) resolveIdenity(ctx context.Context, orgID int64, typedID string) (*authn.Identity, error) { ctx, span := s.tracer.Start(ctx, "authn.resolveIdentity") defer span.End() t, i, err := types.ParseTypeID(typedID) if err != nil { return nil, err } if types.IsIdentityType(t, types.TypeUser) { return &authn.Identity{ OrgID: orgID, ID: i, Type: types.TypeUser, ClientParams: authn.ClientParams{ AllowGlobalOrg: true, FetchSyncedUser: true, SyncPermissions: true, }, }, nil } if types.IsIdentityType(t, types.TypeServiceAccount) { return &authn.Identity{ ID: i, Type: types.TypeServiceAccount, OrgID: orgID, ClientParams: authn.ClientParams{ AllowGlobalOrg: true, FetchSyncedUser: true, SyncPermissions: true, }, }, nil } resolver, ok := s.idenityResolverClients[string(t)] if !ok { return nil, authn.ErrUnsupportedIdentity.Errorf("no resolver for : %s", t) } return resolver.ResolveIdentity(ctx, orgID, t, i) } func (s *Service) errorLogFunc(ctx context.Context, err error) func(msg string, ctx ...any) { if errors.Is(err, context.Canceled) { return func(msg string, ctx ...any) {} } l := s.log.FromContext(ctx) var grfErr errutil.Error if errors.As(err, &grfErr) { return grfErr.LogLevel.LogFunc(l) } return l.Warn } func (s *Service) orgIDFromRequest(r *authn.Request) (int64, error) { if r.HTTPRequest == nil { return 0, nil } orgID, err := s.orgIDFromNamespace(r.HTTPRequest) if err != nil { return 0, err } if orgID > 0 { return orgID, nil } orgID = orgIDFromQuery(r.HTTPRequest) if orgID > 0 { return orgID, nil } return orgIDFromHeader(r.HTTPRequest), nil } func (s *Service) orgIDFromNamespace(req *http.Request) (int64, error) { if !strings.HasPrefix(req.URL.Path, "/apis") { return 0, nil } namespace := parseNamespace(req.URL.Path) if namespace == "" { return 0, nil } info, err := types.ParseNamespace(namespace) if err != nil { return 0, nil } if info.StackID != s.stackID { return 0, errInvalidNamespace.Errorf("invalid namespace") } return info.OrgID, nil } func parseNamespace(path string) string { // Pretty navie parsing of namespace but it should do the job // Possbile url paths can be found here: // https://github.com/kubernetes/kubernetes/blob/803e9d64952407981b3815b1d749cc96a39ba3c6/staging/src/k8s.io/apiserver/pkg/endpoints/request/requestinfo.go#L104-L127 const namespacePath = "/namespaces/" index := strings.Index(path, namespacePath) if index == -1 { return "" } parts := strings.Split(path[index+len(namespacePath):], "/") if len(parts) == 0 { return "" } return parts[0] } // name of query string used to target specific org for request const orgIDTargetQuery = "targetOrgId" func orgIDFromQuery(req *http.Request) int64 { params := req.URL.Query() if !params.Has(orgIDTargetQuery) { return 0 } id, err := strconv.ParseInt(params.Get(orgIDTargetQuery), 10, 64) if err != nil { return 0 } return id } // name of header containing org id for request const orgIDHeaderName = "X-Grafana-Org-Id" func orgIDFromHeader(req *http.Request) int64 { header := req.Header.Get(orgIDHeaderName) if header == "" { return 0 } id, err := strconv.ParseInt(header, 10, 64) if err != nil { return 0 } return id } func (s *Service) resolveExternalSessionFromIdentity(ctx context.Context, identity *authn.Identity, userID int64) *auth.ExternalSession { if identity.OAuthToken == nil && identity.SAMLSession == nil { return nil } info, err := s.authTokenService.GetAuthInfo(ctx, &login.GetAuthInfoQuery{AuthId: identity.GetAuthID(), UserId: userID}) if err != nil { s.log.FromContext(ctx).Info("Failed to get auth info", "error", err, "authID", identity.GetAuthID(), "userID", userID) return nil } extSession := &auth.ExternalSession{ AuthModule: identity.GetAuthenticatedBy(), UserAuthID: info.Id, UserID: userID, } if identity.OAuthToken != nil { extSession.AccessToken = identity.OAuthToken.AccessToken extSession.RefreshToken = identity.OAuthToken.RefreshToken extSession.ExpiresAt = identity.OAuthToken.Expiry if idToken, ok := identity.OAuthToken.Extra("id_token").(string); ok && idToken != "" { extSession.IDToken = idToken } // As of https://openid.net/specs/openid-connect-session-1_0.html if sessionState, ok := identity.OAuthToken.Extra("session_state").(string); ok && sessionState != "" { extSession.SessionID = sessionState } return extSession } extSession.SessionID = identity.SAMLSession.SessionIndex extSession.NameID = identity.SAMLSession.NameID return extSession }