mirror of https://github.com/dexidp/dex.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
433 lines
16 KiB
433 lines
16 KiB
// Package ssh implements a connector that authenticates using SSH keys |
|
package ssh |
|
|
|
import ( |
|
"context" |
|
"encoding/base64" |
|
"errors" |
|
"fmt" |
|
"log/slog" |
|
"net/http" |
|
"time" |
|
|
|
"github.com/golang-jwt/jwt/v5" |
|
"golang.org/x/crypto/ssh" |
|
|
|
"github.com/dexidp/dex/connector" |
|
) |
|
|
|
// Config holds the configuration for the SSH connector. |
|
type Config struct { |
|
// Users maps usernames to their SSH key configuration and user information |
|
Users map[string]UserConfig `json:"users"` |
|
|
|
// AllowedIssuers specifies which JWT issuers are accepted |
|
AllowedIssuers []string `json:"allowed_issuers"` |
|
|
|
// DefaultGroups are assigned to all authenticated users |
|
DefaultGroups []string `json:"default_groups"` |
|
|
|
// TokenTTL specifies how long tokens are valid (in seconds, defaults to 3600 if 0) |
|
TokenTTL int `json:"token_ttl"` |
|
} |
|
|
|
// UserConfig contains a user's SSH keys and identity information. |
|
type UserConfig struct { |
|
// Keys is a list of SSH public keys authorized for this user. |
|
// Format: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample... user@host" |
|
// Note: Per SSH spec, the comment (user@host) part is optional |
|
Keys []string `json:"keys"` |
|
|
|
// UserInfo contains the user's identity information |
|
UserInfo `json:",inline"` |
|
} |
|
|
|
// UserInfo contains user identity information. |
|
type UserInfo struct { |
|
Username string `json:"username"` |
|
Email string `json:"email"` |
|
Groups []string `json:"groups"` |
|
FullName string `json:"full_name"` |
|
} |
|
|
|
// SSHConnector implements the Dex connector interface for SSH key authentication. |
|
type SSHConnector struct { |
|
config Config |
|
logger *slog.Logger |
|
} |
|
|
|
// Compile-time interface assertion to ensure SSHConnector implements Connector interface |
|
var _ connector.Connector = &SSHConnector{} |
|
|
|
// Open creates a new SSH connector. |
|
// Uses slog.Logger for compatibility with Dex v2.44.0+. |
|
func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { |
|
// Log SSH connector startup |
|
if logger != nil { |
|
logger.Info("SSH connector starting") |
|
} |
|
|
|
// Set default values if not configured |
|
config := *c |
|
if config.TokenTTL == 0 { |
|
config.TokenTTL = 3600 // Default to 1 hour |
|
} |
|
|
|
return &SSHConnector{ |
|
config: config, |
|
logger: logger, |
|
}, nil |
|
} |
|
|
|
// LoginURL returns the URL for SSH-based login. |
|
func (c *SSHConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { |
|
// For SSH authentication, we don't use a traditional login URL |
|
// Instead, clients directly present SSH-signed JWTs |
|
return fmt.Sprintf("%s?state=%s&ssh_auth=true", callbackURL, state), nil |
|
} |
|
|
|
// HandleCallback processes the SSH authentication callback. |
|
func (c *SSHConnector) HandleCallback(scopes connector.Scopes, r *http.Request) (identity connector.Identity, err error) { |
|
// Handle both SSH JWT directly and as authorization code |
|
var sshJWT string |
|
|
|
// First try direct SSH JWT parameter |
|
sshJWT = r.FormValue("ssh_jwt") |
|
|
|
// If not found, try as authorization code |
|
if sshJWT == "" { |
|
sshJWT = r.FormValue("code") |
|
} |
|
|
|
if sshJWT == "" { |
|
c.logAuditEvent("auth_attempt", "", "", "", "failed", "no SSH JWT or authorization code provided") |
|
return identity, errors.New("no SSH JWT or authorization code provided") |
|
} |
|
|
|
// Validate and extract identity - this will now work with Dex's standard token generation |
|
return c.validateSSHJWT(sshJWT) |
|
} |
|
|
|
// validateSSHJWT validates an SSH-signed JWT and extracts user identity. |
|
// SECURITY FIX: Now uses configured keys for verification instead of trusting keys from JWT claims. |
|
func (c *SSHConnector) validateSSHJWT(sshJWTString string) (connector.Identity, error) { |
|
// Register our custom SSH signing method for JWT parsing |
|
jwt.RegisterSigningMethod("SSH", func() jwt.SigningMethod { |
|
return &SSHSigningMethodServer{} |
|
}) |
|
|
|
// Parse JWT with secure verification - try all configured user keys |
|
token, verifiedUser, verifiedKey, err := c.parseAndVerifyJWTSecurely(sshJWTString) |
|
if err != nil { |
|
c.logAuditEvent("auth_attempt", "unknown", "unknown", "unknown", "failed", fmt.Sprintf("JWT parse error: %s", err.Error())) |
|
return connector.Identity{}, fmt.Errorf("failed to parse JWT: %w", err) |
|
} |
|
|
|
// Extract claims |
|
claims, ok := token.Claims.(jwt.MapClaims) |
|
if !ok { |
|
return connector.Identity{}, errors.New("invalid JWT claims format") |
|
} |
|
|
|
// Validate JWT claims (extracted for readability) |
|
sub, iss, err := c.validateJWTClaims(claims) |
|
if err != nil { |
|
keyFingerprint := ssh.FingerprintSHA256(verifiedKey) |
|
c.logAuditEvent("auth_attempt", sub, keyFingerprint, iss, "failed", err.Error()) |
|
return connector.Identity{}, err |
|
} |
|
|
|
// Use the verified user info (key was already verified during parsing) |
|
userInfo := c.config.Users[verifiedUser].UserInfo |
|
if userInfo.Username == "" { |
|
userInfo.Username = verifiedUser |
|
} |
|
|
|
// Build identity |
|
identity := connector.Identity{ |
|
UserID: userInfo.Username, |
|
Username: userInfo.Username, |
|
Email: userInfo.Email, |
|
EmailVerified: true, |
|
Groups: append(userInfo.Groups, c.config.DefaultGroups...), |
|
} |
|
|
|
// Log successful authentication with verified key fingerprint |
|
keyFingerprint := ssh.FingerprintSHA256(verifiedKey) |
|
c.logAuditEvent("auth_success", sub, keyFingerprint, iss, "success", fmt.Sprintf("user %s authenticated with key %s", sub, keyFingerprint)) |
|
|
|
return identity, nil |
|
} |
|
|
|
// parseAndVerifyJWTSecurely implements secure 2-pass JWT verification following jwt-ssh-agent pattern. |
|
// |
|
// CRITICAL SECURITY MODEL: |
|
// - JWT is just a packaging format - it contains NO trusted data until verification succeeds |
|
// - Trusted public keys and user mappings are configured separately in Dex by administrators |
|
// - Authentication (JWT signature verification) is separated from authorization (user/key mapping) |
|
// - This prevents key injection attacks where clients could embed their own verification keys |
|
// |
|
// Returns the parsed token, verified username, verified public key, and any error. |
|
func (c *SSHConnector) parseAndVerifyJWTSecurely(sshJWTString string) (*jwt.Token, string, ssh.PublicKey, error) { |
|
// PASS 1: Parse JWT structure without verification to extract claims |
|
// This is tricky - we need to get the subject to know which keys to try for verification, |
|
// but we're NOT ready to trust this data yet. The claims are UNTRUSTED until verification succeeds. |
|
parser := &jwt.Parser{} |
|
unverifiedToken, _, err := parser.ParseUnverified(sshJWTString, jwt.MapClaims{}) |
|
if err != nil { |
|
return nil, "", nil, fmt.Errorf("failed to parse JWT structure: %w", err) |
|
} |
|
|
|
// Extract the subject claim - this tells us which user is CLAIMING to authenticate |
|
// IMPORTANT: We do NOT trust this claim yet! It's just used to know which keys to try |
|
claims, ok := unverifiedToken.Claims.(jwt.MapClaims) |
|
if !ok { |
|
return nil, "", nil, errors.New("invalid claims format") |
|
} |
|
|
|
sub, ok := claims["sub"].(string) |
|
if !ok || sub == "" { |
|
return nil, "", nil, errors.New("missing or invalid sub claim") |
|
} |
|
|
|
// Now we have the subject from the JWT - i.e. the user trying to auth. |
|
// We still don't trust it though! It's only used to guide our verification attempts. |
|
|
|
// PASS 2: Try cryptographic verification against each configured public key |
|
// SECURITY CRITICAL: Only SSH keys explicitly configured in Dex by administrators can verify JWTs |
|
// This enforces the separation between authentication and authorization: |
|
// - Authentication: Cryptographic proof the client holds a private key |
|
// - Authorization: Administrative decision about which keys/users are allowed |
|
for username, userConfig := range c.config.Users { |
|
for _, authorizedKeyStr := range userConfig.Keys { |
|
// Parse the configured public key (trusted, set by administrators) |
|
publicKey, comment, options, rest, err := ssh.ParseAuthorizedKey([]byte(authorizedKeyStr)) |
|
_, _, _ = comment, options, rest // Explicitly ignore unused return values |
|
if err != nil { |
|
continue // Skip invalid keys |
|
} |
|
|
|
// Attempt cryptographic verification of JWT signature using this configured key |
|
// This proves the client holds the corresponding private key |
|
verifiedToken, err := jwt.Parse(sshJWTString, func(token *jwt.Token) (interface{}, error) { |
|
if token.Method.Alg() != "SSH" { |
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) |
|
} |
|
// Return the configured public key for verification - NOT any key from JWT claims |
|
return publicKey, nil |
|
}) |
|
|
|
if err == nil && verifiedToken.Valid { |
|
// SUCCESS: Cryptographic verification passed with a configured key! |
|
// NOW we can trust the JWT claims because we've proven: |
|
// 1. The JWT was signed by a private key corresponding to a configured public key |
|
// 2. The configured key belongs to this username (per administrator configuration) |
|
// 3. No key injection attack is possible (we never used keys from JWT claims) |
|
// |
|
// Return the username from our configuration (trusted), not from JWT claims |
|
return verifiedToken, username, publicKey, nil |
|
} |
|
} |
|
} |
|
|
|
return nil, "", nil, fmt.Errorf("no configured key could verify the JWT signature") |
|
} |
|
|
|
// validateJWTClaims validates the standard JWT claims (sub, aud, iss, exp, nbf). |
|
// Returns subject, issuer, and any validation error. |
|
func (c *SSHConnector) validateJWTClaims(claims jwt.MapClaims) (string, string, error) { |
|
// Validate required claims |
|
sub, ok := claims["sub"].(string) |
|
if !ok || sub == "" { |
|
return "", "", errors.New("missing or invalid sub claim") |
|
} |
|
|
|
aud, ok := claims["aud"].(string) |
|
if !ok || aud == "" { |
|
return sub, "", errors.New("missing or invalid aud claim") |
|
} |
|
|
|
iss, ok := claims["iss"].(string) |
|
if !ok || iss == "" { |
|
return sub, "", errors.New("missing or invalid iss claim") |
|
} |
|
|
|
// Validate audience - ensure this token is intended for our Dex instance |
|
if aud != "kubernetes" { |
|
return sub, iss, fmt.Errorf("invalid audience: %s", aud) |
|
} |
|
|
|
// Validate issuer |
|
if !c.isAllowedIssuer(iss) { |
|
return sub, iss, fmt.Errorf("invalid issuer: %s", iss) |
|
} |
|
|
|
// Validate expiration (critical security check) |
|
exp, ok := claims["exp"].(float64) |
|
if !ok { |
|
return sub, iss, errors.New("missing or invalid exp claim") |
|
} |
|
|
|
if time.Unix(int64(exp), 0).Before(time.Now()) { |
|
return sub, iss, errors.New("token has expired") |
|
} |
|
|
|
// Validate not before if present |
|
if nbfClaim, nbfOk := claims["nbf"].(float64); nbfOk { |
|
if time.Unix(int64(nbfClaim), 0).After(time.Now()) { |
|
return sub, iss, errors.New("token not yet valid") |
|
} |
|
} |
|
|
|
return sub, iss, nil |
|
} |
|
|
|
// findUserByUsernameAndKey finds a user by username and verifies the key is authorized. |
|
// This provides O(1) lookup performance instead of searching all users. |
|
// Supports both SSH fingerprints and full public key formats. |
|
func (c *SSHConnector) findUserByUsernameAndKey(username, keyFingerprint string) (UserInfo, error) { |
|
// First, check the new Users format (O(1) lookup) |
|
if userConfig, exists := c.config.Users[username]; exists { |
|
// Check if this key is authorized for this user |
|
for _, authorizedKey := range userConfig.Keys { |
|
if c.isKeyMatch(authorizedKey, keyFingerprint) { |
|
// Return the user info with username filled in if not already set |
|
userInfo := userConfig.UserInfo |
|
if userInfo.Username == "" { |
|
userInfo.Username = username |
|
} |
|
return userInfo, nil |
|
} |
|
} |
|
return UserInfo{}, fmt.Errorf("key %s not authorized for user %s", keyFingerprint, username) |
|
} |
|
|
|
return UserInfo{}, fmt.Errorf("user %s not found or key %s not authorized", username, keyFingerprint) |
|
} |
|
|
|
// isKeyMatch checks if an authorized key (from config) matches the presented key fingerprint. |
|
// Only supports full public key format in the config: |
|
// - Full public keys: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample... user@host" |
|
// Note: Per SSH spec, the comment (user@host) part is optional |
|
func (c *SSHConnector) isKeyMatch(authorizedKey, presentedKeyFingerprint string) bool { |
|
// Parse the authorized key as a full public key |
|
publicKey, comment, _, rest, err := ssh.ParseAuthorizedKey([]byte(authorizedKey)) |
|
_ = comment // Ignore comment |
|
_ = rest // Ignore rest |
|
if err != nil { |
|
// Invalid public key format |
|
c.logger.Warn("Invalid public key format in configuration", "key", authorizedKey, "error", err) |
|
return false |
|
} |
|
|
|
// Generate fingerprint from the public key and compare |
|
authorizedKeyFingerprint := ssh.FingerprintSHA256(publicKey) |
|
return authorizedKeyFingerprint == presentedKeyFingerprint |
|
} |
|
|
|
// isAllowedIssuer checks if the JWT issuer is allowed. |
|
func (c *SSHConnector) isAllowedIssuer(issuer string) bool { |
|
if len(c.config.AllowedIssuers) == 0 { |
|
return true // Allow all if none specified |
|
} |
|
|
|
for _, allowed := range c.config.AllowedIssuers { |
|
if issuer == allowed { |
|
return true |
|
} |
|
} |
|
|
|
return false |
|
} |
|
|
|
// SSHSigningMethodServer implements JWT signing method for server-side SSH verification. |
|
type SSHSigningMethodServer struct{} |
|
|
|
// Alg returns the signing method algorithm identifier. |
|
func (m *SSHSigningMethodServer) Alg() string { |
|
return "SSH" |
|
} |
|
|
|
// Sign is not implemented on server side (client-only operation). |
|
func (m *SSHSigningMethodServer) Sign(signingString string, key interface{}) ([]byte, error) { |
|
return nil, errors.New("SSH signing not supported on server side") |
|
} |
|
|
|
// Verify verifies the JWT signature using the SSH public key. |
|
func (m *SSHSigningMethodServer) Verify(signingString string, signature []byte, key interface{}) error { |
|
// Parse SSH public key |
|
publicKey, ok := key.(ssh.PublicKey) |
|
if !ok { |
|
return fmt.Errorf("SSH verification requires ssh.PublicKey, got %T", key) |
|
} |
|
|
|
// Decode the base64-encoded signature |
|
signatureStr := string(signature) |
|
signatureBytes, err := base64.StdEncoding.DecodeString(signatureStr) |
|
if err != nil { |
|
return fmt.Errorf("failed to decode signature: %w", err) |
|
} |
|
|
|
// For SSH signature verification, we need to construct the signature structure |
|
// The signature format follows SSH wire protocol |
|
sshSignature := &ssh.Signature{ |
|
Format: publicKey.Type(), // Use key type as format |
|
Blob: signatureBytes, |
|
} |
|
|
|
// Verify the signature |
|
err = publicKey.Verify([]byte(signingString), sshSignature) |
|
if err != nil { |
|
return fmt.Errorf("SSH signature verification failed: %w", err) |
|
} |
|
|
|
return nil |
|
} |
|
|
|
// logAuditEvent logs SSH authentication events for security auditing. |
|
// This provides comprehensive audit trails for SSH-based authentication attempts. |
|
func (c *SSHConnector) logAuditEvent(eventType, username, keyFingerprint, issuer, status, details string) { |
|
// Build structured log message |
|
logMsg := fmt.Sprintf("SSH_AUDIT: type=%s username=%s key=%s issuer=%s status=%s details=%q", |
|
eventType, username, keyFingerprint, issuer, status, details) |
|
|
|
// Use slog.Logger for audit logging |
|
if c.logger != nil { |
|
c.logger.Info(logMsg) |
|
} else { |
|
// Fallback: use standard output for audit logging |
|
// This ensures audit events are always logged even if logger is unavailable |
|
fmt.Printf("%s\n", logMsg) |
|
} |
|
} |
|
|
|
// TokenIdentity implements the TokenIdentityConnector interface. |
|
// This method validates an SSH JWT token and returns the user identity. |
|
func (c *SSHConnector) TokenIdentity(ctx context.Context, subjectTokenType, subjectToken string) (connector.Identity, error) { |
|
if c.logger != nil { |
|
c.logger.InfoContext(ctx, "TokenIdentity method called", "tokenType", subjectTokenType) |
|
} |
|
|
|
// Validate token type - accept standard OAuth2 JWT types |
|
switch subjectTokenType { |
|
case "ssh_jwt", "urn:ietf:params:oauth:token-type:jwt", "urn:ietf:params:oauth:token-type:access_token", "urn:ietf:params:oauth:token-type:id_token": |
|
// Supported token types |
|
default: |
|
return connector.Identity{}, fmt.Errorf("unsupported token type: %s", subjectTokenType) |
|
} |
|
|
|
// Use existing SSH JWT validation logic |
|
identity, err := c.validateSSHJWT(subjectToken) |
|
if err != nil { |
|
if c.logger != nil { |
|
// SSH agent trying multiple keys is normal behavior - log at debug level |
|
c.logger.DebugContext(ctx, "SSH JWT validation failed in TokenIdentity", "error", err) |
|
} |
|
return connector.Identity{}, fmt.Errorf("SSH JWT validation failed: %w", err) |
|
} |
|
|
|
if c.logger != nil { |
|
c.logger.InfoContext(ctx, "TokenIdentity successful", "user", identity.UserID) |
|
} |
|
return identity, nil |
|
}
|
|
|