Authn: Handle logout logic in auth broker (#79635)

* AuthN: Add new client extension interface that allows for custom logout logic

* AuthN: Add tests for oauth client logout

* Call authn.Logout

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
Karl Persson
2023-12-19 10:17:28 +01:00
committed by GitHub
co-authored by Gabriel MABILLE
parent eb490193b9
commit 8cb351e54a
8 changed files with 395 additions and 127 deletions
+64 -11
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/attribute"
@@ -19,6 +20,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authnimpl/sync"
"github.com/grafana/grafana/pkg/services/authn/clients"
@@ -73,15 +75,16 @@ func ProvideService(
signingKeysService signingkeys.Service, oauthServer oauthserver.OAuth2Server,
) *Service {
s := &Service{
log: log.New("authn.service"),
cfg: cfg,
clients: make(map[string]authn.Client),
clientQueue: newQueue[authn.ContextAwareClient](),
tracer: tracer,
metrics: newMetrics(registerer),
sessionService: sessionService,
postAuthHooks: newQueue[authn.PostAuthHookFn](),
postLoginHooks: newQueue[authn.PostLoginHookFn](),
log: log.New("authn.service"),
cfg: cfg,
clients: make(map[string]authn.Client),
clientQueue: newQueue[authn.ContextAwareClient](),
tracer: tracer,
metrics: newMetrics(registerer),
authInfoService: authInfoService,
sessionService: sessionService,
postAuthHooks: newQueue[authn.PostAuthHookFn](),
postLoginHooks: newQueue[authn.PostLoginHookFn](),
}
usageStats.RegisterMetricsFunc(s.getUsageStats)
@@ -146,7 +149,7 @@ func ProvideService(
if errConnector != nil || errHTTPClient != nil {
s.log.Error("Failed to configure oauth client", "client", clientName, "err", errors.Join(errConnector, errHTTPClient))
} else {
s.RegisterClient(clients.ProvideOAuth(clientName, cfg, oauthCfg, connector, httpClient))
s.RegisterClient(clients.ProvideOAuth(clientName, cfg, oauthCfg, connector, httpClient, oauthTokenService))
}
}
}
@@ -175,7 +178,8 @@ type Service struct {
tracer tracing.Tracer
metrics *metrics
sessionService auth.UserTokenService
authInfoService login.AuthInfoService
sessionService auth.UserTokenService
// postAuthHooks are called after a successful authentication. They can modify the identity.
postAuthHooks *queue[authn.PostAuthHookFn]
@@ -335,6 +339,55 @@ func (s *Service) RedirectURL(ctx context.Context, client string, r *authn.Reque
return redirectClient.RedirectURL(ctx, r)
}
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"}
namespace, id := user.GetNamespacedID()
if namespace != authn.NamespaceUser {
return redirect, nil
}
userID, err := identity.IntIdentifier(namespace, id)
if err != nil {
s.log.FromContext(ctx).Debug("Invalid user id", "id", userID, "err", err)
return redirect, nil
}
info, _ := s.authInfoService.GetAuthInfo(ctx, &login.GetAuthInfoQuery{UserId: userID})
if info != nil {
client := authn.ClientWithPrefix(strings.TrimPrefix(info.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, info)
if !ok {
goto Default
}
redirect = clientRedirect
}
Default:
if err = s.sessionService.RevokeToken(ctx, sessionToken, false); err != nil {
return nil, err
}
return redirect, nil
}
func (s *Service) RegisterClient(c authn.Client) {
s.clients[c.Name()] = c
if cac, ok := c.(authn.ContextAwareClient); ok {
@@ -13,10 +13,14 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models/usertoken"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/login/authinfotest"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -299,6 +303,95 @@ func TestService_RedirectURL(t *testing.T) {
}
}
func TestService_Logout(t *testing.T) {
type TestCase struct {
desc string
identity *authn.Identity
sessionToken *usertoken.UserToken
info *login.UserAuth
client authn.Client
expectedErr error
expectedTokenRevoked bool
expectedRedirect *authn.Redirect
}
tests := []TestCase{
{
desc: "should redirect to default redirect url when identity is not a user",
identity: &authn.Identity{ID: authn.NamespacedID(authn.NamespaceServiceAccount, 1)},
expectedRedirect: &authn.Redirect{URL: "http://localhost:3000/login"},
},
{
desc: "should redirect to default redirect url when no external provider was used to authenticate",
identity: &authn.Identity{ID: authn.NamespacedID(authn.NamespaceUser, 1)},
expectedRedirect: &authn.Redirect{URL: "http://localhost:3000/login"},
expectedTokenRevoked: true,
},
{
desc: "should redirect to default redirect url when client is not found",
identity: &authn.Identity{ID: authn.NamespacedID(authn.NamespaceUser, 1)},
info: &login.UserAuth{AuthModule: "notFound"},
expectedRedirect: &authn.Redirect{URL: "http://localhost:3000/login"},
expectedTokenRevoked: true,
},
{
desc: "should redirect to default redirect url when client do not implement logout extension",
identity: &authn.Identity{ID: authn.NamespacedID(authn.NamespaceUser, 1)},
info: &login.UserAuth{AuthModule: "azuread"},
expectedRedirect: &authn.Redirect{URL: "http://localhost:3000/login"},
client: &authntest.FakeClient{ExpectedName: "auth.client.azuread"},
expectedTokenRevoked: true,
},
{
desc: "should redirect to client specific url",
identity: &authn.Identity{ID: authn.NamespacedID(authn.NamespaceUser, 1)},
info: &login.UserAuth{AuthModule: "azuread"},
expectedRedirect: &authn.Redirect{URL: "http://idp.com/logout"},
client: &authntest.MockClient{
NameFunc: func() string { return "auth.client.azuread" },
LogoutFunc: func(ctx context.Context, _ identity.Requester, _ *login.UserAuth) (*authn.Redirect, bool) {
return &authn.Redirect{URL: "http://idp.com/logout"}, true
},
},
expectedTokenRevoked: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
var tokenRevoked bool
s := setupTests(t, func(svc *Service) {
if tt.client != nil {
svc.RegisterClient(tt.client)
}
svc.cfg.AppSubURL = "http://localhost:3000"
svc.authInfoService = &authinfotest.FakeService{
ExpectedUserAuth: tt.info,
}
svc.sessionService = &authtest.FakeUserAuthTokenService{
RevokeTokenProvider: func(_ context.Context, sessionToken *auth.UserToken, soft bool) error {
tokenRevoked = true
assert.EqualValues(t, tt.sessionToken, sessionToken)
assert.False(t, soft)
return nil
},
}
})
redirect, err := s.Logout(context.Background(), tt.identity, tt.sessionToken)
assert.ErrorIs(t, err, tt.expectedErr)
assert.EqualValues(t, tt.expectedRedirect, redirect)
assert.Equal(t, tt.expectedTokenRevoked, tokenRevoked)
})
}
}
func mustParseURL(s string) *url.URL {
u, err := url.Parse(s)
if err != nil {