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:
co-authored by
Gabriel MABILLE
parent
eb490193b9
commit
8cb351e54a
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user