Browse Source

Merge f285d02907 into 548b0f54e8

pull/4521/merge
dronenb 1 month ago committed by GitHub
parent
commit
39d1581401
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 422
      connector/microsoft/TESTING.md
  2. 83
      connector/microsoft/microsoft.go
  3. 51
      connector/microsoft/microsoft_test.go

422
connector/microsoft/TESTING.md

@ -0,0 +1,422 @@
# Testing the Microsoft Connector
This guide covers how to test the Microsoft connector with both client secret and client assertion authentication methods.
## Prerequisites
- `az` CLI installed and logged in (`az login`)
- `openssl` (for generating keypairs)
- Dex built locally (`make build examples`)
- `jq` (for JSON parsing)
- `kubectl` - for testing using Federated Credential
## Setup Azure AD Application
First, create an Azure AD application that will be used for testing:
```bash
# Set variables
APP_NAME="dex-test-$(date +%s)"
REDIRECT_URI="http://127.0.0.1:5556/dex/callback"
# Create the application
APP_JSON=$(az ad app create \
--display-name "${APP_NAME}" \
--sign-in-audience AzureADMyOrg \
--web-redirect-uris "${REDIRECT_URI}" \
--query '{appId:appId,displayName:displayName}' \
-o json)
APP_ID=$(echo "${APP_JSON}" | jq -r .appId)
# Get your tenant ID
TENANT_ID=$(az account show --query tenantId -o tsv)
```
Add Microsoft Graph API permissions:
```bash
# Add User.Read and Directory.Read.All permissions
az ad app permission add \
--id "${APP_ID}" \
--api 00000003-0000-0000-c000-000000000000 \
--api-permissions \
e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope \
06da0dbc-49e2-44d2-8312-53f166ab848a=Scope
# Note: Admin consent may be required for Directory.Read.All
# You can grant it via Azure Portal or with admin permissions:
az ad app permission admin-consent --id "${APP_ID}"
```
## Method 1: Testing with Client Secret
### Create Client Secret
```bash
# Create a client secret (expires in 1 year)
SECRET_JSON=$(az ad app credential reset \
--id "${APP_ID}" \
--append \
--years 1 \
--query '{secret:password}' \
-o json)
CLIENT_SECRET=$(echo "${SECRET_JSON}" | jq -r '.secret')
```
### Create Dex Configuration for Client Secret
```bash
# Create temporary config file with client secret
CONFIG_FILE=$(mktemp -t dex-config-secret.yaml)
cat > "${CONFIG_FILE}" <<EOF
issuer: http://127.0.0.1:5556/dex
storage:
type: sqlite3
config:
file: var/dex.db
web:
http: 127.0.0.1:5556
telemetry:
http: 127.0.0.1:5558
staticClients:
- id: example-app
redirectURIs:
- 'http://127.0.0.1:5555/callback'
name: 'Example App'
secret: ZXhhbXBsZS1hcHAtc2VjcmV0
connectors:
- type: microsoft
id: microsoft
name: Microsoft
config:
clientID: "${APP_ID}"
clientSecret: "${CLIENT_SECRET}"
tenant: "${TENANT_ID}"
redirectURI: http://127.0.0.1:5556/dex/callback
enablePasswordDB: true
EOF
```
### Run the Test with Client Secret
```bash
# Terminal 1: Start Dex
./bin/dex serve "${CONFIG_FILE}"
# Terminal 2: Start example app
./bin/example-app --issuer http://127.0.0.1:5556/dex
# Open browser to http://127.0.0.1:5555 and test login with the following:
echo "Test the config with the following:"
echo "Authenticate for: example-app"
echo "Connector ID: microsoft"
# When done, remove the temporary config file
rm "${CONFIG_FILE}"
```
## Method 2: Testing with Client Assertion (JWT)
### Generate RSA Keypair and Certificate
```bash
# Create temporary directory for keys
KEYS_DIR=$(mktemp -d -t microsoft-test-keys.XXXXXX)
# Generate RSA private key (2048-bit)
openssl genrsa -out "${KEYS_DIR}/private.pem" 2048
# Create a self-signed certificate
openssl req -new -x509 \
-key "${KEYS_DIR}/private.pem" \
-out "${KEYS_DIR}/cert.pem" \
-days 365 \
-subj "/CN=dex-test"
```
### Upload Public Certificate to Azure AD
```bash
# Upload certificate to Azure AD application
az ad app credential reset \
--id "${APP_ID}" \
--cert "@${KEYS_DIR}/cert.pem" \
--append
# Get certificate thumbprint (for JWT header)
THUMBPRINT=$(openssl x509 -in "${KEYS_DIR}/cert.pem" \
-fingerprint -noout -sha1 | sed 's/://g' | cut -d= -f2)
```
### Create JWT Generation Script
Create the JWT generation script:
```bash
cat > "${KEYS_DIR}/generate-jwt.sh" <<'SCRIPT'
#!/bin/bash
set -e
PRIVATE_KEY="$1"
CERT="$2"
APP_ID="$3"
TENANT_ID="$4"
if [[ -z "${PRIVATE_KEY}" || -z "${CERT}" || -z "${APP_ID}" || -z "${TENANT_ID}" ]]; then
echo "Usage: $0 <private-key> <cert> <app-id> <tenant-id>"
exit 1
fi
# Base64URL encode function
base64url() {
openssl base64 -e -A | tr '+/' '-_' | tr -d '='
}
# Get certificate thumbprint for x5t header
X5T=$(openssl x509 -in "${CERT}" -fingerprint -noout -sha1 | \
sed 's/://g' | cut -d= -f2 | xxd -r -p | base64url)
# JWT Header
HEADER=$(echo -n "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"x5t\":\"${X5T}\"}" | base64url)
# JWT Claims
NOW=$(date +%s)
EXP=$((NOW + 3600)) # Expires in 1 hour
JTI=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "$(date +%s)-$$")
CLAIMS=$(cat <<EOF | tr -d '\n' | tr -d ' '
{
"aud": "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token",
"exp": ${EXP},
"iss": "${APP_ID}",
"jti": "${JTI}",
"nbf": ${NOW},
"sub": "${APP_ID}",
"iat": ${NOW}
}
EOF
)
CLAIMS_B64=$(echo -n "${CLAIMS}" | base64url)
# Create signature
SIGNATURE=$(echo -n "${HEADER}.${CLAIMS_B64}" | \
openssl dgst -sha256 -sign "${PRIVATE_KEY}" -binary | base64url)
# Output JWT
echo "${HEADER}.${CLAIMS_B64}.${SIGNATURE}"
SCRIPT
chmod +x "${KEYS_DIR}/generate-jwt.sh"
```
### Generate JWT Assertion
```bash
# Generate JWT assertion
"${KEYS_DIR}/generate-jwt.sh" \
"${KEYS_DIR}/private.pem" \
"${KEYS_DIR}/cert.pem" \
"${APP_ID}" \
"${TENANT_ID}" \
> "${KEYS_DIR}/assertion.jwt"
```
### Create Dex Configuration for Client Assertion
```bash
# Create temporary config file with client assertion
CONFIG_FILE=$(mktemp -t dex-config-assertion.XXXXXX.yaml)
cat > "${CONFIG_FILE}" <<EOF
issuer: http://127.0.0.1:5556/dex
storage:
type: sqlite3
config:
file: var/dex-assertion.db
web:
http: 127.0.0.1:5556
telemetry:
http: 127.0.0.1:5558
staticClients:
- id: example-app
redirectURIs:
- 'http://127.0.0.1:5555/callback'
name: 'Example App'
secret: ZXhhbXBsZS1hcHAtc2VjcmV0
connectors:
- type: microsoft
id: microsoft-assertion
name: Microsoft (Client Assertion)
config:
clientID: "${APP_ID}"
clientAssertion: "${KEYS_DIR}/assertion.jwt"
tenant: "${TENANT_ID}"
redirectURI: http://127.0.0.1:5556/dex/callback
enablePasswordDB: true
EOF
```
### Run the Test with Client Assertion
```bash
# Regenerate JWT before each test (they expire after 1 hour)
"${KEYS_DIR}/generate-jwt.sh" \
"${KEYS_DIR}/private.pem" \
"${KEYS_DIR}/cert.pem" \
"${APP_ID}" \
"${TENANT_ID}" \
> "${KEYS_DIR}/assertion.jwt"
# Terminal 1: Start Dex
./bin/dex serve "${CONFIG_FILE}"
# Terminal 2: Start example app
./bin/example-app --issuer http://127.0.0.1:5556/dex
# Open browser to http://127.0.0.1:5555 and test login
echo "Test the config with the following:"
echo "Authenticate for: example-app"
echo "Connector ID: microsoft-assertion"
# When done, remove the temporary files
rm "${CONFIG_FILE}"
rm -rf "${KEYS_DIR}"
```
## Method 3: Testing with Client Assertion Using Kubernetes Workload Identity
If you have a Kubernetes cluster with a publicly available OIDC issuer configured for service accounts:
### Create Service Account
```bash
kubectl create serviceaccount dex-test -n default
```
### Configure Federated Credential in Azure
```bash
# Get your Kubernetes OIDC issuer
K8S_ISSUER=$(kubectl get --raw /.well-known/openid-configuration | jq -r '.issuer')
# Create federated credential
az ad app federated-credential create \
--id "${APP_ID}" \
--parameters "{
\"name\": \"k8s-dex-test\",
\"issuer\": \"${K8S_ISSUER}\",
\"subject\": \"system:serviceaccount:default:dex-test\",
\"audiences\": [\"api://AzureADTokenExchange\"]
}"
```
### Generate Kubernetes Token
```bash
# Create temporary file for the Kubernetes token
K8S_TOKEN_FILE=$(mktemp -t k8s-token.jwt)
# Create token with correct audience
kubectl create token dex-test \
-n default \
--duration=1h \
--audience=api://AzureADTokenExchange \
> "${K8S_TOKEN_FILE}"
```
### Create Dex Configuration for Kubernetes
```bash
# Create temporary config file with Kubernetes token
CONFIG_FILE=$(mktemp -t dex-config-k8s.yaml)
cat > "${CONFIG_FILE}" <<EOF
issuer: http://127.0.0.1:5556/dex
storage:
type: sqlite3
config:
file: var/dex-k8s.db
web:
http: 127.0.0.1:5556
telemetry:
http: 127.0.0.1:5558
staticClients:
- id: example-app
redirectURIs:
- 'http://127.0.0.1:5555/callback'
name: 'Example App'
secret: ZXhhbXBsZS1hcHAtc2VjcmV0
connectors:
- type: microsoft
id: microsoft-k8s
name: Microsoft (Kubernetes Workload Identity)
config:
clientID: "${APP_ID}"
clientAssertion: "${K8S_TOKEN_FILE}"
tenant: "${TENANT_ID}"
redirectURI: http://127.0.0.1:5556/dex/callback
enablePasswordDB: true
EOF
```
### Run Dex
```bash
# Terminal 1: Start Dex
./bin/dex serve "${CONFIG_FILE}"
# Terminal 2: Start example app
./bin/example-app --issuer http://127.0.0.1:5556/dex
# Open browser to http://127.0.0.1:5555 and test login
echo "Test the config with the following:"
echo "Authenticate for: example-app"
echo "Connector ID: microsoft-k8s"
# When done, remove the temporary files
rm "${CONFIG_FILE}"
rm "${K8S_TOKEN_FILE}"
```
## Cleanup
```bash
# Delete the Azure AD application
az ad app delete --id "${APP_ID}"
# Delete Kubernetes resources (if created)
kubectl delete serviceaccount dex-test -n default
# Remove temporary files and directories
rm -f var/dex.db var/dex-assertion.db var/dex-k8s.db
[ -n "${CONFIG_FILE}" ] && rm -f "${CONFIG_FILE}"
[ -n "${KEYS_DIR}" ] && rm -rf "${KEYS_DIR}"
[ -n "${K8S_TOKEN_FILE}" ] && rm -f "${K8S_TOKEN_FILE}"
```
## Notes
- JWTs generated with the script are valid for 1 hour
- Dex reads the assertion file on each authentication request, so you can update it without restarting Dex
- For production use with Kubernetes, the token is automatically rotated by the kubelet
- Client assertions are recommended over client secrets for improved security (no secrets to store)

83
connector/microsoft/microsoft.go

@ -10,6 +10,8 @@ import (
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
@ -42,10 +44,11 @@ const (
scopeOfflineAccess = "offline_access"
)
// Config holds configuration options for microsoft logins.
// Config holds configuration options for Microsoft logins.
type Config struct {
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
ClientAssertion string `json:"clientAssertion"`
RedirectURI string `json:"redirectURI"`
Tenant string `json:"tenant"`
OnlySecurityGroups bool `json:"onlySecurityGroups"`
@ -73,6 +76,7 @@ func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, erro
redirectURI: c.RedirectURI,
clientID: c.ClientID,
clientSecret: c.ClientSecret,
clientAssertion: c.ClientAssertion,
tenant: c.Tenant,
onlySecurityGroups: c.OnlySecurityGroups,
groups: c.Groups,
@ -128,6 +132,7 @@ type microsoftConnector struct {
redirectURI string
clientID string
clientSecret string
clientAssertion string
tenant string
onlySecurityGroups bool
groupNameFormat GroupNameFormat
@ -191,6 +196,46 @@ func (c *microsoftConnector) LoginURL(scopes connector.Scopes, callbackURL, stat
return c.oauth2Config(scopes).AuthCodeURL(state, options...), nil
}
// assertionTransport is an http.RoundTripper that intercepts token endpoint requests
// and injects client_assertion parameters while removing client_secret.
type assertionTransport struct {
assertion string
tokenURL string
base http.RoundTripper
}
func (t *assertionTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Only modify requests to the token endpoint
if req.URL.String() != t.tokenURL {
return t.base.RoundTrip(req)
}
// Read the original request body
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %v", err)
}
req.Body.Close()
// Parse the form data
values, err := url.ParseQuery(string(body))
if err != nil {
return nil, fmt.Errorf("failed to parse request body: %v", err)
}
// Remove client_secret and add client_assertion parameters
values.Del("client_secret")
values.Set("client_assertion", t.assertion)
values.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
// Create new request with modified body
newBody := strings.NewReader(values.Encode())
req.Body = io.NopCloser(newBody)
req.ContentLength = int64(len(values.Encode()))
return t.base.RoundTrip(req)
}
func (c *microsoftConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
q := r.URL.Query()
if errType := q.Get("error"); errType != "" {
@ -201,6 +246,24 @@ func (c *microsoftConnector) HandleCallback(s connector.Scopes, r *http.Request)
ctx := r.Context()
// If using client assertion, wrap the HTTP client with a custom transport
if c.clientAssertion != "" {
assertionBytes, err := os.ReadFile(c.clientAssertion)
if err != nil {
return identity, fmt.Errorf("microsoft: failed to read client assertion: %v", err)
}
// Create HTTP client with custom transport that injects client_assertion
httpClient := &http.Client{
Transport: &assertionTransport{
assertion: string(assertionBytes),
tokenURL: oauth2Config.Endpoint.TokenURL,
base: http.DefaultTransport,
},
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
}
token, err := oauth2Config.Exchange(ctx, q.Get("code"))
if err != nil {
return identity, fmt.Errorf("microsoft: failed to get token: %v", err)
@ -290,6 +353,24 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id
Expiry: data.Expiry,
}
// If using client assertion, wrap the HTTP client with a custom transport
if c.clientAssertion != "" {
assertionBytes, err := os.ReadFile(c.clientAssertion)
if err != nil {
return identity, fmt.Errorf("microsoft: failed to read client assertion: %v", err)
}
oauth2Config := c.oauth2Config(s)
httpClient := &http.Client{
Transport: &assertionTransport{
assertion: string(assertionBytes),
tokenURL: oauth2Config.Endpoint.TokenURL,
base: http.DefaultTransport,
},
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
}
client := oauth2.NewClient(ctx, &notifyRefreshTokenSource{
new: c.oauth2Config(s).TokenSource(ctx, tok),
t: tok,

51
connector/microsoft/microsoft_test.go

@ -3,6 +3,7 @@ package microsoft
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
@ -119,6 +120,56 @@ func TestUserGroupsFromGraphAPI(t *testing.T) {
expectEquals(t, identity.Groups, []string{"a", "b"})
}
func TestClientAssertionTokenExchange(t *testing.T) {
assertion := "dummy-jwt-assertion"
file, err := os.CreateTemp("", "assertion.jwt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(file.Name())
file.WriteString(assertion)
file.Close()
tokenCalled := false
var receivedAssertion, receivedSecret string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/testtenant/oauth2/v2.0/token" {
bodyBytes, _ := io.ReadAll(r.Body)
r.Body.Close()
form, _ := url.ParseQuery(string(bodyBytes))
receivedAssertion = form.Get("client_assertion")
receivedSecret = form.Get("client_secret")
tokenCalled = true
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token": "token", "expires_in": 3600}`))
}
}))
defer ts.Close()
conn := microsoftConnector{
apiURL: ts.URL,
graphURL: ts.URL,
redirectURI: "https://test.com",
clientID: clientID,
clientSecret: "should-not-be-used",
tenant: "testtenant",
clientAssertion: file.Name(),
}
req, _ := http.NewRequest("GET", ts.URL, nil)
conn.HandleCallback(connector.Scopes{}, req)
if !tokenCalled {
t.Errorf("Token endpoint was not called")
}
if receivedAssertion != assertion {
t.Errorf("Expected client_assertion to be %q, got %q", assertion, receivedAssertion)
}
if receivedSecret != "" {
t.Errorf("Expected client_secret to be empty, got %q", receivedSecret)
}
}
func newTestServer(responses map[string]testResponse) *httptest.Server {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response, found := responses[r.RequestURI]

Loading…
Cancel
Save