diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index 5232d76..5a6d1f1 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -4,6 +4,7 @@ package main import ( "context" + "crypto/tls" "log/slog" "net" "os" @@ -16,6 +17,7 @@ import ( "github.com/kleffio/idp-keycloak/internal/adapters/keycloak" "github.com/kleffio/idp-keycloak/internal/core/application" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" ) func main() { @@ -33,6 +35,7 @@ func main() { AdminUser: env("KEYCLOAK_ADMIN_USER", "admin"), AdminPassword: env("KEYCLOAK_ADMIN_PASSWORD", "admin"), AuthMode: env("AUTH_MODE", "headless"), + PanelURL: env("PANEL_URL", ""), }) // ── Ensure Keycloak realm is configured (retry until Keycloak is ready) ─── @@ -58,7 +61,21 @@ func main() { // ── Inbound adapter (gRPC) ───────────────────────────────────────────────── srv := grpcadapter.New(svc) - gs := grpc.NewServer() + var serverOpts []grpc.ServerOption + if certPEM := env("PLUGIN_TLS_CERT_PEM", ""); certPEM != "" { + keyPEM := env("PLUGIN_TLS_KEY_PEM", "") + cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) + if err != nil { + logger.Error("invalid TLS cert/key", "error", err) + os.Exit(1) + } + serverOpts = append(serverOpts, grpc.Creds(credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + }))) + logger.Info("gRPC server configured with mTLS") + } + + gs := grpc.NewServer(serverOpts...) pluginsv1.RegisterIdentityPluginServer(gs, srv) pluginsv1.RegisterPluginHealthServer(gs, srv) pluginsv1.RegisterPluginUIServer(gs, srv) diff --git a/internal/adapters/grpc/server.go b/internal/adapters/grpc/server.go index 77f2016..8f2dfbc 100644 --- a/internal/adapters/grpc/server.go +++ b/internal/adapters/grpc/server.go @@ -94,11 +94,14 @@ func (s *Server) GetOIDCConfig(_ context.Context, _ *pluginsv1.GetOIDCConfigRequ cfg := s.svc.OIDCConfig() return &pluginsv1.GetOIDCConfigResponse{ Config: &pluginsv1.OIDCConfig{ - Authority: cfg.Authority, - ClientID: cfg.ClientID, - JwksURI: cfg.JwksURI, - Scopes: []string{"openid", "profile", "email"}, - AuthMode: cfg.AuthMode, + Authority: cfg.Authority, + ClientID: cfg.ClientID, + JwksURI: cfg.JwksURI, + Scopes: []string{"openid", "profile", "email"}, + AuthMode: cfg.AuthMode, + TokenEndpoint: cfg.TokenEndpoint, + InternalTokenEndpoint: cfg.InternalTokenEndpoint, + EndSessionEndpoint: cfg.EndSessionEndpoint, }, }, nil } @@ -145,7 +148,7 @@ func (s *Server) ListSessions(ctx context.Context, req *pluginsv1.ListSessionsRe } func (s *Server) RevokeSession(ctx context.Context, req *pluginsv1.RevokeSessionRequest) (*pluginsv1.RevokeSessionResponse, error) { - if err := s.svc.RevokeSession(ctx, req.SessionID); err != nil { + if err := s.svc.RevokeSession(ctx, req.UserID, req.SessionID); err != nil { return &pluginsv1.RevokeSessionResponse{Error: toPluginError(err)}, nil } return &pluginsv1.RevokeSessionResponse{}, nil diff --git a/internal/adapters/keycloak/client.go b/internal/adapters/keycloak/client.go index c7b23b1..ca321ac 100644 --- a/internal/adapters/keycloak/client.go +++ b/internal/adapters/keycloak/client.go @@ -26,6 +26,7 @@ type Config struct { AdminUser string AdminPassword string AuthMode string // "headless" (default) or "redirect" + PanelURL string // public panel URL, e.g. "https://panel.example.com"; used to register the OIDC callback } // Client is the production implementation of ports.IDPProvider. @@ -177,12 +178,17 @@ func (c *Client) OIDCConfig() domain.OIDCConfig { if authMode == "" { authMode = "headless" } + internal := strings.TrimRight(c.cfg.BaseURL, "/") + base := fmt.Sprintf("%s/realms/%s/protocol/openid-connect", public, c.cfg.Realm) return domain.OIDCConfig{ - Authority: fmt.Sprintf("%s/realms/%s", public, c.cfg.Realm), - ClientID: c.cfg.ClientID, - JwksURI: fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", public, c.cfg.Realm), - Realm: c.cfg.Realm, - AuthMode: authMode, + Authority: fmt.Sprintf("%s/realms/%s", public, c.cfg.Realm), + ClientID: c.cfg.ClientID, + JwksURI: base + "/certs", + Realm: c.cfg.Realm, + AuthMode: authMode, + TokenEndpoint: base + "/token", + InternalTokenEndpoint: fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", internal, c.cfg.Realm), + EndSessionEndpoint: base + "/logout", } } @@ -320,14 +326,23 @@ func (c *Client) EnsureRealm(ctx context.Context) error { var clients []map[string]any _ = json.Unmarshal(body, &clients) + redirectURIs := []string{"http://localhost/callback"} + webOrigins := []string{"http://localhost"} + if panelURL := strings.TrimRight(c.cfg.PanelURL, "/"); panelURL != "" { + redirectURIs = append(redirectURIs, panelURL+"/auth/callback") + if u, err := url.Parse(panelURL); err == nil { + origin := u.Scheme + "://" + u.Host + webOrigins = append(webOrigins, origin) + } + } clientPayload := map[string]any{ "clientId": c.cfg.ClientID, "enabled": true, "publicClient": true, "directAccessGrantsEnabled": true, "standardFlowEnabled": true, - "redirectUris": []string{"*"}, - "webOrigins": []string{"*"}, + "redirectUris": redirectURIs, + "webOrigins": webOrigins, } if len(clients) == 0 { @@ -684,14 +699,45 @@ func (c *Client) ListSessions(ctx context.Context, userID string) ([]*domain.Ses return sessions, nil } -// RevokeSession revokes a specific session. -func (c *Client) RevokeSession(ctx context.Context, sessionID string) error { +// RevokeSession revokes a specific session after verifying it belongs to userID. +func (c *Client) RevokeSession(ctx context.Context, userID, sessionID string) error { tok, err := c.adminToken(ctx) if err != nil { return fmt.Errorf("revoke session: admin token: %w", err) } base := strings.TrimRight(c.cfg.BaseURL, "/") + + // Verify ownership: list the user's sessions and confirm sessionID is among them. + sessionsURL := fmt.Sprintf("%s/admin/realms/%s/users/%s/sessions", base, c.cfg.Realm, userID) + checkReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, sessionsURL, nil) + checkReq.Header.Set("Authorization", "Bearer "+tok) + checkResp, err := c.http.Do(checkReq) + if err != nil { + return fmt.Errorf("revoke session: list user sessions: %w", err) + } + checkBody, _ := io.ReadAll(checkResp.Body) + checkResp.Body.Close() + if checkResp.StatusCode != http.StatusOK { + return fmt.Errorf("revoke session: list user sessions: status %d", checkResp.StatusCode) + } + var userSessions []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(checkBody, &userSessions); err != nil { + return fmt.Errorf("revoke session: decode user sessions: %w", err) + } + owned := false + for _, s := range userSessions { + if s.ID == sessionID { + owned = true + break + } + } + if !owned { + return &domain.ErrUnauthorized{Msg: "session does not belong to user"} + } + deleteURL := fmt.Sprintf("%s/admin/realms/%s/sessions/%s", base, c.cfg.Realm, sessionID) req, _ := http.NewRequestWithContext(ctx, http.MethodDelete, deleteURL, nil) req.Header.Set("Authorization", "Bearer "+tok) diff --git a/internal/core/application/service.go b/internal/core/application/service.go index 1dd9225..a48b9a2 100644 --- a/internal/core/application/service.go +++ b/internal/core/application/service.go @@ -60,8 +60,8 @@ func (s *Service) ListSessions(ctx context.Context, userID string) ([]*domain.Se return s.provider.ListSessions(ctx, userID) } -// RevokeSession revokes a specific session. -func (s *Service) RevokeSession(ctx context.Context, sessionID string) error { - return s.provider.RevokeSession(ctx, sessionID) +// RevokeSession revokes a specific session after verifying ownership. +func (s *Service) RevokeSession(ctx context.Context, userID, sessionID string) error { + return s.provider.RevokeSession(ctx, userID, sessionID) } diff --git a/internal/core/domain/types.go b/internal/core/domain/types.go index 3c4194e..abbdadf 100644 --- a/internal/core/domain/types.go +++ b/internal/core/domain/types.go @@ -22,11 +22,14 @@ type TokenClaims struct { // OIDCConfig holds the OIDC discovery parameters the frontend needs to bootstrap. type OIDCConfig struct { - Authority string // browser-reachable issuer URL - ClientID string - JwksURI string - Realm string // Keycloak realm name, used to derive admin console URL - AuthMode string // "headless" (default) or "redirect" + Authority string // browser-reachable issuer URL + ClientID string + JwksURI string + Realm string // Keycloak realm name, used to derive admin console URL + AuthMode string // "headless" (default) or "redirect" + TokenEndpoint string // public token endpoint (browser-reachable) + InternalTokenEndpoint string // Docker-internal token endpoint for server-side proxy + EndSessionEndpoint string // public end-session (logout) endpoint } // RegisterRequest holds the fields required to create a new user. diff --git a/internal/core/ports/provider.go b/internal/core/ports/provider.go index ab66a5e..8354f9d 100644 --- a/internal/core/ports/provider.go +++ b/internal/core/ports/provider.go @@ -42,5 +42,6 @@ type IDPProvider interface { ListSessions(ctx context.Context, userID string) ([]*domain.Session, error) // RevokeSession revokes a specific session. - RevokeSession(ctx context.Context, sessionID string) error + // userID is verified to own the session before deletion. + RevokeSession(ctx context.Context, userID, sessionID string) error }