// Package ssh implements a connector that authenticates using SSH keys package ssh import ( "context" "crypto/rand" "encoding/base64" "errors" "fmt" "log/slog" "net/http" "net/url" "strings" "sync" "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"` // DexInstanceID is the required audience value for JWT validation. // This ensures JWTs are created specifically for this Dex instance. // Example: "https://dex.example.com" or "dex-cluster-1" DexInstanceID string `json:"dex_instance_id"` // AllowedTargetAudiences specifies which target_audience values are accepted. // This controls what audiences can be requested for the final OIDC tokens. // For Kubernetes OIDC, this should typically be client IDs (e.g., "kubectl"). // If empty, any target_audience is allowed. AllowedTargetAudiences []string `json:"allowed_target_audiences"` // 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"` // ChallengeTTL specifies how long challenges are valid (in seconds, defaults to 300 if 0) ChallengeTTL int `json:"challenge_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 returned in OIDC tokens. // This information is configured by administrators and cannot be influenced by clients. UserInfo `json:",inline"` } // UserInfo contains user identity information for OIDC token claims. // All fields are configured administratively to prevent privilege escalation attacks. type UserInfo struct { Username string `json:"username"` Email string `json:"email"` Groups []string `json:"groups"` FullName string `json:"full_name"` } // Challenge represents a temporary SSH challenge for challenge/response authentication. // Challenges are single-use and expire after the configured ChallengeTTL (default 5 minutes) to prevent replay attacks. type Challenge struct { Data []byte Username string CreatedAt time.Time IsValid bool // True if username exists in config, false for enumeration prevention } // challengeStore holds temporary challenges with TTL type challengeStore struct { challenges map[string]*Challenge mutex sync.RWMutex ttl time.Duration } // rateLimiter prevents brute force user enumeration attacks type rateLimiter struct { attempts map[string][]time.Time mutex sync.RWMutex maxAttempts int window time.Duration } // newRateLimiter creates a rate limiter with cleanup func newRateLimiter(maxAttempts int, window time.Duration) (limiter *rateLimiter) { limiter = &rateLimiter{ attempts: make(map[string][]time.Time), maxAttempts: maxAttempts, window: window, } // Start cleanup goroutine go limiter.cleanup() return limiter } // isAllowed checks if an IP can make another attempt func (rl *rateLimiter) isAllowed(ip string) (allowed bool) { rl.mutex.Lock() defer rl.mutex.Unlock() now := time.Now() attemptTimes := rl.attempts[ip] // Remove old attempts outside the window var validAttempts []time.Time for _, attemptTime := range attemptTimes { if now.Sub(attemptTime) < rl.window { validAttempts = append(validAttempts, attemptTime) } } // Check if under limit if len(validAttempts) >= rl.maxAttempts { rl.attempts[ip] = validAttempts allowed = false return allowed } // Record this attempt validAttempts = append(validAttempts, now) rl.attempts[ip] = validAttempts allowed = true return allowed } // cleanup removes old rate limit entries func (rl *rateLimiter) cleanup() { ticker := time.NewTicker(time.Minute * 5) for range ticker.C { rl.mutex.Lock() now := time.Now() for ip, attempts := range rl.attempts { var validAttempts []time.Time for _, attemptTime := range attempts { if now.Sub(attemptTime) < rl.window { validAttempts = append(validAttempts, attemptTime) } } if len(validAttempts) == 0 { delete(rl.attempts, ip) } else { rl.attempts[ip] = validAttempts } } rl.mutex.Unlock() } } // newChallengeStore creates a new challenge store with cleanup func newChallengeStore(ttl time.Duration) (store *challengeStore) { store = &challengeStore{ challenges: make(map[string]*Challenge), ttl: ttl, } // Start cleanup goroutine go store.cleanup() return store } // store saves a challenge with expiration func (cs *challengeStore) store(id string, challenge *Challenge) { cs.mutex.Lock() defer cs.mutex.Unlock() cs.challenges[id] = challenge } // get retrieves and removes a challenge func (cs *challengeStore) get(id string) (challenge *Challenge, found bool) { cs.mutex.Lock() defer cs.mutex.Unlock() challenge, found = cs.challenges[id] if found { delete(cs.challenges, id) // One-time use } return challenge, found } // cleanup removes expired challenges func (cs *challengeStore) cleanup() { ticker := time.NewTicker(time.Minute) for range ticker.C { cs.mutex.Lock() now := time.Now() for id, challenge := range cs.challenges { if now.Sub(challenge.CreatedAt) > cs.ttl { delete(cs.challenges, id) } } cs.mutex.Unlock() } } // SSHConnector implements the Dex connector interface for SSH key authentication. // Supports both JWT-based authentication (TokenIdentityConnector) and // challenge/response authentication (CallbackConnector). type SSHConnector struct { config Config logger *slog.Logger challenges *challengeStore rateLimiter *rateLimiter } // Compile-time interface assertions var ( _ connector.Connector = &SSHConnector{} _ connector.TokenIdentityConnector = &SSHConnector{} _ connector.CallbackConnector = &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) (conn connector.Connector, err 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 } if config.ChallengeTTL == 0 { config.ChallengeTTL = 300 // Default to 5 minutes } conn = &SSHConnector{ config: config, logger: logger, challenges: newChallengeStore(time.Duration(config.ChallengeTTL) * time.Second), rateLimiter: newRateLimiter(10, time.Minute*5), // 10 attempts per 5 minutes per IP } return conn, err } // LoginURL generates the OAuth2 authorization URL for SSH authentication. // The implementation supports two authentication modes: // // 1. JWT-based authentication: Returns URL with ssh_auth=true parameter for clients // that will perform OAuth2 Token Exchange with SSH-signed JWTs // // 2. Challenge/response authentication: Generates cryptographic challenge when // ssh_challenge=true parameter is present, embeds challenge in callback URL // // The URL format follows standard OAuth2 authorization code flow patterns. // Clients determine the authentication mode via query parameters. func (c *SSHConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (loginURL string, connData []byte, err error) { // This method exists for interface compatibility but lacks request context // Rate limiting is not possible without HTTP request - log this limitation var parsedCallback *url.URL parsedCallback, err = url.Parse(callbackURL) if err != nil { err = fmt.Errorf("invalid callback URL: %w", err) return loginURL, connData, err } // If this is a challenge request without request context, we can't rate limit if parsedCallback.Query().Get("ssh_challenge") == "true" { username := parsedCallback.Query().Get("username") c.logAuditEvent("auth_attempt", username, "unknown", "challenge", "warning", "challenge request without rate limiting context") // Proceed without rate limiting (not ideal but maintains compatibility) loginURL, err = c.generateChallengeURL(callbackURL, state, username, "unknown") return loginURL, connData, err } // Default: JWT-based authentication (backward compatibility) // For JWT clients, return callback URL with SSH auth flag loginURL = fmt.Sprintf("%s?state=%s&ssh_auth=true", callbackURL, state) return loginURL, connData, err } // generateChallengeURL creates a callback URL with an embedded SSH challenge. // This method implements the challenge generation phase of challenge/response authentication. // // The process: // 1. Validates the requested username exists in configuration // 2. Generates cryptographically random challenge data // 3. Stores challenge temporarily with expiration // 4. Encodes challenge in base64 and embeds in callback URL // 5. Returns URL that clients can extract challenge from // // Security: Challenges are single-use and time-limited to prevent replay attacks. // User enumeration is prevented by validating usernames before challenge generation. func (c *SSHConnector) generateChallengeURL(callbackURL, state, username, clientIP string) (challengeURL string, err error) { // SECURITY: Rate limiting to prevent brute force user enumeration (skip if IP unknown) if clientIP != "unknown" && !c.rateLimiter.isAllowed(clientIP) { c.logAuditEvent("auth_attempt", username, "unknown", "challenge", "failed", fmt.Sprintf("rate limit exceeded for IP %s", clientIP)) challengeURL = "" err = errors.New("too many requests") return challengeURL, err } // SECURITY: Prevent user enumeration by always generating challenges // Valid and invalid users get identical responses - authentication fails later if username == "" { c.logAuditEvent("auth_attempt", "", "unknown", "challenge", "failed", "missing username in challenge request") challengeURL = "" err = errors.New("username required for challenge generation") return challengeURL, err } // Check if user exists, but DON'T change the response behavior userExists := false if _, exists := c.config.Users[username]; exists { userExists = exists } // ALWAYS generate cryptographic challenge (prevents timing attacks) challengeData := make([]byte, 32) if _, randErr := rand.Read(challengeData); randErr != nil { challengeURL = "" err = fmt.Errorf("failed to generate challenge: %w", randErr) return challengeURL, err } // Create unique challenge ID challengeID := base64.URLEncoding.EncodeToString(challengeData[:16]) // Store challenge with validity flag (prevents user enumeration) challenge := &Challenge{ Data: challengeData, Username: username, CreatedAt: time.Now(), IsValid: userExists, // This determines if auth will succeed later } c.challenges.store(challengeID, challenge) // Create callback URL with challenge embedded challengeB64 := base64.URLEncoding.EncodeToString(challengeData) stateWithChallenge := fmt.Sprintf("%s:%s", state, challengeID) // Parse the callback URL to handle existing query parameters properly var parsedCallback *url.URL parsedCallback, err = url.Parse(callbackURL) if err != nil { challengeURL = "" err = fmt.Errorf("invalid callback URL: %w", err) return challengeURL, err } // Add our parameters to the existing query values := parsedCallback.Query() values.Set("state", stateWithChallenge) values.Set("ssh_challenge", challengeB64) parsedCallback.RawQuery = values.Encode() // SECURITY: Always log success to prevent enumeration via logs // Real validation happens during signature verification c.logAuditEvent("challenge_generated", username, "unknown", "challenge", "success", "challenge generated successfully") challengeURL = parsedCallback.String() return challengeURL, err } // HandleCallback processes OAuth2 callbacks for SSH authentication. // This method implements the callback phase of the OAuth2 authorization code flow. // // The connector supports two distinct authentication flows: // // 1. JWT-based authentication: // - Clients provide SSH-signed JWTs as authorization codes // - JWTs are verified against administratively configured SSH keys // - Supports OAuth2 Token Exchange (RFC 8693) pattern // // 2. Challenge/response authentication: // - Clients provide signatures of previously issued challenges // - Signatures are verified against SSH keys for the claimed user // - Follows standard OAuth2 authorization code pattern // // Both flows result in connector.Identity objects containing user attributes // configured administratively, preventing client-controlled privilege escalation. func (c *SSHConnector) HandleCallback(scopes connector.Scopes, connData []byte, r *http.Request) (identity connector.Identity, err error) { // Check if this is a challenge/response flow if challengeB64 := r.FormValue("ssh_challenge"); challengeB64 != "" { identity, err = c.handleChallengeResponse(r) return identity, err } // Handle JWT-based authentication (existing flow) identity, err = c.handleJWTCallback(r) return identity, err } // handleJWTCallback processes JWT-based authentication via OAuth2 Token Exchange. // This method validates SSH-signed JWTs submitted as OAuth2 authorization codes. // // The JWT verification process: // 1. Extracts JWT from either direct submission or authorization code // 2. Parses JWT headers to identify signing key requirements // 3. Validates JWT signature against administratively configured SSH keys // 4. Verifies JWT claims (issuer, expiration, audience) // 5. Maps authenticated user to configured identity attributes // // Security: Only SSH keys configured by administrators can verify JWTs. // No cryptographic material from JWTs is trusted until signature verification succeeds. func (c *SSHConnector) handleJWTCallback(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") err = errors.New("no SSH JWT or authorization code provided") return identity, err } // Validate and extract identity using existing JWT logic identity, err = c.validateSSHJWT(sshJWT) return identity, err } // handleChallengeResponse processes challenge/response authentication flows. // This method validates SSH signatures of previously issued challenges. // // The verification process: // 1. Extracts challenge, signature, and username from callback request // 2. Retrieves stored challenge data and validates expiration // 3. Verifies SSH signature against user's configured public keys // 4. Returns user identity attributes from administrative configuration // // Security: Challenges are single-use and time-limited. User enumeration is // prevented by only generating challenges for valid configured users. func (c *SSHConnector) handleChallengeResponse(r *http.Request) (identity connector.Identity, err error) { // Extract parameters username := r.FormValue("username") signature := r.FormValue("signature") state := r.FormValue("state") if username == "" || signature == "" || state == "" { c.logAuditEvent("auth_attempt", username, "unknown", "challenge", "failed", "missing required parameters") identity = connector.Identity{} err = errors.New("missing required parameters: username, signature, or state") return identity, err } // Extract challenge ID from state parts := strings.Split(state, ":") if len(parts) < 2 { c.logAuditEvent("auth_attempt", username, "unknown", "challenge", "failed", "invalid state format") identity = connector.Identity{} err = errors.New("invalid state format") return identity, err } challengeID := parts[len(parts)-1] // Retrieve stored challenge challenge, exists := c.challenges.get(challengeID) if !exists { c.logAuditEvent("auth_attempt", username, "unknown", "challenge", "failed", "invalid or expired challenge") identity = connector.Identity{} err = errors.New("invalid or expired challenge") return identity, err } // SECURITY: Validate that the username matches the challenge // This prevents challenge reuse across different users if challenge.Username != username { c.logAuditEvent("auth_attempt", username, "unknown", "challenge", "failed", fmt.Sprintf("username mismatch: challenge for %s, request for %s", challenge.Username, username)) identity = connector.Identity{} err = errors.New("challenge username mismatch") return identity, err } // SECURITY: Check if this was a valid user challenge (prevents enumeration) if !challenge.IsValid { c.logAuditEvent("auth_attempt", username, "unknown", "challenge", "failed", "invalid user challenge") identity = connector.Identity{} err = errors.New("authentication failed") return identity, err } // Get user config (we know it exists because IsValid=true) userConfig, exists := c.config.Users[username] if !exists { // This should never happen if IsValid=true, but defensive programming c.logAuditEvent("auth_attempt", username, "unknown", "challenge", "failed", "user config missing") identity = connector.Identity{} err = errors.New("authentication failed") return identity, err } // Verify SSH signature against challenge var signatureBytes []byte signatureBytes, err = base64.StdEncoding.DecodeString(signature) if err != nil { c.logAuditEvent("auth_attempt", username, "unknown", "challenge", "failed", "invalid signature encoding") identity = connector.Identity{} err = fmt.Errorf("invalid signature encoding: %w", err) return identity, err } // Try each configured SSH key for the user var verifiedKey ssh.PublicKey for _, keyStr := range userConfig.Keys { var pubKey ssh.PublicKey pubKey, err = c.parseSSHKey(keyStr) if err == nil { if c.verifySSHSignature(pubKey, challenge.Data, signatureBytes) { verifiedKey = pubKey break } } } if verifiedKey == nil { keyFingerprint := "unknown" c.logAuditEvent("auth_attempt", username, keyFingerprint, "challenge", "failed", "signature verification failed") identity = connector.Identity{} err = errors.New("signature verification failed") return identity, err } // Create identity from user configuration userInfo := userConfig.UserInfo if userInfo.Username == "" { userInfo.Username = username } // Combine default groups with user-specific groups allGroups := append([]string{}, c.config.DefaultGroups...) allGroups = append(allGroups, userInfo.Groups...) identity = connector.Identity{ UserID: userInfo.Username, Username: userInfo.Username, PreferredUsername: userInfo.Username, Email: userInfo.Email, EmailVerified: true, Groups: allGroups, } // Log successful authentication keyFingerprint := ssh.FingerprintSHA256(verifiedKey) c.logAuditEvent("auth_success", username, keyFingerprint, "challenge", "success", fmt.Sprintf("user %s authenticated with SSH key %s via challenge/response", username, keyFingerprint)) return identity, err } // parseSSHKey parses a public key string into an SSH public key func (c *SSHConnector) parseSSHKey(keyStr string) (pubKey ssh.PublicKey, err error) { var comment string var options []string var rest []byte pubKey, comment, options, rest, err = ssh.ParseAuthorizedKey([]byte(keyStr)) _ = comment // Comment is optional per SSH spec _ = options // Options not used in this context _ = rest // Rest not used in this context if err != nil { err = fmt.Errorf("invalid SSH public key format: %w", err) return pubKey, err } return pubKey, err } // verifySSHSignature verifies an SSH signature against data using a public key func (c *SSHConnector) verifySSHSignature(pubKey ssh.PublicKey, data, signature []byte) (valid bool) { // For SSH signature verification, we need to reconstruct the signed data format // SSH signatures typically sign a specific data format // Create a signature object from the signature bytes sig := &ssh.Signature{} if err := ssh.Unmarshal(signature, sig); err != nil { if c.logger != nil { c.logger.Debug("Failed to unmarshal SSH signature", "error", err) } valid = false return valid } // Verify the signature against the data err := pubKey.Verify(data, sig) valid = err == nil return valid } // validateSSHJWT validates an SSH-signed JWT and extracts user identity. func (c *SSHConnector) validateSSHJWT(sshJWTString string) (identity connector.Identity, err error) { // Register our custom SSH signing method for JWT parsing jwt.RegisterSigningMethod("SSH", func() (method jwt.SigningMethod) { method = &SSHSigningMethodServer{} return method }) // Parse JWT with secure verification - try all configured user keys var token *jwt.Token var verifiedUser string var verifiedKey ssh.PublicKey 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())) identity = connector.Identity{} err = fmt.Errorf("failed to parse JWT: %w", err) return identity, err } // Extract claims claims, ok := token.Claims.(jwt.MapClaims) if !ok { identity = connector.Identity{} err = errors.New("invalid JWT claims format") return identity, err } // Validate JWT claims (extracted for readability) var sub, iss string sub, iss, err = c.validateJWTClaims(claims) if err != nil { keyFingerprint := ssh.FingerprintSHA256(verifiedKey) c.logAuditEvent("auth_attempt", sub, keyFingerprint, iss, "failed", err.Error()) identity = connector.Identity{} return 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, err } // 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) (token *jwt.Token, username string, publicKey ssh.PublicKey, err 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{} var unverifiedToken *jwt.Token unverifiedToken, _, err = parser.ParseUnverified(sshJWTString, jwt.MapClaims{}) if err != nil { err = fmt.Errorf("failed to parse JWT structure: %w", err) return token, username, publicKey, 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 { err = errors.New("invalid claims format") return token, username, publicKey, err } sub, ok := claims["sub"].(string) if !ok || sub == "" { err = errors.New("missing or invalid sub claim") return token, username, publicKey, err } // 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 configUsername, userConfig := range c.config.Users { for _, authorizedKeyStr := range userConfig.Keys { // Parse the configured public key (trusted, set by administrators) var configPublicKey ssh.PublicKey var comment string var options []string var rest []byte configPublicKey, 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 var verifiedToken *jwt.Token verifiedToken, err = jwt.Parse(sshJWTString, func(token *jwt.Token) (key interface{}, keyErr error) { if token.Method.Alg() != "SSH" { keyErr = fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) return key, keyErr } // Return the configured public key for verification - NOT any key from JWT claims key = configPublicKey return key, keyErr }) 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 token = verifiedToken username = configUsername publicKey = configPublicKey return token, username, publicKey, err } } } err = fmt.Errorf("no configured key could verify the JWT signature") return token, username, publicKey, err } // 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) (username string, issuer string, err error) { // Validate required claims sub, ok := claims["sub"].(string) if !ok || sub == "" { err = errors.New("missing or invalid sub claim") return username, issuer, err } aud, ok := claims["aud"].(string) if !ok || aud == "" { username = sub err = errors.New("missing or invalid aud claim") return username, issuer, err } iss, ok := claims["iss"].(string) if !ok || iss == "" { username = sub err = errors.New("missing or invalid iss claim") return username, issuer, err } // DUAL AUDIENCE MODEL (legacy support removed) // Require target_audience claim - only new dual-audience tokens are supported targetAudClaim, hasTargetAudience := claims["target_audience"] if !hasTargetAudience { username = sub issuer = iss err = errors.New("missing target_audience claim - legacy tokens no longer supported") return username, issuer, err } // Validate Dex instance audience if !c.isValidDexInstanceAudience(aud) { username = sub issuer = iss err = fmt.Errorf("JWT not intended for this Dex instance, audience: %s", aud) return username, issuer, err } targetAudStr, ok := targetAudClaim.(string) if !ok { username = sub issuer = iss err = errors.New("target_audience claim must be a string") return username, issuer, err } if !c.isAllowedTargetAudience(targetAudStr) { username = sub issuer = iss err = fmt.Errorf("invalid target_audience: %s", targetAudStr) return username, issuer, err } // Log successful dual audience validation c.logAuditEvent("token_validation", username, "unknown", issuer, "info", fmt.Sprintf("validated dual audience token: dex_instance=%s, target_audience=%s", aud, targetAudStr)) // Validate issuer if !c.isAllowedIssuer(iss) { username = sub issuer = iss err = fmt.Errorf("invalid issuer: %s", iss) return username, issuer, err } // Validate expiration (critical security check) exp, ok := claims["exp"].(float64) if !ok { username = sub issuer = iss err = errors.New("missing or invalid exp claim") return username, issuer, err } if time.Unix(int64(exp), 0).Before(time.Now()) { username = sub issuer = iss err = errors.New("token has expired") return username, issuer, err } // Validate not before if present if nbfClaim, nbfOk := claims["nbf"].(float64); nbfOk { if time.Unix(int64(nbfClaim), 0).After(time.Now()) { username = sub issuer = iss err = errors.New("token not yet valid") return username, issuer, err } } username = sub issuer = iss return username, issuer, err } // 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 UserInfo, err 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, err } } err = fmt.Errorf("key %s not authorized for user %s", keyFingerprint, username) return userInfo, err } err = fmt.Errorf("user %s not found or key %s not authorized", username, keyFingerprint) return userInfo, err } // 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) (matches 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) matches = false return matches } // Generate fingerprint from the public key and compare authorizedKeyFingerprint := ssh.FingerprintSHA256(publicKey) matches = authorizedKeyFingerprint == presentedKeyFingerprint return matches } // isAllowedIssuer checks if the JWT issuer is allowed. func (c *SSHConnector) isAllowedIssuer(issuer string) (allowed bool) { if len(c.config.AllowedIssuers) == 0 { allowed = true // Allow all if none specified return allowed } for _, allowedIssuer := range c.config.AllowedIssuers { if issuer == allowedIssuer { allowed = true return allowed } } allowed = false return allowed } // isValidDexInstanceAudience checks if the JWT audience matches this Dex instance. func (c *SSHConnector) isValidDexInstanceAudience(audience string) (valid bool) { if c.config.DexInstanceID == "" { valid = true // Allow all if not configured (backward compatibility) return valid } valid = audience == c.config.DexInstanceID return valid } // isAllowedTargetAudience checks if the target_audience claim is allowed. func (c *SSHConnector) isAllowedTargetAudience(targetAudience string) (allowed bool) { if len(c.config.AllowedTargetAudiences) == 0 { allowed = true // Allow all if none specified return allowed } for _, allowedTargetAudience := range c.config.AllowedTargetAudiences { if targetAudience == allowedTargetAudience { allowed = true return allowed } } allowed = false return allowed } // SSHSigningMethodServer implements JWT signing method for server-side SSH verification. type SSHSigningMethodServer struct{} // Alg returns the signing method algorithm identifier. func (m *SSHSigningMethodServer) Alg() (algorithm string) { algorithm = "SSH" return algorithm } // Sign is not implemented on server side (client-only operation). func (m *SSHSigningMethodServer) Sign(signingString string, key interface{}) (signature []byte, err error) { err = errors.New("SSH signing not supported on server side") return signature, err } // Verify verifies the JWT signature using the SSH public key. func (m *SSHSigningMethodServer) Verify(signingString string, signature []byte, key interface{}) (err error) { // Parse SSH public key publicKey, ok := key.(ssh.PublicKey) if !ok { err = fmt.Errorf("SSH verification requires ssh.PublicKey, got %T", key) return err } // Decode the base64-encoded signature signatureStr := string(signature) signatureBytes, decodeErr := base64.StdEncoding.DecodeString(signatureStr) if decodeErr != nil { err = fmt.Errorf("failed to decode signature: %w", decodeErr) return 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 { err = fmt.Errorf("SSH signature verification failed: %w", err) } return err } // 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 validates SSH JWT tokens via OAuth2 Token Exchange (RFC 8693). // This method implements the TokenIdentityConnector interface, enabling clients // to exchange SSH-signed JWTs for Dex identity tokens. // // The OAuth2 Token Exchange flow: // 1. Client creates JWT signed with SSH private key // 2. Client calls Dex token exchange endpoint with SSH JWT as subject token // 3. Dex validates JWT signature against administratively configured SSH keys // 4. Dex returns standard OAuth2 tokens (ID token, access token, refresh token) // // Supported subject token types: // - "ssh_jwt" (custom type for SSH-signed JWTs) // - "urn:ietf:params:oauth:token-type:jwt" (RFC 8693 standard) // - "urn:ietf:params:oauth:token-type:access_token" (compatibility) // - "urn:ietf:params:oauth:token-type:id_token" (compatibility) // // Security: JWT verification follows a secure 2-pass process where no JWT content // is trusted until cryptographic signature verification against configured SSH keys succeeds. func (c *SSHConnector) TokenIdentity(ctx context.Context, subjectTokenType, subjectToken string) (identity connector.Identity, err 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: err = fmt.Errorf("unsupported token type: %s", subjectTokenType) return identity, err } // 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) } err = fmt.Errorf("SSH JWT validation failed: %w", err) return identity, err } if c.logger != nil { c.logger.InfoContext(ctx, "TokenIdentity successful", "user", identity.UserID) } return identity, err }