mirror of https://github.com/dexidp/dex.git
10 changed files with 3175 additions and 6 deletions
@ -0,0 +1,434 @@
|
||||
# SSH Connector |
||||
|
||||
The SSH connector allows users to authenticate using SSH keys instead of passwords. This connector is designed specifically for Kubernetes environments where users want to leverage their existing SSH key infrastructure for authentication. |
||||
|
||||
## Features |
||||
|
||||
- **SSH Key Authentication**: Users authenticate using their SSH keys via SSH agent or key files |
||||
- **Dual Authentication Modes**: Supports both JWT-based and challenge/response authentication |
||||
- **OAuth2 Token Exchange**: Uses RFC 8693 OAuth2 Token Exchange for standards-compliant authentication |
||||
- **Challenge/Response Flow**: Direct SSH signature verification for simpler CLI integration |
||||
- **Flexible Key Storage**: Supports both SSH key fingerprints and full public keys in configuration |
||||
- **Group Mapping**: Map SSH users to groups for authorization |
||||
- **Audit Logging**: Comprehensive authentication event logging |
||||
- **Multiple Issuer Support**: Accept JWTs from multiple configured issuers |
||||
|
||||
## Authentication Modes |
||||
|
||||
The SSH connector supports two authentication modes: |
||||
|
||||
### Mode 1: JWT-Based Authentication (OAuth2 Token Exchange) |
||||
|
||||
**Best for**: Sophisticated clients like kubectl-ssh-oidc that need full OAuth2 compliance |
||||
|
||||
1. Client creates a JWT signed with SSH key |
||||
2. Client performs OAuth2 Token Exchange using the SSH JWT as subject token |
||||
3. Dex validates the JWT via the connector's `TokenIdentity` method |
||||
4. Dex returns standard OAuth2 tokens (ID token, access token, refresh token) |
||||
|
||||
### Mode 2: Challenge/Response Authentication (CallbackConnector) |
||||
|
||||
**Best for**: Simple CLI tools and shell scripts that want direct SSH signature verification |
||||
|
||||
1. Client requests authentication URL with `ssh_challenge=true` parameter |
||||
2. Dex generates cryptographic challenge and returns it in callback URL |
||||
3. Client extracts challenge, signs it with SSH private key |
||||
4. Client submits signed challenge to callback URL |
||||
5. Dex verifies SSH signature and returns OAuth2 authorization code |
||||
|
||||
**Challenge Expiration**: Challenges expire after the configured `challenge_ttl` (default 300 seconds/5 minutes) and are single-use to prevent replay attacks. |
||||
|
||||
## Configuration |
||||
|
||||
```yaml |
||||
connectors: |
||||
- type: ssh |
||||
id: ssh |
||||
name: SSH |
||||
config: |
||||
# User configuration mapping usernames to SSH keys and user info |
||||
users: |
||||
alice: |
||||
keys: |
||||
- "SHA256:abcd1234..." # SSH key fingerprint |
||||
- "ssh-rsa AAAAB3NzaC1y..." # Or full public key |
||||
user_info: |
||||
username: "alice" |
||||
email: "alice@example.com" |
||||
groups: ["developers", "admins"] |
||||
bob: |
||||
keys: |
||||
- "SHA256:efgh5678..." |
||||
user_info: |
||||
username: "bob" |
||||
email: "bob@example.com" |
||||
groups: ["developers"] |
||||
|
||||
# Input JWT issuer configuration - controls which JWTs Dex will ACCEPT |
||||
# IMPORTANT: These are NOT the same as the issuer of JWTs that Dex produces |
||||
# Dex accepts JWTs with these issuers, but issues its own JWTs with Dex's configured issuer |
||||
allowed_issuers: |
||||
- "kubectl-ssh-oidc" # Accept JWTs from kubectl-ssh-oidc tool |
||||
- "my-custom-issuer" # Accept JWTs from custom client tools |
||||
- "ssh-agent-helper" # Accept JWTs from other SSH authentication tools |
||||
|
||||
# Dex instance ID for JWT audience validation (SECURITY) |
||||
# This ensures JWTs are created specifically for this Dex instance |
||||
# Should match your Dex issuer URL or a unique instance identifier |
||||
dex_instance_id: "https://dex.example.com" |
||||
|
||||
# Target audience configuration (for final OIDC tokens) |
||||
# Controls what audiences can be requested in JWT target_audience claim |
||||
# For Kubernetes OIDC, use client IDs as target audiences |
||||
allowed_target_audiences: |
||||
- "kubectl" # Standard kubectl client ID |
||||
- "example-app" # Custom application client ID |
||||
|
||||
# Default groups assigned to all authenticated users |
||||
default_groups: ["authenticated"] |
||||
|
||||
# Token TTL in seconds (default: 3600) |
||||
token_ttl: 7200 |
||||
|
||||
# Challenge TTL in seconds for challenge/response auth (default: 300) |
||||
challenge_ttl: 600 |
||||
|
||||
# OAuth2 client IDs allowed to use this connector (legacy - use allowed_audiences instead) |
||||
allowed_clients: |
||||
- "kubectl" |
||||
- "my-k8s-client" |
||||
``` |
||||
|
||||
## User Configuration |
||||
|
||||
### SSH Keys |
||||
Users can be configured with SSH keys in two formats: |
||||
|
||||
1. **SSH Key Fingerprints**: `SHA256:abcd1234...` (recommended) |
||||
2. **Full Public Keys**: `ssh-rsa AAAAB3NzaC1y...` (also supported) |
||||
|
||||
### User Information |
||||
Each user must have: |
||||
- `username`: The user's login name |
||||
- `email`: User's email address (required for Kubernetes OIDC) |
||||
- `groups`: Optional list of groups the user belongs to |
||||
|
||||
## Client Integration |
||||
|
||||
The SSH connector supports multiple client types: |
||||
|
||||
### JWT-Based Clients |
||||
|
||||
**kubectl-ssh-oidc Plugin**: The [kubectl-ssh-oidc](https://github.com/nikogura/kubectl-ssh-oidc) plugin provides full JWT-based authentication: |
||||
|
||||
```bash |
||||
# Install kubectl-ssh-oidc plugin |
||||
kubectl ssh-oidc --dex-url https://dex.example.com --client-id kubectl |
||||
|
||||
# The plugin will: |
||||
# 1. Generate a JWT signed with your SSH key |
||||
# 2. Perform OAuth2 Token Exchange with Dex |
||||
# 3. Return Kubernetes credentials |
||||
``` |
||||
|
||||
### Challenge/Response Clients |
||||
|
||||
**Simple CLI Authentication**: For basic shell scripts and CLI tools: |
||||
|
||||
```bash |
||||
#!/bin/bash |
||||
# Example CLI client for challenge/response authentication |
||||
|
||||
DEX_URL="https://dex.example.com" |
||||
CLIENT_ID="kubectl" |
||||
USERNAME="alice" |
||||
|
||||
# Step 1: Request challenge |
||||
AUTH_URL=$(curl -s "${DEX_URL}/auth/${CLIENT_ID}/authorize?response_type=code&ssh_challenge=true" \ |
||||
| grep -o 'Location: [^"]*' | cut -d' ' -f2) |
||||
|
||||
# Step 2: Extract challenge from auth URL |
||||
CHALLENGE=$(echo "$AUTH_URL" | sed -n 's/.*ssh_challenge=\([^&]*\).*/\1/p' | base64 -d) |
||||
|
||||
# Step 3: Sign challenge with SSH key |
||||
SIGNATURE=$(echo -n "$CHALLENGE" | ssh-keysign - | base64 -w0) |
||||
|
||||
# Step 4: Submit signed challenge |
||||
STATE=$(echo "$AUTH_URL" | sed -n 's/.*state=\([^&]*\).*/\1/p') |
||||
CALLBACK_URL=$(echo "$AUTH_URL" | sed -n 's/^\([^?]*\).*/\1/p') |
||||
|
||||
curl -X POST "$CALLBACK_URL" \ |
||||
-d "username=$USERNAME" \ |
||||
-d "signature=$SIGNATURE" \ |
||||
-d "state=$STATE" |
||||
|
||||
# Result: OAuth2 authorization code for token exchange |
||||
``` |
||||
|
||||
**JWT-Based Clients**: Must use the dual-audience JWT format with both `aud` and `target_audience` claims. |
||||
|
||||
**Challenge/Response Clients**: Use direct SSH signature verification - no JWT required. |
||||
|
||||
## Issuer Configuration: Input vs Output |
||||
|
||||
**CRITICAL DISTINCTION**: The SSH connector configuration deals with **input issuers** (JWTs Dex accepts), which are completely separate from **output issuers** (JWTs Dex produces). |
||||
|
||||
### Input Issuers (`allowed_issuers`) |
||||
These control which external JWTs the SSH connector will **accept** for authentication: |
||||
|
||||
```yaml |
||||
allowed_issuers: |
||||
- "kubectl-ssh-oidc" # Accept JWTs from kubectl-ssh-oidc client |
||||
- "ssh-agent-helper" # Accept JWTs from custom SSH helper tools |
||||
- "my-company-ssh-tool" # Accept JWTs from internal tools |
||||
``` |
||||
|
||||
- **Purpose**: Validates the `iss` claim in incoming SSH-signed JWTs |
||||
- **Security**: Prevents arbitrary clients from claiming to be trusted issuers |
||||
- **Multiple Support**: Can accept JWTs from multiple different client tools |
||||
- **Empty List Behavior**: If empty, accepts JWTs from **any** issuer (less secure) |
||||
|
||||
### Output Issuer (Dex Configuration) |
||||
This is configured in Dex's main configuration file, **NOT** in the SSH connector: |
||||
|
||||
```yaml |
||||
# In dex.yaml (main Dex config) |
||||
issuer: https://dex.example.com |
||||
|
||||
connectors: |
||||
- type: ssh |
||||
# SSH connector config has NO control over output issuer |
||||
``` |
||||
|
||||
- **Purpose**: All JWTs that Dex **produces** will have `iss: "https://dex.example.com"` |
||||
- **Control**: Completely separate from SSH connector configuration |
||||
- **Single Value**: Dex can only have one output issuer URL |
||||
|
||||
### Example Flow |
||||
1. **Client creates JWT**: `{"iss": "kubectl-ssh-oidc", "sub": "alice", ...}` |
||||
2. **SSH connector validates**: Checks if "kubectl-ssh-oidc" is in `allowed_issuers` |
||||
3. **Dex authenticates user**: Verifies SSH signature, creates user session |
||||
4. **Dex issues tokens**: `{"iss": "https://dex.example.com", "sub": "alice", ...}` |
||||
|
||||
**Key Point**: The SSH connector accepts JWTs with issuer "kubectl-ssh-oidc" but Dex produces JWTs with issuer "https://dex.example.com". These are completely different values serving different purposes. |
||||
|
||||
## JWT Format and Security Model |
||||
|
||||
**CRITICAL SECURITY NOTICE**: This connector implements a secure JWT verification model where JWT is treated as just a packaging format. The JWT contains NO trusted data until cryptographic verification succeeds. |
||||
|
||||
### JWT Claims Format |
||||
|
||||
The SSH connector expects JWTs with the following standard claims: |
||||
|
||||
**Input JWT (from client to Dex)**: |
||||
```json |
||||
{ |
||||
"sub": "alice", // Username (UNTRUSTED until verification) |
||||
"iss": "kubectl-ssh-oidc", // INPUT issuer - must be in allowed_issuers (UNTRUSTED until verification) |
||||
"aud": "https://dex.example.com", // Dex instance ID (UNTRUSTED until verification) |
||||
"target_audience": "kubectl", // Desired token audience (UNTRUSTED until verification) |
||||
"exp": 1234567890, // Expiration time (UNTRUSTED until verification) |
||||
"iat": 1234567890, // Issued at time (UNTRUSTED until verification) |
||||
"nbf": 1234567890, // Not before time (UNTRUSTED until verification) |
||||
"jti": "unique-token-id" // JWT ID (UNTRUSTED until verification) |
||||
} |
||||
``` |
||||
|
||||
**Output JWT (from Dex to clients)**: |
||||
```json |
||||
{ |
||||
"sub": "alice", // Same user, now trusted after SSH verification |
||||
"iss": "https://dex.example.com", // OUTPUT issuer - from main Dex configuration |
||||
"aud": "kubectl", // Final audience (from target_audience above) |
||||
"exp": 1234567890, // New expiration time |
||||
"iat": 1234567890, // New issued time |
||||
// ... standard OIDC claims |
||||
} |
||||
``` |
||||
|
||||
**Notice**: The `iss` field changes from input ("kubectl-ssh-oidc") to output ("https://dex.example.com"). This is normal and expected. |
||||
|
||||
**Dual Audience Model** |
||||
- `aud`: Must match the configured `dex_instance_id` - ensures JWT is for this Dex instance |
||||
- `target_audience`: Required claim specifying desired audience for final OIDC tokens |
||||
|
||||
**REQUIRED FORMAT**: All JWTs must use the dual-audience model: |
||||
- JWTs **must** include both `aud` and `target_audience` claims |
||||
|
||||
**IMPORTANT**: The JWT does NOT contain SSH keys, fingerprints, or any cryptographic material. These would be security vulnerabilities allowing key injection attacks. SSH keys and fingerprints are only used in the Dex administrative configuration, never in JWT tokens sent by clients. |
||||
|
||||
### Security Model: Authentication vs Authorization |
||||
|
||||
This connector maintains strict separation between authentication and authorization: |
||||
|
||||
**Authentication (Cryptographic Proof)**: |
||||
- JWT signature is verified against SSH keys configured by administrators in Dex |
||||
- Only SSH keys explicitly configured in the `users` section can verify JWTs |
||||
- Clients prove they control the private key by successfully signing the JWT |
||||
- JWT verification uses a secure 2-pass process following the jwt-ssh-agent-go pattern |
||||
|
||||
**Authorization (Administrative Policy)**: |
||||
- User identity, email, groups, and permissions are configured separately by administrators |
||||
- No user information comes from the JWT itself - it's all from Dex configuration |
||||
- This prevents privilege escalation through client-controlled JWT claims |
||||
|
||||
**Identity Claim and Proof Process**: |
||||
1. **Identity Claim**: User sets the `sub` field in the JWT to claim their identity |
||||
2. **Cryptographic Proof**: User signs the JWT with their SSH private key to prove they control that identity |
||||
3. **Administrative Verification**: Dex verifies the signature against configured SSH keys for that user |
||||
4. **Authorization**: Dex returns user attributes (email, groups) from administrative configuration, not JWT claims |
||||
|
||||
### Administrative Control Model |
||||
|
||||
The Dex configuration provides complete control over access: |
||||
|
||||
1. **Connection Authorization**: Only users explicitly configured in the `users` section can authenticate at all |
||||
2. **Cryptographic Authentication**: Each user's configured SSH keys define which private keys can "prove" the user's identity |
||||
3. **Scope Authorization**: User configuration provides scopes (email, groups) that determine what the authenticated user can access |
||||
4. **No Client Control**: Clients cannot influence authorization - they can only cryptographically prove they control a configured private key |
||||
|
||||
### Why This Design Is Secure |
||||
|
||||
1. **No Key Injection**: JWTs cannot contain verification keys that clients control |
||||
2. **Administrative Control**: All trusted keys, user mappings, and scopes are configured by Dex administrators |
||||
3. **Separation of Concerns**: Authentication (crypto) is separate from authorization (policy) |
||||
4. **Standard Compliance**: Uses only standard JWT claims, no custom security-sensitive fields |
||||
5. **Allowlist Model**: Only explicitly configured users with specific SSH keys can authenticate |
||||
|
||||
The JWT must be signed using the "SSH" algorithm (custom signing method that integrates with SSH agents). |
||||
|
||||
## Security Considerations |
||||
|
||||
### Built-in Security Features |
||||
|
||||
The SSH connector includes several built-in security protections: |
||||
|
||||
**User Enumeration Prevention**: |
||||
- **Constant-time responses**: Valid and invalid usernames receive identical response patterns and timing |
||||
- **Challenge generation**: All users (valid or invalid) receive challenges to prevent enumeration via timing differences |
||||
- **Identical error messages**: Authentication failures use consistent error messages regardless of whether user exists |
||||
|
||||
**Rate Limiting**: |
||||
- **IP-based rate limiting**: Maximum 10 authentication attempts per IP address per 5-minute window |
||||
- **Automatic cleanup**: Rate limit entries are automatically cleaned up to prevent memory leaks |
||||
- **Brute force protection**: Prevents attackers from rapidly trying multiple username/key combinations |
||||
|
||||
**Timing Attack Prevention**: |
||||
- **Consistent processing**: Authentication logic takes similar time for valid and invalid users |
||||
- **Deferred validation**: Username validation is deferred to prevent timing-based user discovery |
||||
|
||||
### SSH Key Management |
||||
- Use SSH agent for key storage when possible |
||||
- Avoid storing unencrypted private keys on disk |
||||
- Regularly rotate SSH keys |
||||
- Use strong key types (ED25519, RSA 4096-bit) |
||||
|
||||
### Network Security |
||||
- Always use HTTPS for Dex endpoints |
||||
- Consider network-level restrictions for the `/ssh/token` endpoint |
||||
- Implement proper firewall rules |
||||
|
||||
### Audit and Monitoring |
||||
- **Comprehensive audit logging**: All authentication attempts are logged with structured events including: |
||||
- Authentication attempts (successful and failed) |
||||
- Challenge generation and validation |
||||
- Rate limiting events |
||||
- User enumeration prevention activities |
||||
- Monitor SSH connector authentication logs for security events |
||||
- Set up alerts for failed authentication attempts and rate limiting triggers |
||||
- Regularly review user access and group memberships |
||||
- Watch for patterns that may indicate attack attempts |
||||
|
||||
## Troubleshooting |
||||
|
||||
### Common Issues |
||||
|
||||
#### "JWT parse error: token is unverifiable" |
||||
- Verify SSH key is properly configured in users section |
||||
- Check that key fingerprint matches the one in the JWT |
||||
- Ensure JWT is signed with correct SSH key |
||||
|
||||
#### "User not found or key not authorized" |
||||
- Verify username exists in configuration |
||||
- Check that SSH key fingerprint matches configured keys |
||||
- Confirm user has required SSH key loaded in agent |
||||
|
||||
#### "Invalid issuer" |
||||
**Problem**: The `iss` claim in the INPUT JWT doesn't match any value in `allowed_issuers` |
||||
|
||||
**Solutions**: |
||||
- Verify the client's JWT has `iss` claim matching one of the `allowed_issuers` values |
||||
- Check client configuration uses correct issuer value (e.g., "kubectl-ssh-oidc") |
||||
- Add the client's issuer to the `allowed_issuers` list in SSH connector configuration |
||||
|
||||
**Note**: This error is about INPUT JWTs (client→Dex), not OUTPUT JWTs (Dex→client). The OUTPUT issuer is always Dex's main `issuer` configuration and cannot be changed by the SSH connector. |
||||
|
||||
#### "Too many requests" or Rate Limiting |
||||
- **Cause**: IP address has exceeded 10 authentication attempts in 5 minutes |
||||
- **Solution**: Wait for the rate limit window to expire (5 minutes) |
||||
- **Prevention**: Avoid rapid authentication attempts from the same IP |
||||
- **Investigation**: Check audit logs for potential brute force attacks |
||||
|
||||
#### User Enumeration Protection Working |
||||
- **Normal behavior**: Both valid and invalid users receive identical responses |
||||
- **Expected**: Challenge generation succeeds for all usernames (this is intentional) |
||||
- **Security**: Authentication failures happen during signature verification, not user lookup |
||||
|
||||
### Debug Logging |
||||
Enable debug logging to troubleshoot authentication issues: |
||||
|
||||
```yaml |
||||
logger: |
||||
level: debug |
||||
``` |
||||
|
||||
This will show detailed authentication flow information and help identify configuration issues. |
||||
|
||||
## Client Requirements |
||||
|
||||
The SSH connector supports two distinct client authentication methods: |
||||
|
||||
### JWT-Based Client Requirements |
||||
|
||||
For clients using JWT-based authentication (OAuth2 Token Exchange): |
||||
|
||||
1. **Required JWT Claims** |
||||
```json |
||||
{ |
||||
"aud": "https://dex.example.com", // Must match dex_instance_id |
||||
"target_audience": "kubectl" // Must be in allowed_target_audiences |
||||
} |
||||
``` |
||||
|
||||
2. **Client Configuration** |
||||
Update kubectl-ssh-oidc clients to include: |
||||
```json |
||||
{ |
||||
"dex_instance_id": "https://dex.example.com", |
||||
"target_audience": "kubectl" |
||||
} |
||||
``` |
||||
|
||||
### Challenge/Response Client Requirements |
||||
|
||||
For clients using challenge/response authentication: |
||||
|
||||
1. **No JWT Required** - Uses direct SSH signature verification |
||||
2. **Authentication Flow** - Follow the bash example above |
||||
3. **SSH Key Access** - Requires access to SSH private key or SSH agent |
||||
|
||||
## Status |
||||
|
||||
- **Connector Status**: Alpha (subject to change) |
||||
- **Supports Refresh Tokens**: Yes |
||||
- **Supports Groups Claim**: Yes |
||||
- **Supports Preferred Username Claim**: Yes |
||||
|
||||
## Contributing |
||||
|
||||
The SSH connector is part of a Dex fork and may be contributed back to upstream Dex. When contributing: |
||||
|
||||
1. Ensure all tests pass: `go test ./connector/ssh` |
||||
2. Follow Dex coding standards and patterns |
||||
3. Update documentation for any configuration changes |
||||
4. Add appropriate test coverage for new features |
||||
@ -0,0 +1,617 @@
|
||||
package server |
||||
|
||||
import ( |
||||
"crypto/ed25519" |
||||
"crypto/rand" |
||||
"encoding/base64" |
||||
"encoding/json" |
||||
"fmt" |
||||
"log/slog" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"net/url" |
||||
"os" |
||||
"strconv" |
||||
"strings" |
||||
"testing" |
||||
"time" |
||||
|
||||
gosundheit "github.com/AppsFlyer/go-sundheit" |
||||
"github.com/golang-jwt/jwt/v5" |
||||
"github.com/prometheus/client_golang/prometheus" |
||||
"github.com/stretchr/testify/require" |
||||
"golang.org/x/crypto/ssh" |
||||
|
||||
"github.com/dexidp/dex/server/signer" |
||||
"github.com/dexidp/dex/storage" |
||||
"github.com/dexidp/dex/storage/ent" |
||||
"github.com/dexidp/dex/storage/memory" |
||||
) |
||||
|
||||
// sshSigningMethodTest implements jwt.SigningMethod for creating test SSH-signed JWTs.
|
||||
type sshSigningMethodTest struct{} |
||||
|
||||
func (m *sshSigningMethodTest) Alg() (algorithm string) { |
||||
algorithm = "SSH" |
||||
return algorithm |
||||
} |
||||
|
||||
func (m *sshSigningMethodTest) Verify(signingString string, signature []byte, key interface{}) (err error) { |
||||
err = fmt.Errorf("verify not used in test signing") |
||||
return err |
||||
} |
||||
|
||||
func (m *sshSigningMethodTest) Sign(signingString string, key interface{}) (signature []byte, err error) { |
||||
signer, ok := key.(ssh.Signer) |
||||
if !ok { |
||||
err = fmt.Errorf("expected ssh.Signer, got %T", key) |
||||
return signature, err |
||||
} |
||||
|
||||
sig, signErr := signer.Sign(rand.Reader, []byte(signingString)) |
||||
if signErr != nil { |
||||
err = fmt.Errorf("SSH signing failed: %w", signErr) |
||||
return signature, err |
||||
} |
||||
|
||||
// Encode just the blob as base64 — the server reconstructs the ssh.Signature
|
||||
encoded := base64.StdEncoding.EncodeToString(sig.Blob) |
||||
signature = []byte(encoded) |
||||
return signature, err |
||||
} |
||||
|
||||
// generateTestSSHJWT creates a JWT signed with an SSH private key for testing.
|
||||
// The JWT uses the dual-audience model: aud=dexInstanceID, target_audience=final audience.
|
||||
func generateTestSSHJWT(t *testing.T, signer ssh.Signer, username, issuer, dexInstanceID, targetAudience string) (tokenString string) { |
||||
t.Helper() |
||||
|
||||
signingMethod := &sshSigningMethodTest{} |
||||
jwt.RegisterSigningMethod("SSH", func() (m jwt.SigningMethod) { |
||||
m = signingMethod |
||||
return m |
||||
}) |
||||
|
||||
now := time.Now() |
||||
claims := jwt.MapClaims{ |
||||
"sub": username, |
||||
"iss": issuer, |
||||
"aud": dexInstanceID, |
||||
"target_audience": targetAudience, |
||||
"exp": now.Add(time.Hour).Unix(), |
||||
"iat": now.Unix(), |
||||
"nbf": now.Add(-time.Minute).Unix(), |
||||
} |
||||
|
||||
token := jwt.NewWithClaims(signingMethod, claims) |
||||
var err error |
||||
tokenString, err = token.SignedString(signer) |
||||
require.NoError(t, err, "failed to sign test JWT") |
||||
return tokenString |
||||
} |
||||
|
||||
// sshConnectorJSON returns JSON config for the SSH connector with the given public key.
|
||||
func sshConnectorJSON(t *testing.T, pubKeyStr, serverURL string) (configJSON []byte) { |
||||
t.Helper() |
||||
|
||||
config := map[string]interface{}{ |
||||
"users": map[string]interface{}{ |
||||
"testuser": map[string]interface{}{ |
||||
"keys": []string{strings.TrimSpace(pubKeyStr)}, |
||||
"username": "testuser", |
||||
"email": "testuser@example.com", |
||||
"groups": []string{"developers", "ssh-users"}, |
||||
}, |
||||
}, |
||||
"allowed_issuers": []string{"test-ssh-client"}, |
||||
"dex_instance_id": serverURL, |
||||
"allowed_target_audiences": []string{"ssh-test-client", "kubectl"}, |
||||
"default_groups": []string{"authenticated"}, |
||||
"token_ttl": 3600, |
||||
"challenge_ttl": 300, |
||||
} |
||||
|
||||
var err error |
||||
configJSON, err = json.Marshal(config) |
||||
require.NoError(t, err, "failed to marshal SSH connector config") |
||||
return configJSON |
||||
} |
||||
|
||||
// newTestServerWithStorage creates a test server using the provided storage backend.
|
||||
// It registers an SSH connector and an OAuth2 client for token exchange testing.
|
||||
func newTestServerWithStorage( |
||||
t *testing.T, |
||||
s storage.Storage, |
||||
pubKeyStr string, |
||||
) (httpServer *httptest.Server, server *Server) { |
||||
t.Helper() |
||||
|
||||
var srv *Server |
||||
httpServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
srv.ServeHTTP(w, r) |
||||
})) |
||||
|
||||
logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) |
||||
ctx := t.Context() |
||||
|
||||
config := Config{ |
||||
Issuer: httpServer.URL, |
||||
Storage: s, |
||||
Web: WebConfig{ |
||||
Dir: "../web", |
||||
}, |
||||
Logger: logger, |
||||
PrometheusRegistry: prometheus.NewRegistry(), |
||||
HealthChecker: gosundheit.New(), |
||||
SkipApprovalScreen: true, |
||||
AllowedGrantTypes: []string{ |
||||
grantTypeAuthorizationCode, |
||||
grantTypeRefreshToken, |
||||
grantTypeTokenExchange, |
||||
}, |
||||
} |
||||
|
||||
// Create SSH connector in storage
|
||||
connectorConfig := sshConnectorJSON(t, pubKeyStr, httpServer.URL) |
||||
sshConn := storage.Connector{ |
||||
ID: "ssh", |
||||
Type: "ssh", |
||||
Name: "SSH", |
||||
ResourceVersion: "1", |
||||
Config: connectorConfig, |
||||
} |
||||
err := s.CreateConnector(ctx, sshConn) |
||||
require.NoError(t, err, "failed to create SSH connector in storage") |
||||
|
||||
sig, err := signer.NewMockSigner(testKey) |
||||
require.NoError(t, err, "failed to create mock signer") |
||||
config.Signer = sig |
||||
|
||||
// Create OAuth2 client for token exchange
|
||||
err = s.CreateClient(ctx, storage.Client{ |
||||
ID: "ssh-test-client", |
||||
Secret: "ssh-test-secret", |
||||
Name: "SSH Test Client", |
||||
LogoURL: "https://example.com/logo.png", |
||||
}) |
||||
require.NoError(t, err, "failed to create test client") |
||||
|
||||
srv, err = newServer(ctx, config) |
||||
require.NoError(t, err, "failed to create server") |
||||
|
||||
srv.refreshTokenPolicy, err = NewRefreshTokenPolicy(logger, false, "", "", "") |
||||
require.NoError(t, err, "failed to create refresh token policy") |
||||
srv.refreshTokenPolicy.now = time.Now |
||||
|
||||
server = srv |
||||
return httpServer, server |
||||
} |
||||
|
||||
// doTokenExchange performs an RFC 8693 token exchange request against the server.
|
||||
func doTokenExchange( |
||||
t *testing.T, |
||||
server *Server, |
||||
serverURL string, |
||||
subjectToken string, |
||||
connectorID string, |
||||
clientID string, |
||||
clientSecret string, |
||||
subjectTokenType string, |
||||
requestedTokenType string, |
||||
scope string, |
||||
audience string, |
||||
) (rr *httptest.ResponseRecorder) { |
||||
t.Helper() |
||||
|
||||
vals := make(url.Values) |
||||
vals.Set("grant_type", grantTypeTokenExchange) |
||||
setNonEmpty(vals, "connector_id", connectorID) |
||||
setNonEmpty(vals, "scope", scope) |
||||
setNonEmpty(vals, "requested_token_type", requestedTokenType) |
||||
setNonEmpty(vals, "subject_token_type", subjectTokenType) |
||||
setNonEmpty(vals, "subject_token", subjectToken) |
||||
setNonEmpty(vals, "client_id", clientID) |
||||
setNonEmpty(vals, "client_secret", clientSecret) |
||||
setNonEmpty(vals, "audience", audience) |
||||
|
||||
rr = httptest.NewRecorder() |
||||
req := httptest.NewRequest(http.MethodPost, serverURL+"/token", strings.NewReader(vals.Encode())) |
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
||||
server.handleToken(rr, req) |
||||
return rr |
||||
} |
||||
|
||||
// generateTestSSHKeyPair creates an ed25519 SSH key pair for testing.
|
||||
func generateTestSSHKeyPair(t *testing.T) (pubKeyStr string, signer ssh.Signer) { |
||||
t.Helper() |
||||
|
||||
_, privKey, err := ed25519.GenerateKey(rand.Reader) |
||||
require.NoError(t, err, "failed to generate ed25519 key") |
||||
|
||||
pubKey, err := ssh.NewPublicKey(privKey.Public().(ed25519.PublicKey)) |
||||
require.NoError(t, err, "failed to create SSH public key") |
||||
|
||||
signer, err = ssh.NewSignerFromKey(privKey) |
||||
require.NoError(t, err, "failed to create SSH signer") |
||||
|
||||
pubKeyStr = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey))) |
||||
return pubKeyStr, signer |
||||
} |
||||
|
||||
// tokenExchangeSubtest defines a table-driven subtest for token exchange.
|
||||
type tokenExchangeSubtest struct { |
||||
name string |
||||
subjectTokenType string |
||||
requestedTokenType string |
||||
scope string |
||||
audience string |
||||
connectorID string |
||||
useValidToken bool |
||||
useBadSignature bool |
||||
omitSubjectToken bool |
||||
expectedCode int |
||||
expectedTokenType string |
||||
} |
||||
|
||||
// standardTokenExchangeSubtests returns the common set of subtests run against each storage backend.
|
||||
func standardTokenExchangeSubtests() (subtests []tokenExchangeSubtest) { |
||||
subtests = []tokenExchangeSubtest{ |
||||
{ |
||||
name: "access-token-exchange", |
||||
subjectTokenType: tokenTypeAccess, |
||||
requestedTokenType: tokenTypeAccess, |
||||
scope: "openid", |
||||
connectorID: "ssh", |
||||
useValidToken: true, |
||||
expectedCode: http.StatusOK, |
||||
expectedTokenType: tokenTypeAccess, |
||||
}, |
||||
{ |
||||
name: "id-token-exchange", |
||||
subjectTokenType: tokenTypeID, |
||||
requestedTokenType: tokenTypeID, |
||||
scope: "openid", |
||||
connectorID: "ssh", |
||||
useValidToken: true, |
||||
expectedCode: http.StatusOK, |
||||
expectedTokenType: tokenTypeID, |
||||
}, |
||||
{ |
||||
name: "default-token-type", |
||||
subjectTokenType: tokenTypeAccess, |
||||
requestedTokenType: "", |
||||
scope: "openid", |
||||
connectorID: "ssh", |
||||
useValidToken: true, |
||||
expectedCode: http.StatusOK, |
||||
expectedTokenType: tokenTypeAccess, |
||||
}, |
||||
{ |
||||
name: "with-audience", |
||||
subjectTokenType: tokenTypeAccess, |
||||
requestedTokenType: tokenTypeAccess, |
||||
scope: "openid", |
||||
audience: "kubectl", |
||||
connectorID: "ssh", |
||||
useValidToken: true, |
||||
expectedCode: http.StatusOK, |
||||
expectedTokenType: tokenTypeAccess, |
||||
}, |
||||
{ |
||||
name: "missing-subject-token", |
||||
subjectTokenType: tokenTypeAccess, |
||||
requestedTokenType: tokenTypeAccess, |
||||
scope: "openid", |
||||
connectorID: "ssh", |
||||
omitSubjectToken: true, |
||||
expectedCode: http.StatusBadRequest, |
||||
}, |
||||
{ |
||||
name: "invalid-connector", |
||||
subjectTokenType: tokenTypeAccess, |
||||
requestedTokenType: tokenTypeAccess, |
||||
scope: "openid", |
||||
connectorID: "nonexistent", |
||||
useValidToken: true, |
||||
expectedCode: http.StatusBadRequest, |
||||
}, |
||||
{ |
||||
name: "invalid-signature", |
||||
subjectTokenType: tokenTypeAccess, |
||||
requestedTokenType: tokenTypeAccess, |
||||
scope: "openid", |
||||
connectorID: "ssh", |
||||
useBadSignature: true, |
||||
expectedCode: http.StatusUnauthorized, |
||||
}, |
||||
} |
||||
return subtests |
||||
} |
||||
|
||||
// runTokenExchangeSubtests runs the standard set of token exchange subtests
|
||||
// against a server backed by the given storage.
|
||||
func runTokenExchangeSubtests( |
||||
t *testing.T, |
||||
s storage.Storage, |
||||
pubKeyStr string, |
||||
validSigner ssh.Signer, |
||||
badSigner ssh.Signer, |
||||
) { |
||||
t.Helper() |
||||
|
||||
httpServer, server := newTestServerWithStorage(t, s, pubKeyStr) |
||||
defer httpServer.Close() |
||||
|
||||
for _, tc := range standardTokenExchangeSubtests() { |
||||
t.Run(tc.name, func(t *testing.T) { |
||||
var subjectToken string |
||||
switch { |
||||
case tc.omitSubjectToken: |
||||
subjectToken = "" |
||||
case tc.useBadSignature: |
||||
subjectToken = generateTestSSHJWT(t, badSigner, "testuser", "test-ssh-client", httpServer.URL, "ssh-test-client") |
||||
case tc.useValidToken: |
||||
subjectToken = generateTestSSHJWT(t, validSigner, "testuser", "test-ssh-client", httpServer.URL, "ssh-test-client") |
||||
} |
||||
|
||||
rr := doTokenExchange( |
||||
t, server, httpServer.URL, |
||||
subjectToken, tc.connectorID, |
||||
"ssh-test-client", "ssh-test-secret", |
||||
tc.subjectTokenType, tc.requestedTokenType, |
||||
tc.scope, tc.audience, |
||||
) |
||||
|
||||
require.Equal(t, tc.expectedCode, rr.Code, "unexpected status code: %s", rr.Body.String()) |
||||
require.Equal(t, "application/json", rr.Result().Header.Get("Content-Type")) |
||||
|
||||
if tc.expectedCode == http.StatusOK { |
||||
var res accessTokenResponse |
||||
err := json.NewDecoder(rr.Result().Body).Decode(&res) |
||||
require.NoError(t, err, "failed to decode response") |
||||
require.Equal(t, tc.expectedTokenType, res.IssuedTokenType) |
||||
require.NotEmpty(t, res.AccessToken, "access_token should not be empty") |
||||
require.Equal(t, "bearer", res.TokenType) |
||||
require.Greater(t, res.ExpiresIn, 0, "expires_in should be positive") |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
// TestTokenExchangeSSH_SQLite tests the full SSH token exchange flow using SQLite in-memory storage.
|
||||
// This test always runs (no env vars required).
|
||||
func TestTokenExchangeSSH_SQLite(t *testing.T) { |
||||
pubKeyStr, validSigner := generateTestSSHKeyPair(t) |
||||
_, badSigner := generateTestSSHKeyPair(t) |
||||
|
||||
logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) |
||||
cfg := ent.SQLite3{File: ":memory:"} |
||||
s, err := cfg.Open(logger) |
||||
require.NoError(t, err, "failed to open SQLite storage") |
||||
|
||||
runTokenExchangeSubtests(t, s, pubKeyStr, validSigner, badSigner) |
||||
} |
||||
|
||||
// TestTokenExchangeSSH_Postgres tests the full SSH token exchange flow using PostgreSQL storage.
|
||||
// Gated by DEX_POSTGRES_ENT_HOST environment variable.
|
||||
func TestTokenExchangeSSH_Postgres(t *testing.T) { |
||||
host := os.Getenv("DEX_POSTGRES_ENT_HOST") |
||||
if host == "" { |
||||
t.Skipf("test environment variable DEX_POSTGRES_ENT_HOST not set, skipping") |
||||
} |
||||
|
||||
port := uint64(5432) |
||||
if rawPort := os.Getenv("DEX_POSTGRES_ENT_PORT"); rawPort != "" { |
||||
var parseErr error |
||||
port, parseErr = strconv.ParseUint(rawPort, 10, 32) |
||||
require.NoError(t, parseErr, "invalid postgres port %q", rawPort) |
||||
} |
||||
|
||||
logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) |
||||
cfg := ent.Postgres{ |
||||
NetworkDB: ent.NetworkDB{ |
||||
Database: envOrDefault("DEX_POSTGRES_ENT_DATABASE", "postgres"), |
||||
User: envOrDefault("DEX_POSTGRES_ENT_USER", "postgres"), |
||||
Password: envOrDefault("DEX_POSTGRES_ENT_PASSWORD", "postgres"), |
||||
Host: host, |
||||
Port: uint16(port), |
||||
}, |
||||
SSL: ent.SSL{Mode: "disable"}, |
||||
} |
||||
s, err := cfg.Open(logger) |
||||
require.NoError(t, err, "failed to open Postgres storage") |
||||
|
||||
pubKeyStr, validSigner := generateTestSSHKeyPair(t) |
||||
_, badSigner := generateTestSSHKeyPair(t) |
||||
|
||||
runTokenExchangeSubtests(t, s, pubKeyStr, validSigner, badSigner) |
||||
} |
||||
|
||||
// TestTokenExchangeSSH_MySQL tests the full SSH token exchange flow using MySQL storage.
|
||||
// Gated by DEX_MYSQL_ENT_HOST environment variable.
|
||||
func TestTokenExchangeSSH_MySQL(t *testing.T) { |
||||
host := os.Getenv("DEX_MYSQL_ENT_HOST") |
||||
if host == "" { |
||||
t.Skipf("test environment variable DEX_MYSQL_ENT_HOST not set, skipping") |
||||
} |
||||
|
||||
port := uint64(3306) |
||||
if rawPort := os.Getenv("DEX_MYSQL_ENT_PORT"); rawPort != "" { |
||||
var parseErr error |
||||
port, parseErr = strconv.ParseUint(rawPort, 10, 32) |
||||
require.NoError(t, parseErr, "invalid mysql port %q", rawPort) |
||||
} |
||||
|
||||
logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) |
||||
cfg := ent.MySQL{ |
||||
NetworkDB: ent.NetworkDB{ |
||||
Database: envOrDefault("DEX_MYSQL_ENT_DATABASE", "mysql"), |
||||
User: envOrDefault("DEX_MYSQL_ENT_USER", "mysql"), |
||||
Password: envOrDefault("DEX_MYSQL_ENT_PASSWORD", "mysql"), |
||||
Host: host, |
||||
Port: uint16(port), |
||||
}, |
||||
SSL: ent.SSL{Mode: "false"}, |
||||
} |
||||
s, err := cfg.Open(logger) |
||||
require.NoError(t, err, "failed to open MySQL storage") |
||||
|
||||
pubKeyStr, validSigner := generateTestSSHKeyPair(t) |
||||
_, badSigner := generateTestSSHKeyPair(t) |
||||
|
||||
runTokenExchangeSubtests(t, s, pubKeyStr, validSigner, badSigner) |
||||
} |
||||
|
||||
// TestTokenExchangeSSH_InMemory tests the full SSH token exchange flow using in-memory storage.
|
||||
// This verifies the SSH connector works through the full server stack with the default storage.
|
||||
func TestTokenExchangeSSH_InMemory(t *testing.T) { |
||||
pubKeyStr, validSigner := generateTestSSHKeyPair(t) |
||||
_, badSigner := generateTestSSHKeyPair(t) |
||||
|
||||
logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) |
||||
s := memory.New(logger) |
||||
|
||||
runTokenExchangeSubtests(t, s, pubKeyStr, validSigner, badSigner) |
||||
} |
||||
|
||||
// TestTokenExchangeSSH_LDAPCoexistence tests that the SSH connector works correctly
|
||||
// when an LDAP connector is also registered. This verifies that connector routing
|
||||
// dispatches token exchange requests to the correct connector.
|
||||
func TestTokenExchangeSSH_LDAPCoexistence(t *testing.T) { |
||||
pubKeyStr, validSigner := generateTestSSHKeyPair(t) |
||||
|
||||
logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) |
||||
s := memory.New(logger) |
||||
|
||||
var srv *Server |
||||
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
srv.ServeHTTP(w, r) |
||||
})) |
||||
defer httpServer.Close() |
||||
|
||||
ctx := t.Context() |
||||
|
||||
config := Config{ |
||||
Issuer: httpServer.URL, |
||||
Storage: s, |
||||
Web: WebConfig{ |
||||
Dir: "../web", |
||||
}, |
||||
Logger: logger, |
||||
PrometheusRegistry: prometheus.NewRegistry(), |
||||
HealthChecker: gosundheit.New(), |
||||
SkipApprovalScreen: true, |
||||
AllowedGrantTypes: []string{ |
||||
grantTypeAuthorizationCode, |
||||
grantTypeRefreshToken, |
||||
grantTypeTokenExchange, |
||||
}, |
||||
} |
||||
|
||||
// Register SSH connector
|
||||
sshConfig := sshConnectorJSON(t, pubKeyStr, httpServer.URL) |
||||
err := s.CreateConnector(ctx, storage.Connector{ |
||||
ID: "ssh", |
||||
Type: "ssh", |
||||
Name: "SSH", |
||||
ResourceVersion: "1", |
||||
Config: sshConfig, |
||||
}) |
||||
require.NoError(t, err, "failed to create SSH connector") |
||||
|
||||
// Register LDAP connector (minimal config — just needs to exist in storage for routing tests)
|
||||
ldapConfig, err := json.Marshal(map[string]interface{}{ |
||||
"host": "ldap.example.com:389", |
||||
"insecureNoSSL": true, |
||||
"bindDN": "cn=admin,dc=example,dc=org", |
||||
"bindPW": "admin", |
||||
"userSearch": map[string]interface{}{ |
||||
"baseDN": "ou=People,dc=example,dc=org", |
||||
"username": "cn", |
||||
"idAttr": "DN", |
||||
"emailAttr": "mail", |
||||
"nameAttr": "cn", |
||||
}, |
||||
}) |
||||
require.NoError(t, err, "failed to marshal LDAP config") |
||||
|
||||
err = s.CreateConnector(ctx, storage.Connector{ |
||||
ID: "ldap", |
||||
Type: "ldap", |
||||
Name: "LDAP", |
||||
ResourceVersion: "1", |
||||
Config: ldapConfig, |
||||
}) |
||||
require.NoError(t, err, "failed to create LDAP connector") |
||||
|
||||
sig, sigErr := signer.NewMockSigner(testKey) |
||||
require.NoError(t, sigErr, "failed to create mock signer") |
||||
config.Signer = sig |
||||
|
||||
// Create OAuth2 client
|
||||
err = s.CreateClient(ctx, storage.Client{ |
||||
ID: "ssh-test-client", |
||||
Secret: "ssh-test-secret", |
||||
Name: "SSH Test Client", |
||||
}) |
||||
require.NoError(t, err, "failed to create test client") |
||||
|
||||
srv, err = newServer(ctx, config) |
||||
require.NoError(t, err, "failed to create server") |
||||
|
||||
srv.refreshTokenPolicy, err = NewRefreshTokenPolicy(logger, false, "", "", "") |
||||
require.NoError(t, err, "failed to create refresh token policy") |
||||
srv.refreshTokenPolicy.now = time.Now |
||||
|
||||
t.Run("ssh-connector-routes-correctly", func(t *testing.T) { |
||||
subjectToken := generateTestSSHJWT(t, validSigner, "testuser", "test-ssh-client", httpServer.URL, "ssh-test-client") |
||||
rr := doTokenExchange( |
||||
t, srv, httpServer.URL, |
||||
subjectToken, "ssh", |
||||
"ssh-test-client", "ssh-test-secret", |
||||
tokenTypeAccess, tokenTypeAccess, |
||||
"openid", "", |
||||
) |
||||
require.Equal(t, http.StatusOK, rr.Code, "SSH token exchange should succeed: %s", rr.Body.String()) |
||||
|
||||
var res accessTokenResponse |
||||
err := json.NewDecoder(rr.Result().Body).Decode(&res) |
||||
require.NoError(t, err) |
||||
require.NotEmpty(t, res.AccessToken) |
||||
require.Equal(t, tokenTypeAccess, res.IssuedTokenType) |
||||
}) |
||||
|
||||
t.Run("ldap-connector-rejects-token-exchange", func(t *testing.T) { |
||||
// LDAP connector does not implement TokenIdentityConnector, so token exchange should fail
|
||||
subjectToken := generateTestSSHJWT(t, validSigner, "testuser", "test-ssh-client", httpServer.URL, "ssh-test-client") |
||||
rr := doTokenExchange( |
||||
t, srv, httpServer.URL, |
||||
subjectToken, "ldap", |
||||
"ssh-test-client", "ssh-test-secret", |
||||
tokenTypeAccess, tokenTypeAccess, |
||||
"openid", "", |
||||
) |
||||
require.Equal(t, http.StatusBadRequest, rr.Code, "LDAP connector should reject token exchange") |
||||
}) |
||||
|
||||
t.Run("nonexistent-connector-returns-error", func(t *testing.T) { |
||||
subjectToken := generateTestSSHJWT(t, validSigner, "testuser", "test-ssh-client", httpServer.URL, "ssh-test-client") |
||||
rr := doTokenExchange( |
||||
t, srv, httpServer.URL, |
||||
subjectToken, "nonexistent", |
||||
"ssh-test-client", "ssh-test-secret", |
||||
tokenTypeAccess, tokenTypeAccess, |
||||
"openid", "", |
||||
) |
||||
require.Equal(t, http.StatusBadRequest, rr.Code, "nonexistent connector should return error") |
||||
}) |
||||
} |
||||
|
||||
// envOrDefault returns the environment variable value or a default.
|
||||
func envOrDefault(key, defaultVal string) (val string) { |
||||
val = os.Getenv(key) |
||||
if val == "" { |
||||
val = defaultVal |
||||
} |
||||
return val |
||||
} |
||||
Loading…
Reference in new issue