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.
 
 
 
 
 
 

1052 lines
37 KiB

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