OpenID Connect (OIDC) identity and OAuth 2.0 provider with pluggable connectors
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

// 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
}