Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions README.MD
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# keyrad: Keycloak RADIUS Server

A Go-based RADIUS server that authenticates users against Keycloak, supporting password and OTP (TOTP) flows.
A Go-based RADIUS server that authenticates users against Keycloak, supporting password and OTP (TOTP) flows.

Features include:
- Challenge-response for OTP
- YAML-based Keycloak and RADIUS config
- FreeRADIUS-style `clients.conf` for client secrets
- Optional Message-Authenticator
- Scope/group-to-RADIUS attribute mapping (with regexp support)
- **Vendor-Specific Attributes (VSA):** Return vendor-specific attributes (e.g. MikroTik, Cisco, Ubiquiti) in Access-Accept responses based on user roles, groups, or scopes
- **Configurable OTP mode:** Use standard challenge-response or <password><otp> style
- **Configurable OTP challenge message** via `otp_challenge_message` in config
- **Asynchronous/multi-request support:** Multiple RADIUS requests are handled concurrently for high performance
Expand Down Expand Up @@ -41,11 +42,78 @@ go build -o keyrad main.go
- Users must have credentials set for password or OTP (TOTP) as desired.
- To use OTP, users must enroll an OTP device in Keycloak.

### Configuration
See `clients.conf` and `keyrad.yaml`.
### 2. keyrad.yaml
See `keyrad.yaml` for a full example.

- To customize the OTP challenge message, add `otp_challenge_message: "Your custom message"` to `keyrad.yaml`.
- The server now supports multiple concurrent RADIUS requests for high performance (async worker pool, default 8 workers).
- The server supports multiple concurrent RADIUS requests (async worker pool, default 8 workers).

### 3. clients.conf
FreeRADIUS-style client definitions. See `clients.conf` for the format.

### 4. Scope-to-RADIUS Attribute Mapping

Map Keycloak realm roles, groups, or OAuth2 scopes to RADIUS attributes returned in Access-Accept responses. Supports both standard RADIUS attributes and Vendor-Specific Attributes (VSA).

After successful authentication, keyrad extracts the user's roles, groups, and scopes from the Keycloak JWT access token and matches them against the `scope_radius_map` keys.

#### Configuration format

```yaml
scope_radius_map:
# Exact match against role/group/scope name
admin:
- attribute: 6 # Service-Type
value: "6" # Administrative-User
value_type: integer

# Multiple attributes per scope
vpn:
- attribute: 8 # Framed-IP-Address
value: "10.0.0.1"
value_type: ipaddr
- vendor: 14988 # MikroTik Vendor ID
attribute: 3 # Mikrotik-Group
value: "full"

# Vendor-Specific Attributes (VSA)
wifi:
- vendor: 14988 # MikroTik
attribute: 8 # Mikrotik-Rate-Limit
value: "10M/10M"

# Regex matching with "re:" prefix
re:^group_.*:
- attribute: 11 # Filter-Id
value: "group-member"
```

#### Attribute fields

| Field | Required | Description |
|--------------|----------|--------------------------------------------------------------------|
| `vendor` | No | IANA vendor ID. Omit or set to `0` for standard RADIUS attributes. |
| `attribute` | Yes | Attribute type number. |
| `value` | Yes | Attribute value. |
| `value_type` | No | Encoding type: `string` (default), `integer`, or `ipaddr`. |

#### Common vendor IDs

| Vendor | ID | Reference |
|----------|-------|--------------------------------------------------------------|
| Cisco | 9 | [Cisco VSA documentation](https://www.cisco.com) |
| Microsoft| 311 | RFC 2548 |
| MikroTik | 14988 | [MikroTik RADIUS dictionary](https://help.mikrotik.com/docs/spaces/ROS/pages/328097/RADIUS) |
| Ubiquiti | 41112 | Ubiquiti RADIUS dictionary |

#### Keycloak setup for scope/group matching

For roles and scopes, no extra Keycloak configuration is needed -- they are included in the JWT by default.

For **group-based matching**, add a "groups" protocol mapper to your Keycloak client:
1. Go to your client's **Client Scopes** > **Dedicated scope** > **Add mapper** > **By configuration** > **Group Membership**.
2. Set **Token Claim Name** to `groups`.
3. Disable **Full group path** if you want short group names.

## Usage

Expand All @@ -67,7 +135,8 @@ radtest testuser password 127.0.0.1 0 test
## Advanced Features
- **Challenge-response**: Standard RADIUS Access-Challenge for OTP, or disable for legacy OTP style.
- **Configurable OTP challenge message**: Set `otp_challenge_message` in your config for custom challenge text.
- **Scope/group mapping**: Map Keycloak OAuth2 scopes or group memberships to RADIUS attributes. Supports regexp keys (e.g. `re:^group_.*`).
- **Scope/group mapping**: Map Keycloak realm roles, groups, or OAuth2 scopes to RADIUS attributes. Supports regexp keys (e.g. `re:^group_.*`).
- **Vendor-Specific Attributes (VSA)**: Return vendor-specific attributes in Access-Accept based on user roles/groups. Supports any vendor by IANA vendor ID (e.g. MikroTik 14988, Cisco 9, Microsoft 311).
- **TLS verification**: Set `insecure_skip_tls_verify: true` for self-signed Keycloak certs.
- **Asynchronous/multi-request support**: Multiple RADIUS requests are handled concurrently for high performance.

Expand Down
10 changes: 7 additions & 3 deletions auth/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ type ClientConfig struct {
IPAddr string
}

type ScopeRadiusMapping map[string]struct {
Attribute int `yaml:"attribute"`
Value string `yaml:"value"`
type RadiusAttribute struct {
Vendor uint32 `yaml:"vendor"` // Vendor ID (0 = standard attribute)
Attribute int `yaml:"attribute"` // Attribute type number
Value string `yaml:"value"` // Attribute value
ValueType string `yaml:"value_type"` // "string" (default), "integer", "ipaddr"
}

type ScopeRadiusMapping map[string][]RadiusAttribute

func ParseClientsConf(path string) (map[string]ClientConfig, error) {
file, err := os.Open(path)
if err != nil {
Expand Down
76 changes: 64 additions & 12 deletions keycloak/keycloak.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package keycloak

import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)

type KeycloakAPI struct {
Expand All @@ -29,21 +31,23 @@ func (k *KeycloakAPI) GetAdminToken() (string, error) {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", fmt.Errorf("keycloak admin token error: %s", string(body))
return "", fmt.Errorf("keycloak admin token error: status %d", resp.StatusCode)
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
token, ok := result["access_token"].(string)
if !ok {
return "", fmt.Errorf("no access_token in admin token response: %s", string(body))
return "", fmt.Errorf("no access_token in admin token response")
}
return token, nil
}

// AuthenticateUser checks username/password (and optional OTP) against Keycloak
func (k *KeycloakAPI) AuthenticateUser(username, password string, otp ...string) (bool, error) {
// AuthenticateUser checks username/password (and optional OTP) against Keycloak.
// Returns (ok, userRoles, error) where userRoles are extracted from the JWT access token
// and include realm roles, groups, and OAuth2 scopes.
func (k *KeycloakAPI) AuthenticateUser(username, password string, otp ...string) (bool, []string, error) {
data := url.Values{}
data.Set("grant_type", "password")
data.Set("client_id", k.ClientID)
Expand All @@ -53,20 +57,62 @@ func (k *KeycloakAPI) AuthenticateUser(username, password string, otp ...string)
if len(otp) > 0 && otp[0] != "" {
data.Set("totp", otp[0])
}
fmt.Printf("[DEBUG] Keycloak Auth Request: %s\n", data.Encode())
fmt.Printf("[DEBUG] Keycloak Auth Request for user: %s\n", username)
fmt.Printf("[DEBUG] Keycloak Token URL: %s\n", k.TokenURL)
resp, err := k.HTTPClient.PostForm(k.TokenURL, data)
if err != nil {
return false, err
return false, nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("[DEBUG] Keycloak Response Status: %d\n", resp.StatusCode)
fmt.Printf("[DEBUG] Keycloak Response Body: %s\n", string(body))
if resp.StatusCode == 200 {
return true, nil
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return false, nil, fmt.Errorf("failed to parse keycloak response: %w", err)
}
accessToken, _ := result["access_token"].(string)
roles := extractRolesFromJWT(accessToken)
return true, roles, nil
}
return false, fmt.Errorf("keycloak auth failed: %s", string(body))
return false, nil, fmt.Errorf("keycloak auth failed: status %d", resp.StatusCode)
}

// extractRolesFromJWT decodes the JWT payload and extracts realm roles, groups, and scopes.
func extractRolesFromJWT(token string) []string {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil
}
payload := parts[1]
// Add base64 padding if needed
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
return nil
}
var claims struct {
RealmAccess struct {
Roles []string `json:"roles"`
} `json:"realm_access"`
Groups []string `json:"groups"`
Scope string `json:"scope"`
}
if err := json.Unmarshal(decoded, &claims); err != nil {
return nil
}
var roles []string
roles = append(roles, claims.RealmAccess.Roles...)
roles = append(roles, claims.Groups...)
if claims.Scope != "" {
roles = append(roles, strings.Split(claims.Scope, " ")...)
}
return roles
}

// HasOTP returns true if the user has an OTP authenticator assigned in Keycloak
Expand All @@ -75,8 +121,11 @@ func (k *KeycloakAPI) HasOTP(username string) (bool, error) {
if err != nil {
return false, err
}
url := fmt.Sprintf("%s/users?username=%s", k.APIURL, url.QueryEscape(username))
req, _ := http.NewRequest("GET", url, nil)
reqURL := fmt.Sprintf("%s/users?username=%s", k.APIURL, url.QueryEscape(username))
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return false, fmt.Errorf("failed to create user lookup request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := k.HTTPClient.Do(req)
if err != nil {
Expand All @@ -90,7 +139,10 @@ func (k *KeycloakAPI) HasOTP(username string) (bool, error) {
userID, _ := users[0]["id"].(string)
// Get credentials for user
credURL := fmt.Sprintf("%s/users/%s/credentials", k.APIURL, userID)
req, _ = http.NewRequest("GET", credURL, nil)
req, err = http.NewRequest("GET", credURL, nil)
if err != nil {
return false, fmt.Errorf("failed to create credentials request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err = k.HTTPClient.Do(req)
if err != nil {
Expand Down
Binary file modified keyrad
Binary file not shown.
40 changes: 29 additions & 11 deletions keyrad.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,41 @@ api_url: "https://<>/admin/realms/<>" # this is the admin API URL
insecure_skip_tls_verify: false
otp_challenge_message: "Please enter your OTP code"

# RADIUS attribute mappings based on user scopes
# RADIUS attribute mappings based on user roles/groups/scopes.
# Each scope key maps to a list of attributes (standard or vendor-specific).
# Scope keys are matched against Keycloak realm roles, groups, and OAuth2 scopes.
# Use "re:" prefix for regex matching (e.g. "re:^group_.*").
#
# Attribute fields:
# vendor: Vendor ID (omit or 0 for standard attributes)
# attribute: Attribute type number
# value: Attribute value
# value_type: "string" (default), "integer", or "ipaddr"
scope_radius_map:
admin:
attribute: 6 # Service-Type
value: "6" # Administrative-User
- attribute: 6 # Service-Type
value: "6" # Administrative-User
value_type: integer
vpn:
attribute: 8 # Framed-IP-Address
value: "10.0.0.1"
- attribute: 8 # Framed-IP-Address
value: "10.0.0.1"
value_type: ipaddr
- vendor: 14988 # MikroTik
attribute: 3 # Mikrotik-Group
value: "full"
wifi:
attribute: 64 # Tunnel-Type
value: "VLAN"
- attribute: 64 # Tunnel-Type
value: "13" # VLAN
value_type: integer
- vendor: 14988 # MikroTik
attribute: 8 # Mikrotik-Rate-Limit
value: "10M/10M"
group_admins:
attribute: 11 # Filter-Id
value: "admins"
- attribute: 11 # Filter-Id
value: "admins"
re:^group_.*:
attribute: 11
value: "group-member"
- attribute: 11 # Filter-Id
value: "group-member"

# Example: Listen on a specific interface/port
listen_addr: "127.0.0.1:1812"
7 changes: 2 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import (
"gopkg.in/yaml.v3"
)

const Version = "1.0.0"
const Version = "1.1.0dev1"
const Author = "Marco Moenig <marco@sec73.io>"

func main() {
Expand All @@ -39,7 +39,6 @@ func main() {
var disableMessageAuthenticator bool
var disableChallengeResponse bool
var debug bool
var mschapEnabled bool
var papEnabled bool

flag.StringVar(&keycloakConfigPath, "c", "keyrad.yaml", "Path to keyrad.yaml config file")
Expand All @@ -48,8 +47,7 @@ func main() {
flag.BoolVar(&disableChallengeResponse, "disable-challenge-response", false, "Disable RADIUS challenge-response and use <password><otp> style for OTP users")
flag.BoolVar(&debug, "debug", false, "Enable debug output for RADIUS and Keycloak communication")
flag.BoolVar(&showVersion, "version", false, "Show version and author information")
flag.BoolVar(&mschapEnabled, "mschap", true, "Enable MS-CHAPv2 authentication")
flag.BoolVar(&papEnabled, "pap", true, "Enable PAP authentication")
flag.BoolVar(&papEnabled, "pap", true, "Enable PAP authentication")
flag.Parse()
if showVersion {
fmt.Printf("keyrad version %s\nAuthor: %s\n", Version, Author)
Expand Down Expand Up @@ -103,7 +101,6 @@ func main() {
DisableMsgAuth: disableMessageAuthenticator,
DisableChallenge: disableChallengeResponse,
Debug: debug,
MSCHAPEnabled: mschapEnabled,
PAPEnabled: papEnabled,
}
listenAddr := keycloakConfig.ListenAddr
Expand Down
Loading
Loading