Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion cmd/plugin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main

import (
"context"
"crypto/tls"
"log/slog"
"net"
"os"
Expand All @@ -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() {
Expand All @@ -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) ───
Expand All @@ -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)
Expand Down
15 changes: 9 additions & 6 deletions internal/adapters/grpc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
64 changes: 55 additions & 9 deletions internal/adapters/keycloak/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions internal/core/application/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

13 changes: 8 additions & 5 deletions internal/core/domain/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion internal/core/ports/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading