diff --git a/DEBUGGING_TOKEN_INACTIVE.md b/DEBUGGING_TOKEN_INACTIVE.md new file mode 100644 index 0000000..ed0673e --- /dev/null +++ b/DEBUGGING_TOKEN_INACTIVE.md @@ -0,0 +1,145 @@ +# Debugging Token Introspection Issue + +## Problem +Tokens are being marked as "inactive" by Keycloak immediately after working successfully: +- 13:22:00 - Token validates successfully (200) +- 13:22:02 - Same token marked inactive (401) - only 2 seconds later! + +## Changes Made + +Added detailed logging to trace token validation through the system: + +### 1. Keycloak Provider (`server/providers/keycloak/keycloak.go`) +- Logs introspection request details: + - URL being called + - Client ID used for introspection + - Token prefix/suffix (first 20 chars, last 10 chars) + - Token length +- Logs the FULL response from Keycloak's introspection endpoint +- Logs detailed info when token is marked inactive + +### 2. Auth Handler (`server/server.go`) +- Logs where token was extracted from (header vs query param) +- Logs token prefix/suffix and length +- Logs when Keycloak introspection is being used +- Logs successful introspection results (email, scopes, expiry) + +## What to Look For in Logs + +When you reproduce the issue, look for these patterns: + +### 1. Token Consistency +Compare the working request vs failing request: +``` +[Auth] Token extracted from Authorization header token_prefix="eyJhbGciOiJSUzI1NiIs" token_suffix="7WBOQfVRmw" token_length=2025 +``` +- Are the prefix, suffix, and length EXACTLY the same? +- If different, the token being sent is different + +### 2. Client ID Used for Introspection +``` +[Keycloak] Starting token introspection client_id="mcp-server" +``` +- Confirm this matches the client_id that issued the token + +### 3. Keycloak's Response +Look for the full introspection response: +```json +{ + "active": false, + "exp": 1769003499, + "iat": 1769001699, + "sid": "de12c644-f2a9-4448-0b7a-a666ed15229f", + ... +} +``` + +Key fields to check: +- `active`: Should be `true` for valid tokens +- `exp`: Expiration timestamp (should be in the future) +- `iat`: Issued at timestamp +- `sid`: Session ID - does this change between requests? +- Any error fields or additional context + +### 4. Timing Pattern +Look at the timestamps: +- When was the token issued? +- When did the first successful validation occur? +- When did the failed validation occur? +- How much time between success and failure? + +## Theories to Test + +### Theory 1: Session Invalidation +If the `sid` (session ID) field changes or is missing in the failed request, the Keycloak session may have been invalidated. + +### Theory 2: Multiple OAuth Flows +You mentioned "disconnect and reconnect in a different chat session". If your MCP client initiates multiple OAuth flows: +- Starting a new flow might invalidate the previous session +- Check Keycloak session settings: Admin Console → Realm Settings → Sessions + - "SSO Session Idle" + - "SSO Session Max" + - Check if "Revoke Refresh Token" is enabled + +### Theory 3: Token Type Mismatch +Keycloak might be expecting a refresh token but receiving an access token (or vice versa). + +### Theory 4: Client Permissions +Even with matching client_id, check Keycloak client settings: +- Is the client configured as "confidential" or "public"? +- Does it have the right service account roles? +- Check: Clients → mcp-server → Settings → Access Type + +## Next Steps + +1. **Reproduce the issue** with the new logging enabled +2. **Capture the full logs** including: + - Token issuance + - First successful validation + - Failed validation +3. **Compare**: + - Token values (prefix/suffix/length) + - Keycloak responses + - Session IDs +4. **Check Keycloak Admin Console**: + - Sessions tab - look for multiple active sessions for your user + - Events tab - look for SESSION_LOGIN, SESSION_LOGOUT events around the failure time + +## Keycloak Settings to Check + +Navigate to Keycloak Admin Console → Realm (master) → Realm Settings → Sessions: + +- **SSO Session Idle**: How long a session can be idle before expiring +- **SSO Session Max**: Maximum session lifetime regardless of activity +- **Client Session Idle**: Timeout for idle client sessions +- **Client Session Max**: Maximum client session lifetime + +Also check: Realm Settings → Tokens: +- **Access Token Lifespan**: Should be 30 minutes (1800 seconds) based on your logs +- **Refresh Token Max Reuse**: Could cause issues if set to 0 + +## Expected vs Actual Behavior + +### Expected: +A token valid for 30 minutes should remain active for its full lifetime. + +### Actual: +Token works at T+20s but fails at T+22s (22 seconds after issuance). + +### This suggests: +- Not a token expiration issue +- Not a client credentials issue (would fail immediately) +- Likely a session or state management issue +- Possibly related to starting multiple OAuth flows + +## How to Rebuild and Run + +```bash +# Build +go build -o mcpauth ./cmd/main.go + +# Run (adjust environment variables as needed) +./mcpauth +``` + +The enhanced logging will automatically be included in your output. diff --git a/JWT_VALIDATION_SOLUTION.md b/JWT_VALIDATION_SOLUTION.md new file mode 100644 index 0000000..4029e13 --- /dev/null +++ b/JWT_VALIDATION_SOLUTION.md @@ -0,0 +1,302 @@ +# JWT Validation Solution - Resolving Cross-Client Introspection Issue + +## Executive Summary + +Your instinct was **100% correct**. The issue is a well-documented OAuth 2.0 security behavior where Keycloak returns `{"active": false}` when a token issued to one client is introspected by a different client. We've implemented **local JWT validation** as the recommended solution. + +## What Was Happening + +### The Problem + +1. **MCP Client** (client_id: `mcp-server`) obtains a token from Keycloak +2. **Your forward auth middleware** (mcpauth) tries to introspect the token using its own client credentials +3. **Keycloak sees**: "Token issued to `mcp-server`, but introspection request is from ``" +4. **Keycloak returns**: `{"active": false}` for security reasons +5. **Result**: Valid tokens are rejected + +### Why This Happens + +According to the [OAuth 2.0 specification](https://devforum.okta.com/t/introspection-endpoint-should-work-for-tokens-issued-by-different-client/13587): + +> "When the token was issued to a different client than is making this request, it should be considered an 'inactive' token" + +This is **by design** - not a bug. It's a security feature to prevent unauthorized clients from checking arbitrary tokens. + +### Known Issues + +- [Keycloak cross-client introspection commonly returns false](https://github.com/keycloak/keycloak/discussions/35463) +- [Keycloak 26.2.0+ has a regression](https://github.com/keycloak/keycloak/issues/39599) with session-based introspection +- [Token introspection always fails with {"active":false}](https://github.com/Nerzal/gocloak/issues/106) for different clients + +## The Solution: Local JWT Validation + +We've implemented **local JWT validation** which is the **industry-standard approach** used by most production systems. + +### What Changed + +**Before (Introspection):** +```go +// Made network call to Keycloak for EVERY request +tokenInfo, err := KeycloakProvider.IntrospectToken(token) +``` + +**After (JWT Validation):** +```go +// Validates locally using cached public keys +tokenInfo, err := KeycloakProvider.ValidateJWT(token) +``` + +### How It Works + +1. **Fetches Keycloak's public keys** (JWKS) from: + ``` + https://keycloak.mcpgateway.online/realms/master/protocol/openid-connect/certs + ``` + +2. **Verifies the JWT signature** locally using RSA public key cryptography + +3. **Validates claims**: + - `exp` (expiration) - Token must not be expired + - `iss` (issuer) - Must match Keycloak + - `aud` (audience) - Must match configured MCP server URL + - Scopes and roles + +4. **Caches public keys** for 10 minutes (respects Keycloak's cache headers) + +### Benefits + +According to [Keycloak JWT validation documentation](https://skycloak.io/blog/how-to-verify-a-keycloak-issued-access-token-on-the-backend/): + +| Feature | Introspection | JWT Validation | +|---------|---------------|----------------| +| **Performance** | Network call per request | Local validation (fast) | +| **Reliability** | Fails if Keycloak is down | Works offline | +| **Scalability** | High load on Keycloak | Minimal load | +| **Cross-client** | ❌ Fails with different clients | ✅ Works | +| **Real-time revocation** | ✅ Immediate | ❌ Valid until expiry | + +## Trade-off: Token Revocation + +From the [comparison article](https://medium.com/@kspoyraz7/keycloak-spring-boot-security-with-jwt-decoder-vs-introspection-methods-f6edc8b899f6): + +> "When using the JWT decoder method, even if the session on Keycloak is deleted, API calls continue to be processed successfully until the token expires." + +### What This Means + +- **Introspection**: Real-time session validation, but has cross-client issues and performance overhead +- **JWT Validation**: Fast and reliable, but tokens remain valid until natural expiration (30 minutes in your case) + +### Why JWT Validation is Still Preferred + +1. **OAuth tokens should have short lifetimes** (yours is 30 minutes - perfect) +2. **Real-time revocation is rarely needed** - most use cases don't require immediate invalidation +3. **Performance and reliability matter more** - especially for high-traffic APIs +4. **Industry standard** - This is how JWT is designed to work + +### When You Might Need Introspection + +Only if you have strict security requirements requiring: +- Immediate token revocation (logout must terminate all active sessions instantly) +- Real-time session validation +- Compliance requirements for continuous validation + +In those cases, you'd need to: +- Use the same client_id for both issuing and introspecting tokens +- OR grant special service account roles to the introspecting client +- Accept the performance overhead and cross-client limitations + +## Implementation Details + +### What Was Added + +1. **JWKS Cache Structure** (`server/providers/keycloak/keycloak.go`): + ```go + type jwksCache struct { + keys map[string]*rsa.PublicKey + lastFetched time.Time + ttl time.Duration // 10 minutes + jwksURL string + } + ``` + +2. **JWT Validation Method**: + - Parses JWT header to extract `kid` (key ID) + - Fetches/caches public key from JWKS endpoint + - Verifies RSA signature using SHA256 + - Validates all required claims + - Returns TokenInfo with user details + +3. **Updated Auth Handler** (`server/server.go`): + ```go + // Changed from IntrospectToken to ValidateJWT + tokenInfo, err := s.KeycloakProvider.ValidateJWT(token) + ``` + +### Logging + +The new implementation includes detailed logging: + +``` +[Keycloak] Starting JWT validation (local, no introspection) +[Keycloak] JWT header parsed kid= alg=RS256 +[Keycloak] Fetching JWKS (first time or expired cache) +[Keycloak] JWKS fetched successfully key_count=2 +[Keycloak] JWT signature verified successfully +[Keycloak] JWT validation successful email=admin scopes=[openid mcp:tools profile email] +``` + +Subsequent requests will show: +``` +[Keycloak] Using cached public key +``` + +## Deployment + +### Docker Image + +**Image**: `oideibrett/mcpauth:dev` +**Latest Digest**: `sha256:361f2ae28b46ea35caab5f495e1d20c207dad93216d72f9e01cbb2c5e8817637` + +### Bug Fix (2026-01-21 16:20) + +**Issue**: Initial implementation had a bug in RSA signature verification - was passing `0` instead of `crypto.SHA256` as the hash algorithm. + +**Fixed**: Updated `rsa.VerifyPKCS1v15()` call to correctly specify SHA256: +```go +// Before (broken): +rsa.VerifyPKCS1v15(publicKey, 0, hashed[:], signatureBytes) + +// After (fixed): +rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hashed[:], signatureBytes) +``` + +**Error seen**: `crypto/rsa: verification error` during JWT validation + +**Status**: ✅ Fixed in latest image + +### To Deploy + +```bash +# Pull the new image +docker pull oideibrett/mcpauth:dev + +# Restart your container +docker-compose restart mcpauth +# OR +docker restart +``` + +### No Configuration Changes Required + +The implementation automatically uses JWT validation when `UseKeycloak` is enabled. Your existing environment variables work as-is: + +```bash +PROVIDER=keycloak +CLIENT_ID=mcp-server +CLIENT_SECRET= +KEYCLOAK_AUTH_HOST=keycloak.mcpgateway.online +KEYCLOAK_REALM=master +``` + +## Testing + +### What to Expect + +1. **First request**: Will fetch JWKS from Keycloak (one-time network call) + ``` + [Keycloak] Fetching JWKS + [Keycloak] JWKS fetched successfully + ``` + +2. **Subsequent requests**: Use cached public keys (instant validation) + ``` + [Keycloak] Using cached public key + [Keycloak] JWT validation successful + ``` + +3. **After 10 minutes**: Cache expires, refetches JWKS automatically + +### Expected Behavior Changes + +- ✅ **Tokens will now work** across different OAuth clients +- ✅ **Much faster validation** (no network call per request) +- ✅ **More reliable** (works even if Keycloak is temporarily unavailable) +- ⚠️ **Tokens remain valid until expiration** even if Keycloak session is terminated + +### Testing the Fix + +1. **Start a new chat session** in your MCP client +2. **Authenticate** and get a new token +3. **Try using the MCP server** - should work immediately +4. **Disconnect and reconnect** - tokens should remain valid for their full 30-minute lifetime + +## Alternative Approaches (If JWT Validation Isn't Suitable) + +If you absolutely need real-time introspection despite the cross-client issues: + +### Option 1: Same Client Credentials (Simplest) + +Configure mcpauth to use the **same client_id** (`mcp-server`) for introspection: + +```bash +CLIENT_ID=mcp-server # Same as MCP client +CLIENT_SECRET= +``` + +**Pros**: Simple, will work immediately +**Cons**: Less secure (both systems share credentials), not architecturally clean + +### Option 2: Service Account Roles (Complex) + +Grant your mcpauth client special [service account roles](https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/22.0/html/authorization_services_guide/service_overview) in Keycloak. Note: This is for UMA (User-Managed Access) and may not apply to standard OAuth tokens. + +### Option 3: Hybrid Approach + +Use JWT validation as primary, with optional introspection for critical operations: + +```go +// Fast path - JWT validation +tokenInfo, err := ValidateJWT(token) +if err != nil { + return unauthorized +} + +// Slow path - only for sensitive operations +if criticalOperation { + active, err := IntrospectToken(token) + if !active { + return forbidden + } +} +``` + +## References + +- [Cross-client introspection issue in Keycloak](https://github.com/keycloak/keycloak/discussions/35463) +- [Keycloak JWT validation guide](https://skycloak.io/blog/how-to-verify-a-keycloak-issued-access-token-on-the-backend/) +- [JWT vs Introspection comparison](https://medium.com/@kspoyraz7/keycloak-spring-boot-security-with-jwt-decoder-vs-introspection-methods-f6edc8b899f6) +- [OAuth 2.0 introspection specification behavior](https://devforum.okta.com/t/introspection-endpoint-should-work-for-tokens-issued-by-different-client/13587) +- [Keycloak 26.2.0 introspection regression](https://github.com/keycloak/keycloak/issues/39599) + +## Support + +If you encounter any issues: + +1. **Check logs** for JWT validation details +2. **Verify JWKS endpoint** is accessible: + ```bash + curl https://keycloak.mcpgateway.online/realms/master/protocol/openid-connect/certs + ``` +3. **Confirm token format** - should be a JWT (three parts separated by dots) +4. **Check Keycloak public key rotation** - keys are cached for 10 minutes + +## Summary + +✅ **Implemented local JWT validation** - the industry-standard approach +✅ **Resolves cross-client introspection issues** - works regardless of client_id +✅ **Better performance** - no network call per request +✅ **Higher reliability** - works even if Keycloak is temporarily down +✅ **Production-ready** - this is how most OAuth systems operate +⚠️ **Trade-off**: Tokens valid until natural expiration (30 minutes) + +This is the **recommended solution** for your use case. diff --git a/README.md b/README.md index 2340a29..0fffb0c 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,276 @@ https://oauth.yourdomain.com/callback Save the Client ID and Client Secret for later use. -### Create .env file +### Alternative: Set Up Keycloak OAuth + +MCPAuth now supports Keycloak as an identity provider. This is useful for enterprise environments that want to use their existing Keycloak infrastructure. + +For complete MCP + Keycloak setup instructions, see the official guide: [MCP Security Authorization with Keycloak](https://modelcontextprotocol.io/docs/tutorials/security/authorization#keycloak-setup) + +#### Detailed Keycloak Client Configuration + +##### 1. Create a Keycloak Realm +- Use an existing realm (e.g., `master`) or create a new one + +##### 2. Create and Configure the Client + +**Basic Settings:** +- Go to your realm → **Clients** → **Create** +- **Client ID:** `mcp-server` (or your preferred ID) +- **Client Protocol:** `openid-connect` +- **Access Type / Client Authentication:** `confidential` +- **Save** the client + +**Settings Tab:** + +**Valid Redirect URIs** (add ALL of these): +``` +https://oauth.yourdomain.com/callback +http://localhost:*/oauth/callback +http://localhost:*/oauth/callback/debug +``` + +**Explanation:** +- First line: Production callback for your mcpauth server +- Second line: Development - MCP clients running locally (wildcard port) +- Third line: MCP Inspector debug interface (includes `/debug` suffix) + +**Web Origins** (for CORS - add ALL of these): +``` +https://oauth.yourdomain.com +http://localhost:* ++ +``` + +**Explanation:** +- Allows CORS requests from your production domain +- Allows CORS from any localhost port (development) +- `+` = Keycloak shortcut meaning "allow all redirect URIs as origins" + +##### 3. Configure Audience Mapper (CRITICAL) + +This step is **required** for mcpauth to validate tokens properly. + +- Go to **Client Scopes** or **Mappers** tab +- Click **Add Mapper** → **By Configuration** → **Audience** +- **Name:** `audience-config` +- **Mapper Type:** `Audience` +- **Included Client Audience:** Leave empty +- **Included Custom Audience:** `https://oauth.yourdomain.com` +- **Add to ID token:** OFF +- **Add to access token:** ON +- **Save** + +**Important:** Set the audience to your **mcpauth gateway URL**, not individual MCP server URLs. All MCP servers protected by mcpauth should use the same audience. + +##### 4. Get Client Credentials + +- Go to the **Credentials** tab +- Copy the **Client Secret** +- Save this for your `KEYCLOAK_CLIENT_SECRET` environment variable + +##### 5. Configure Keycloak for Reverse Proxy (if behind Traefik/Nginx) + +**Keycloak Environment Variables:** +```bash +KC_PROXY=edge +KC_HOSTNAME=keycloak.yourdomain.com +KC_HOSTNAME_PORT=443 +KC_HOSTNAME_STRICT=false +KC_HTTP_ENABLED=true +``` + +**Run Keycloak with:** +```bash +docker run -p 127.0.0.1:9080:8080 \ + -e KC_PROXY=edge \ + -e KC_HOSTNAME=keycloak.yourdomain.com \ + -e KC_HOSTNAME_PORT=443 \ + quay.io/keycloak/keycloak start-dev +``` + +#### Traefik Configuration (IMPORTANT) + +If using Traefik, you **must** make Keycloak's OAuth endpoints publicly accessible (no authentication required): + +```yaml +http: + routers: + # Public Keycloak OAuth endpoints - NO AUTHENTICATION + keycloak-public: + rule: "Host(`keycloak.yourdomain.com`) && (PathPrefix(`/realms/{realm}/.well-known`) || PathPrefix(`/realms/{realm}/protocol/openid-connect`) || PathPrefix(`/realms/{realm}/resources`))" + priority: 300 # Higher priority than authenticated routes + service: keycloak-service + entrypoints: + - websecure + tls: + certResolver: letsencrypt + # NO auth middleware here! + + # Protected Keycloak admin endpoints - WITH AUTHENTICATION + keycloak-admin: + rule: "Host(`keycloak.yourdomain.com`)" + priority: 200 + service: keycloak-service + middlewares: + - your-auth-middleware + entrypoints: + - websecure + tls: + certResolver: letsencrypt + + services: + keycloak-service: + loadBalancer: + servers: + - url: "http://keycloak:8080" +``` + +**Critical Paths That Must Be Public:** +- `/.well-known/oauth-authorization-server` - OAuth discovery +- `/.well-known/openid-configuration` - OpenID Connect discovery +- `/protocol/openid-connect/auth` - Authorization endpoint +- `/protocol/openid-connect/token` - Token endpoint +- `/protocol/openid-connect/certs` - Public keys (JWKS) +- `/resources/*` - Static resources + +#### Keycloak Environment Variables + +| Variable | Default | Description | +|---------------------------|-------------|------------------------------------------| +| `PROVIDER` | *(none)* | Set to `keycloak` to use Keycloak | +| `CLIENT_ID` | *(none)* | Keycloak client ID | +| `CLIENT_SECRET` | *(none)* | Keycloak client secret | +| `KEYCLOAK_AUTH_HOST` | `localhost` | Keycloak server host | +| `KEYCLOAK_AUTH_PORT` | `8080` | Keycloak server port | +| `KEYCLOAK_AUTH_PROTOCOL` | `https` | Keycloak protocol (http or https) | +| `KEYCLOAK_REALM` | `master` | Keycloak realm name | + +#### Example Keycloak .env file + +```bash +PROVIDER=keycloak +CLIENT_ID=mcp-server +CLIENT_SECRET=your-keycloak-client-secret +KEYCLOAK_AUTH_HOST=keycloak.yourdomain.com +KEYCLOAK_AUTH_PORT=443 +KEYCLOAK_AUTH_PROTOCOL=https +KEYCLOAK_REALM=your-realm +OAUTH_DOMAIN=oauth.yourdomain.com +``` + +#### Running with Keycloak + +```bash +go run cmd/main.go \ + -provider=keycloak \ + -clientID=mcp-server \ + -clientSecret=your-client-secret \ + -keycloakAuthHost=localhost \ + -keycloakAuthPort=8080 \ + -keycloakRealm=master \ + -oauthDomain=oauth.yourdomain.com +``` + +Or using Docker Compose: + +```yaml +services: + mcpauth: + image: oideibrett/mcpauth:latest + environment: + - PROVIDER=keycloak + - CLIENT_ID=mcp-server + - CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} + - KEYCLOAK_AUTH_HOST=keycloak + - KEYCLOAK_AUTH_PORT=8080 + - KEYCLOAK_REALM=master + - OAUTH_DOMAIN=oauth.yourdomain.com + ports: + - "11000:11000" + + keycloak: + image: quay.io/keycloak/keycloak:latest + environment: + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + command: start-dev + ports: + - "8080:8080" +``` + +#### Testing Your Keycloak Setup + +##### 1. Verify Metadata Endpoints + +```bash +# Check MCP protected resource metadata +curl https://your-mcp-server.com/.well-known/oauth-protected-resource/mcp + +# Should return: +# { +# "authorization_servers": ["https://keycloak.yourdomain.com/realms/master/"], +# "resource": "https://your-mcp-server.com", +# "scopes_supported": ["openid", "email"] +# } + +# Check Keycloak OAuth metadata +curl https://keycloak.yourdomain.com/realms/master/.well-known/oauth-authorization-server + +# Should return Keycloak's full OAuth configuration with endpoints +``` + +##### 2. Test with MCP Inspector + +1. Install MCP Inspector: `npm install -g @modelcontextprotocol/inspector` +2. Run: `mcp-inspector` +3. Add your protected MCP server URL +4. Click "Connect" - you should be redirected to Keycloak login +5. After login, you should be redirected back and connected + +##### 3. Test with Claude Desktop + +Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "protected-server": { + "url": "https://your-mcp-server.com" + } + } +} +``` + +Restart Claude Desktop and try to use the MCP server - you'll be prompted for Keycloak authentication. + +#### Troubleshooting + +**"Page not found" when redirected to Keycloak:** +- Check that Keycloak's OAuth endpoints are NOT protected by authentication +- Verify the Traefik public router configuration (priority 300) +- Test: `curl https://keycloak.yourdomain.com/realms/master/.well-known/oauth-authorization-server` + +**"Invalid redirect URI" error:** +- Add the exact redirect URI to Keycloak client settings +- For MCP Inspector, include: `http://localhost:*/oauth/callback/debug` +- For production, include: `https://oauth.yourdomain.com/callback` + +**CORS errors in browser:** +- Configure **Web Origins** in Keycloak client +- Add `http://localhost:*` for development +- Add `+` to allow all redirect URIs as origins + +**"Audience validation failed" errors:** +- Verify the **Audience Mapper** is configured correctly +- Audience should be: `https://oauth.yourdomain.com` (your mcpauth gateway) +- Check mcpauth logs for the expected audience value + +**Token introspection fails:** +- Verify `KEYCLOAK_AUTH_HOST`, `KEYCLOAK_AUTH_PORT`, and `KEYCLOAK_AUTH_PROTOCOL` are correct +- Test introspection endpoint: `curl -X POST https://keycloak.yourdomain.com/realms/master/protocol/openid-connect/token/introspect` + +### Create .env file (for Google OAuth) ``` CLIENT_ID= CLIENT_SECRET= @@ -311,8 +580,8 @@ http: redirect-regex: redirectRegex: - regex: "^https://([a-z0-9-]+)\.(.+)/\.well-known/(.+)" - replacement: "https://oauth.${2}/.well-known/${3}" + regex: "^https://[^/]+\.([^.]+\.[^./]+)/\.well-known/(.+)" + replacement: "https://oauth.${1}/.well-known/${2}" permanent: true mcp-auth: @@ -363,14 +632,209 @@ middlewares: name: Regex Redirect type: redirectregex config: - regex: "^https://([a-z0-9-]+)\.yourdomain\.com/\.well-known/oauth-authorization-server" - replacement: "https://oauth.yourdomain.com/.well-known/oauth-authorization-server" + regex: "^https://[^/]+\.(yourdomain\.com)/\.well-known/(.+)" + replacement: "https://oauth.${1}/.well-known/${2}" permanent: true ``` --- +## Edge Auth (CF_Authorization Cookie Flow) + +MCPAuth can act as a replacement for Cloudflare Access when protecting an OpenShell-style gateway behind Traefik. When enabled, it provides: + +- **`/auth/connect`** — Browser-based login relay that authenticates the user via your IdP (Keycloak, Google, or internal), issues a `CF_Authorization` JWT cookie, and serves a connect page that relays the token to the CLI's localhost callback server. +- **`/_ws_tunnel`** — Forward-auth endpoint for Traefik that validates the `CF_Authorization` cookie before allowing WebSocket connections through to the gateway. +- **`/auth` (existing)** — Also accepts the `CF_Authorization` cookie as a fallback token source when edge auth is enabled. + +Edge auth runs **in parallel** with the existing OAuth/Bearer token flow. The same mcpauth instance can serve both MCP OAuth clients and OpenShell gateway browser logins using the same IdP. + +### Edge Auth Environment Variables + +| Variable | Flag | Default | Description | +|---------------------|--------------------|----------------|-------------------------------------------------------| +| `ENABLE_EDGE_AUTH` | `-enableEdgeAuth` | `false` | Enable edge auth endpoints | +| `COOKIE_DOMAIN` | `-cookieDomain` | `OAUTH_DOMAIN` | Domain for the `CF_Authorization` cookie | + +All other provider config (`PROVIDER`, `CLIENT_ID`, `CLIENT_SECRET`, Keycloak settings, etc.) is shared with the existing OAuth flow. + +### Docker Compose with Edge Auth + +```yaml +services: + mcpauth: + image: oideibrett/mcpauth:edge_auth + environment: + - ENABLE_EDGE_AUTH=true + - COOKIE_DOMAIN=.yourdomain.com + - OAUTH_DOMAIN=oauth.yourdomain.com + - PROVIDER=keycloak + - CLIENT_ID=mcp-server + - CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} + - KEYCLOAK_AUTH_HOST=keycloak.yourdomain.com + - KEYCLOAK_AUTH_PORT=443 + - KEYCLOAK_AUTH_PROTOCOL=https + - KEYCLOAK_REALM=master + restart: unless-stopped + + # If using internal auth instead of Keycloak: + # mcpauth: + # image: oideibrett/mcpauth:edge_auth + # environment: + # - ENABLE_EDGE_AUTH=true + # - COOKIE_DOMAIN=.yourdomain.com + # - OAUTH_DOMAIN=oauth.yourdomain.com + # - DEV_MODE=false + # command: ["-useInternalAuth"] +``` + +### Traefik Dynamic Configuration for Edge Auth + +Edge auth requires three Traefik components: the `edge-auth` forwardAuth middleware, a high-priority login router (so the login flow isn't blocked by forwardAuth), and the gateway router with the middleware applied. + +#### 1. ForwardAuth Middleware + +Add an `edge-auth` forwardAuth middleware. Traefik calls mcpauth's `/_ws_tunnel` endpoint to validate the `CF_Authorization` cookie before proxying to the gateway. + +```yaml +http: + middlewares: + edge-auth: + forwardAuth: + address: "http://mcpauth:11000/_ws_tunnel" + authResponseHeaders: + - X-Forwarded-User + - X-Forwarded-Scopes +``` + +#### 2. Routers + +You need two routers on the **gateway domain** with different priorities: + +```yaml +http: + routers: + # ── Login flow (NO middleware, priority 300) ───────────────── + # CRITICAL: The login paths must have a HIGHER priority than + # any router that applies the edge-auth middleware on the same + # domain. Otherwise forwardAuth intercepts the login redirects + # and returns 401 before the user can log in. + gateway-login: + rule: "Host(`gateway.yourdomain.com`) && (PathPrefix(`/auth/connect`) || PathPrefix(`/authorize`) || PathPrefix(`/callback`) || PathPrefix(`/internal`))" + service: mcpauth-service + priority: 300 + entryPoints: + - websecure + tls: + certResolver: letsencrypt + + # ── Gateway (PROTECTED by edge-auth, priority 250) ─────────── + # All other requests to the gateway domain go through forwardAuth. + # The edge-auth middleware validates the CF_Authorization cookie; + # if valid (200), Traefik proxies to the gateway service. + # If invalid/missing (401), the request is blocked. + gateway-protected: + rule: "Host(`gateway.yourdomain.com`)" + service: gateway-service + middlewares: + - edge-auth + priority: 250 + entryPoints: + - websecure + tls: + certResolver: letsencrypt +``` + +> **Why two routers?** The gateway's catch-all router applies `edge-auth` forwardAuth to all requests. But the login flow itself (`/auth/connect` → `/authorize` → IdP → `/callback`) must reach mcpauth without authentication — these paths ARE the login. The higher-priority (300 vs 250) login router matches first and routes directly to mcpauth. + +#### 3. Services + +```yaml +http: + services: + mcpauth-service: + loadBalancer: + servers: + - url: "http://mcpauth:11000" + + gateway-service: + loadBalancer: + servers: + - url: "http://gateway:8080" +``` + +#### Existing MCP OAuth Routes (Unchanged) + +The existing Bearer-token OAuth routes on the `oauth.yourdomain.com` domain are completely unaffected: + +```yaml +http: + middlewares: + mcp-auth: + forwardAuth: + address: "http://mcpauth:11000/auth" + authResponseHeaders: + - X-Forwarded-User + - X-Forwarded-Scopes + + routers: + mcp-server: + rule: "Host(`mcp.yourdomain.com`)" + service: mcp-service + middlewares: + - mcp-auth + entrypoints: + - websecure + tls: + certResolver: letsencrypt +``` + +### How the Edge Auth Flow Works + +1. The CLI opens the user's browser to `https://oauth.yourdomain.com/auth/connect?callback_port=&code=` +2. MCPAuth checks for a `CF_Authorization` cookie — none exists yet +3. MCPAuth redirects through the OAuth login flow (Keycloak, Google, or internal) +4. After successful IdP login, MCPAuth mints a `CF_Authorization` JWT cookie (24h TTL) and redirects back to `/auth/connect` +5. Now the cookie is present — MCPAuth serves the connect page showing the confirmation code +6. The user clicks "Connect" — JavaScript POSTs the token to the CLI's localhost callback +7. Subsequent `/_ws_tunnel` WebSocket connections include the cookie and are validated by the `edge-auth` forwardAuth middleware + +### Keycloak Notes + +- MCPAuth extracts the user's `email` from the Keycloak userinfo response. If no email is set on the Keycloak user, it falls back to `preferred_username`. +- Ensure your Keycloak client has `email` and `openid` in its client scopes so the email claim is returned. + +### Testing Edge Auth with curl + +```bash +# Start mcpauth with edge auth enabled (internal auth, dev mode) +go run cmd/main.go -devMode -useInternalAuth -enableEdgeAuth -port 11000 -oauthDomain localhost:11000 + +# 1. /auth/connect without cookie → redirects to login +curl -v 'http://localhost:11000/auth/connect?callback_port=9999&code=TEST-1234' +# Expect: 307 redirect to /authorize + +# 2. /_ws_tunnel without cookie → 401 +curl -v http://localhost:11000/_ws_tunnel +# Expect: 401 {"error":"authentication required"} + +# 3. /_ws_tunnel with valid CF_Authorization cookie → 200 +curl -v -H "Cookie: CF_Authorization=" http://localhost:11000/_ws_tunnel +# Expect: 200 with X-Forwarded-User header + +# 4. /auth with CF_Authorization cookie → 200 (fallback token source) +curl -v -H "Cookie: CF_Authorization=" http://localhost:11000/auth +# Expect: 200 with X-Forwarded-User header + +# 5. /_ws_tunnel with invalid cookie → 401 +curl -v -H "Cookie: CF_Authorization=bad-token" http://localhost:11000/_ws_tunnel +# Expect: 401 {"error":"invalid or expired token"} +``` + +--- + + ## License Licensed under the [GNU General Public License v3.0](LICENSE). diff --git a/cmd/main.go b/cmd/main.go index cdb2be0..2ab6206 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -25,14 +25,24 @@ func main() { requiredScopes := flag.String("requiredScopes", "", "Comma-separated list of required OAuth scopes") // OAuth provider configuration - provider := flag.String("provider", "", "OAuth provider to use (google, internal, etc)") + provider := flag.String("provider", "", "OAuth provider to use (google, internal, keycloak, etc)") clientID := flag.String("clientID", "", "OAuth client ID") clientSecret := flag.String("clientSecret", "", "OAuth client secret") + // Keycloak-specific configuration + keycloakAuthHost := flag.String("keycloakAuthHost", "localhost", "Keycloak auth server host") + keycloakAuthPort := flag.Int("keycloakAuthPort", 8080, "Keycloak auth server port") + keycloakAuthProtocol := flag.String("keycloakAuthProtocol", "https", "Keycloak auth server protocol (http or https)") + keycloakRealm := flag.String("keycloakRealm", "master", "Keycloak realm") + // Database and internal auth configuration dataDir := flag.String("dataDir", "./data", "Directory for database and other data files") useInternalAuth := flag.Bool("useInternalAuth", false, "Use internal authentication instead of external provider") + // Edge auth (CF_Authorization cookie flow) + enableEdgeAuth := flag.Bool("enableEdgeAuth", false, "Enable edge auth endpoints (/auth/connect, /_ws_tunnel) for CF_Authorization cookie flow") + cookieDomain := flag.String("cookieDomain", "", "Domain for CF_Authorization cookie (defaults to oauthDomain)") + // Default admin user (only used if no users exist and internal auth is enabled) adminUsername := flag.String("adminUsername", "admin", "Default admin username") adminEmail := flag.String("adminEmail", "admin@localhost", "Default admin email") @@ -76,6 +86,30 @@ func main() { *requiredScopes = envRequiredScopes } + // Check environment variables for Keycloak configuration + if envAuthHost := os.Getenv("KEYCLOAK_AUTH_HOST"); envAuthHost != "" { + *keycloakAuthHost = envAuthHost + } + if envAuthPort := os.Getenv("KEYCLOAK_AUTH_PORT"); envAuthPort != "" { + if port, err := strconv.Atoi(envAuthPort); err == nil { + *keycloakAuthPort = port + } + } + if envAuthProtocol := os.Getenv("KEYCLOAK_AUTH_PROTOCOL"); envAuthProtocol != "" { + *keycloakAuthProtocol = envAuthProtocol + } + if envRealm := os.Getenv("KEYCLOAK_REALM"); envRealm != "" { + *keycloakRealm = envRealm + } + + // Check environment variables for edge auth configuration + if envEdgeAuth := os.Getenv("ENABLE_EDGE_AUTH"); envEdgeAuth != "" { + *enableEdgeAuth = strings.ToLower(envEdgeAuth) == "true" + } + if envCookieDomain := os.Getenv("COOKIE_DOMAIN"); envCookieDomain != "" { + *cookieDomain = envCookieDomain + } + // Configure logging based on log level configureLogging(*logLevel) @@ -94,6 +128,14 @@ func main() { log.Fatal().Err(err).Msg("Failed to create server") } + // Configure edge auth + s.EnableEdgeAuth = *enableEdgeAuth + if *cookieDomain != "" { + s.CookieDomain = *cookieDomain + } else { + s.CookieDomain = *oauthDomain + } + // Configure allowed emails if provided if *allowedEmails != "" { emailList := strings.Split(*allowedEmails, ",") @@ -169,6 +211,35 @@ func main() { } log.Info().Msg("Configured internal authentication provider") + } else if actualProvider == "keycloak" { + // Keycloak provider + if actualClientID == "" || actualClientSecret == "" { + log.Fatal(). + Str("provider", actualProvider). + Msg("ClientID and ClientSecret must be set for Keycloak") + } + + if err := s.ConfigureKeycloakProvider( + actualClientID, + actualClientSecret, + redirectURI, + nil, // scopes - will use defaults + *keycloakAuthHost, + *keycloakAuthPort, + *keycloakAuthProtocol, + *keycloakRealm, + ); err != nil { + log.Fatal().Err(err).Msg("Failed to configure Keycloak provider") + } + + log.Info(). + Str("provider", actualProvider). + Str("redirectURI", redirectURI). + Str("auth_host", *keycloakAuthHost). + Int("auth_port", *keycloakAuthPort). + Str("auth_protocol", *keycloakAuthProtocol). + Str("realm", *keycloakRealm). + Msg("Configured Keycloak OAuth provider") } else { // External provider (default: google) if actualClientID == "" || actualClientSecret == "" { @@ -187,6 +258,9 @@ func main() { Msg("Configured OAuth provider") } + // Register edge auth routes (must happen after EnableEdgeAuth is set) + s.SetupEdgeAuthRoutes() + // Start the server address := fmt.Sprintf(":%d", *port) log.Info().Str("address", address).Msg("Starting server") diff --git a/go.mod b/go.mod index 53530fd..5f95fd7 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.21 require ( github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.3.0 github.com/mattn/go-sqlite3 v1.14.17 github.com/rs/zerolog v1.31.0 @@ -19,7 +20,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect diff --git a/server/database/repositories.go b/server/database/repositories.go index 98f1fe8..7606af0 100644 --- a/server/database/repositories.go +++ b/server/database/repositories.go @@ -176,6 +176,32 @@ func (r *ClientRepository) CreateClient(clientName string, redirectURIs []string return r.GetClientByID(clientID) } +// CreateClientWithID creates a new OAuth client with a specific client ID. +func (r *ClientRepository) CreateClientWithID(clientID, clientName string, redirectURIs []string, scopes []string) (*OAuthClient, error) { + clientSecret := uuid.New().String() + + redirectURIsJSON, err := json.Marshal(redirectURIs) + if err != nil { + return nil, fmt.Errorf("failed to marshal redirect URIs: %w", err) + } + + scopesJSON, err := json.Marshal(scopes) + if err != nil { + return nil, fmt.Errorf("failed to marshal scopes: %w", err) + } + + query := ` + INSERT INTO oauth_clients (client_id, client_secret, client_name, redirect_uris, scopes) + VALUES (?, ?, ?, ?, ?) + ` + _, err = r.db.Exec(query, clientID, clientSecret, clientName, string(redirectURIsJSON), string(scopesJSON)) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + return r.GetClientByID(clientID) +} + // GetClientByID retrieves a client by client ID func (r *ClientRepository) GetClientByID(clientID string) (*OAuthClient, error) { client := &OAuthClient{} diff --git a/server/edge_auth.go b/server/edge_auth.go new file mode 100644 index 0000000..3e8fb38 --- /dev/null +++ b/server/edge_auth.go @@ -0,0 +1,523 @@ +package server + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" +) + +const ( + edgeAuthClientID = "edge-auth-client" + edgeAuthCookieName = "edge_auth_params" + cfAuthCookieName = "CF_Authorization" + cfAuthTTL = 24 * time.Hour +) + +// edgeAuthConnectHandler handles GET /auth/connect. +// If the CF_Authorization cookie is present and valid, it serves the connect page. +// Otherwise it saves the callback params and redirects to the OAuth login flow. +func (s *Server) edgeAuthConnectHandler(c *gin.Context) { + callbackPort := c.Query("callback_port") + code := c.Query("code") + + if callbackPort == "" || code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "callback_port and code are required"}) + return + } + + // Check for existing CF_Authorization cookie + cfToken, err := c.Cookie(cfAuthCookieName) + if err == nil && cfToken != "" { + // Validate the JWT + claims, err := validateJWT(cfToken) + if err == nil { + email, _ := (*claims)["email"].(string) + // Derive display name from headers + gateway := c.GetHeader("X-Forwarded-Host") + if gateway == "" { + gateway = c.GetHeader("Host") + } + if gateway == "" { + gateway = s.OAuthDomain + } + + c.Header("Content-Type", "text/html") + c.String(http.StatusOK, renderEdgeConnectPage(gateway, callbackPort, cfToken, code, email)) + return + } + // Invalid/expired cookie — clear it and restart flow + log.Warn().Err(err).Msg("[EdgeAuth] CF_Authorization cookie invalid, clearing") + c.SetCookie(cfAuthCookieName, "", -1, "/", s.CookieDomain, !s.DevMode, true) + } + + // No valid cookie — save params and redirect to OAuth login + paramVal := callbackPort + "|" + code + c.SetCookie(edgeAuthCookieName, paramVal, 600, "/", s.CookieDomain, !s.DevMode, true) + + protocol := "https" + if s.DevMode { + protocol = "http" + } + redirectURI := fmt.Sprintf("%s://%s/auth/connect/callback", protocol, s.OAuthDomain) + + authorizeURL := fmt.Sprintf("/authorize?client_id=%s&redirect_uri=%s&response_type=code&scope=openid+email", + edgeAuthClientID, redirectURI) + + c.Redirect(http.StatusTemporaryRedirect, authorizeURL) +} + +// edgeAuthConnectCallbackHandler handles GET /auth/connect/callback. +// This is the redirect_uri target after the OAuth login flow completes. +// It mints a CF_Authorization JWT cookie and redirects back to /auth/connect. +func (s *Server) edgeAuthConnectCallbackHandler(c *gin.Context) { + code := c.Query("code") + if code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing authorization code"}) + return + } + + var email string + var scopes []string + + // Try external provider session first (stored by callbackHandler) + sessionData, found := s.Sessions.GetByCode(code) + if found { + email = sessionData.Email + scopes = sessionData.Scopes + s.Sessions.DeleteCode(code) + } else if s.UseInternalAuth && s.InternalProvider != nil { + // Internal auth: exchange the authorization code for tokens + protocol := "https" + if s.DevMode { + protocol = "http" + } + redirectURI := fmt.Sprintf("%s://%s/auth/connect/callback", protocol, s.OAuthDomain) + + accessToken, _, err := s.InternalProvider.ExchangeAuthorizationCode(code, edgeAuthClientID, redirectURI) + if err != nil { + log.Error().Err(err).Msg("[EdgeAuth] Failed to exchange authorization code") + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization code"}) + return + } + + userInfo, err := s.InternalProvider.GetUserInfo(accessToken.Token) + if err != nil { + log.Error().Err(err).Msg("[EdgeAuth] Failed to get user info") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user info"}) + return + } + + email, _ = userInfo["email"].(string) + if scopeList, ok := userInfo["scopes"].([]string); ok { + scopes = scopeList + } + } else { + log.Warn().Msg("[EdgeAuth] Authorization code not found in session store") + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization code"}) + return + } + + if email == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not determine user email"}) + return + } + + log.Info().Str("email", email).Msg("[EdgeAuth] User authenticated, minting CF_Authorization JWT") + + // Mint CF_Authorization JWT + cfToken, err := mintCFAuthorizationJWT(email, scopes, cfAuthTTL) + if err != nil { + log.Error().Err(err).Msg("[EdgeAuth] Failed to mint CF_Authorization JWT") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create auth token"}) + return + } + + // Set the CF_Authorization cookie + c.SetCookie(cfAuthCookieName, cfToken, int(cfAuthTTL.Seconds()), "/", s.CookieDomain, !s.DevMode, true) + + // Recover callback_port and code from the edge_auth_params cookie + paramsCookie, err := c.Cookie(edgeAuthCookieName) + if err != nil || paramsCookie == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing edge auth params cookie — please restart the connect flow"}) + return + } + + parts := strings.SplitN(paramsCookie, "|", 2) + if len(parts) != 2 { + c.JSON(http.StatusBadRequest, gin.H{"error": "malformed edge auth params cookie"}) + return + } + callbackPort, connectCode := parts[0], parts[1] + + // Clear the params cookie + c.SetCookie(edgeAuthCookieName, "", -1, "/", s.CookieDomain, !s.DevMode, true) + + // Redirect back to /auth/connect with the original params + redirectURL := fmt.Sprintf("/auth/connect?callback_port=%s&code=%s", callbackPort, connectCode) + c.Redirect(http.StatusTemporaryRedirect, redirectURL) +} + +// edgeAuthWsTunnelHandler handles forward-auth for /_ws_tunnel. +// Validates the CF_Authorization cookie and returns 200 or 401. +func (s *Server) edgeAuthWsTunnelHandler(c *gin.Context) { + cfToken, err := c.Cookie(cfAuthCookieName) + if err != nil || cfToken == "" { + log.Warn().Msg("[EdgeAuth] /_ws_tunnel: no CF_Authorization cookie") + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + claims, err := validateJWT(cfToken) + if err != nil { + log.Warn().Err(err).Msg("[EdgeAuth] /_ws_tunnel: invalid CF_Authorization JWT") + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + return + } + + email, _ := (*claims)["email"].(string) + if email == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "token missing email claim"}) + return + } + + log.Info().Str("email", email).Msg("[EdgeAuth] /_ws_tunnel: authenticated") + c.Header("X-Forwarded-User", email) + + if rawScopes, ok := (*claims)["scopes"]; ok { + if scopeSlice := toStringSlice(rawScopes); len(scopeSlice) > 0 { + c.Header("X-Forwarded-Scopes", strings.Join(scopeSlice, " ")) + } + } + + c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte("authenticated")) +} + +// extractCFAuthCookie extracts the CF_Authorization cookie value from the request. +func extractCFAuthCookie(c *gin.Context) string { + cfToken, err := c.Cookie(cfAuthCookieName) + if err != nil { + return "" + } + return cfToken +} + +// registerEdgeAuthClient registers the edge-auth OAuth client in both the +// in-memory map and the database so internal auth's redirect URI validation succeeds. +func (s *Server) registerEdgeAuthClient() error { + protocol := "https" + if s.DevMode { + protocol = "http" + } + redirectURI := fmt.Sprintf("%s://%s/auth/connect/callback", protocol, s.OAuthDomain) + + // Register in the in-memory client map (used by external provider flow) + s.Clients[edgeAuthClientID] = Client{ + ClientID: edgeAuthClientID, + ClientName: "Edge Auth Connect", + RedirectURIs: []string{redirectURI}, + } + + // Register in the database if internal auth is enabled (for redirect URI validation) + if s.UseInternalAuth && s.ClientService != nil { + _, err := s.ClientService.GetClient(edgeAuthClientID) + if err != nil { + // Client doesn't exist in DB — create it + _, err = s.ClientService.CreateEdgeAuthClient(edgeAuthClientID, []string{redirectURI}, []string{"openid", "email"}) + if err != nil { + return fmt.Errorf("failed to register edge auth client in database: %w", err) + } + log.Info().Str("redirect_uri", redirectURI).Msg("[EdgeAuth] Registered edge auth client in database") + } else { + log.Info().Msg("[EdgeAuth] Edge auth client already exists in database") + } + } + + log.Info().Str("redirect_uri", redirectURI).Msg("[EdgeAuth] Registered edge auth client") + return nil +} + +// renderEdgeConnectPage renders the styled confirmation page with the token embedded. +func renderEdgeConnectPage(gatewayAddr, callbackPort, cfToken, code, email string) string { + escapedToken := escapeJSString(cfToken) + escapedCode := escapeJSString(code) + escapedEmail := escapeHTMLAttr(email) + escapedGateway := escapeHTMLAttr(gatewayAddr) + + return fmt.Sprintf(` + + + + + Connect to Gateway + + + +
+ +
Connect to Gateway
+
+
Confirmation Code
+
%s
+
Verify this matches the code shown in your terminal
+
+
+
+ Gateway + %s +
+
+ User + %s +
+
+ +
+ This will authorize the CLI to connect to this gateway. +
+
+
+ + +`, escapedCode, escapedGateway, escapedEmail, escapedToken, escapedCode, callbackPort) +} + +// renderEdgeWaitingPage renders a waiting/authenticating page with auto-refresh. +func renderEdgeWaitingPage(callbackPort, code string) string { + safeCode := escapeHTMLAttr(code) + return fmt.Sprintf(` + + + + + + Authenticating + + + +
+ +
+
Authenticating...
+
+ +`, callbackPort, safeCode) +} + +// escapeJSString escapes a string for safe embedding in a JS string literal. +func escapeJSString(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `'`, `\'`) + s = strings.ReplaceAll(s, `"`, `\"`) + s = strings.ReplaceAll(s, `<`, `\x3c`) + s = strings.ReplaceAll(s, `>`, `\x3e`) + return s +} + +// escapeHTMLAttr escapes a string for safe embedding in an HTML attribute or content. +func escapeHTMLAttr(s string) string { + s = strings.ReplaceAll(s, `&`, `&`) + s = strings.ReplaceAll(s, `"`, `"`) + s = strings.ReplaceAll(s, `'`, `'`) + s = strings.ReplaceAll(s, `<`, `<`) + s = strings.ReplaceAll(s, `>`, `>`) + return s +} diff --git a/server/providers/keycloak/keycloak.go b/server/providers/keycloak/keycloak.go new file mode 100644 index 0000000..16990ca --- /dev/null +++ b/server/providers/keycloak/keycloak.go @@ -0,0 +1,698 @@ +package keycloak + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +// Helper functions +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// Provider implements the OAuth provider interface for Keycloak +type Provider struct { + ClientID string + ClientSecret string + RedirectURI string + Scopes []string + AuthHost string + AuthPort int + AuthProtocol string // http or https + Realm string + MCPServerURL string // The MCP server URL for audience validation + jwksCache *jwksCache +} + +// JWKS represents a JSON Web Key Set +type jwks struct { + Keys []jwk `json:"keys"` +} + +// JWK represents a JSON Web Key +type jwk struct { + Kid string `json:"kid"` // Key ID + Kty string `json:"kty"` // Key type + Alg string `json:"alg"` // Algorithm + Use string `json:"use"` // Usage + N string `json:"n"` // Modulus (RSA) + E string `json:"e"` // Exponent (RSA) +} + +// jwksCache caches JWKS public keys +type jwksCache struct { + mu sync.RWMutex + keys map[string]*rsa.PublicKey + lastFetched time.Time + ttl time.Duration + jwksURL string +} + +// TokenInfo represents introspected token information +type TokenInfo struct { + Active bool `json:"active"` + ClientID string `json:"client_id"` + Email string `json:"email"` + Scopes []string `json:"scopes"` + ExpiresAt int64 `json:"exp"` + Audience interface{} `json:"aud"` // Can be string or []string +} + +// NewProvider creates a new Keycloak OAuth provider +func NewProvider(clientID, clientSecret, redirectURI string, scopes []string, authHost string, authPort int, authProtocol string, realm string, mcpServerURL string) *Provider { + if scopes == nil || len(scopes) == 0 { + // Default scopes if none provided + scopes = []string{"openid", "email", "profile"} + } + + // Default to https if not specified + if authProtocol == "" { + authProtocol = "https" + } + + // Construct JWKS URL + var jwksURL string + if (authProtocol == "https" && authPort == 443) || (authProtocol == "http" && authPort == 80) { + jwksURL = fmt.Sprintf("%s://%s/realms/%s/protocol/openid-connect/certs", authProtocol, authHost, realm) + } else { + jwksURL = fmt.Sprintf("%s://%s:%d/realms/%s/protocol/openid-connect/certs", authProtocol, authHost, authPort, realm) + } + + return &Provider{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURI: redirectURI, + Scopes: scopes, + AuthHost: authHost, + AuthPort: authPort, + AuthProtocol: authProtocol, + Realm: realm, + MCPServerURL: mcpServerURL, + jwksCache: &jwksCache{ + keys: make(map[string]*rsa.PublicKey), + ttl: 10 * time.Minute, // Cache public keys for 10 minutes + jwksURL: jwksURL, + }, + } +} + +// getBaseURL returns the Keycloak base URL +func (p *Provider) getBaseURL() string { + // Omit port if it's the default for the protocol + if (p.AuthProtocol == "https" && p.AuthPort == 443) || (p.AuthProtocol == "http" && p.AuthPort == 80) { + return fmt.Sprintf("%s://%s/realms/%s/", p.AuthProtocol, p.AuthHost, p.Realm) + } + return fmt.Sprintf("%s://%s:%d/realms/%s/", p.AuthProtocol, p.AuthHost, p.AuthPort, p.Realm) +} + +// GetBaseURL returns the Keycloak base URL (public method) +func (p *Provider) GetBaseURL() string { + return p.getBaseURL() +} + +// GetAuthURL returns the Keycloak OAuth authorization URL +func (p *Provider) GetAuthURL(state string, codeVerifier string, nonce string, scopes []string) string { + baseURL := p.getBaseURL() + authURL, err := url.Parse(baseURL + "protocol/openid-connect/auth") + if err != nil { + log.Error().Err(err).Msg("Failed to parse Keycloak auth URL") + return "" + } + + // Generate code challenge from code verifier (PKCE) + // S256 method: BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + h := sha256.New() + h.Write([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + if len(scopes) == 0 { + scopes = p.Scopes // fallback to provider defaults + } + + // Add query parameters + q := authURL.Query() + q.Set("client_id", p.ClientID) + q.Set("redirect_uri", p.RedirectURI) + q.Set("response_type", "code") + q.Set("scope", strings.Join(scopes, " ")) + q.Set("state", state) + q.Set("nonce", nonce) + q.Set("code_challenge", codeChallenge) + q.Set("code_challenge_method", "S256") + + authURL.RawQuery = q.Encode() + + return authURL.String() +} + +// ExchangeToken exchanges an authorization code for access and ID tokens +func (p *Provider) ExchangeToken(code string, codeVerifier string) (string, string, []string, error) { + // Create the token exchange request + baseURL := p.getBaseURL() + tokenURL := baseURL + "protocol/openid-connect/token" + + data := url.Values{} + data.Set("code", code) + data.Set("client_id", p.ClientID) + data.Set("client_secret", p.ClientSecret) + data.Set("redirect_uri", p.RedirectURI) + data.Set("grant_type", "authorization_code") + data.Set("code_verifier", codeVerifier) + + // Make the request + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.PostForm(tokenURL, data) + if err != nil { + return "", "", nil, fmt.Errorf("failed to make token exchange request: %w", err) + } + defer resp.Body.Close() + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", nil, fmt.Errorf("failed to read token exchange response: %w", err) + } + + // Check for error response + if resp.StatusCode != http.StatusOK { + log.Error(). + Str("token_url", tokenURL). + Str("client_id", p.ClientID). + Str("redirect_uri", p.RedirectURI). + Int("status", resp.StatusCode). + Str("response", string(body)). + Msg("Keycloak token exchange failed") + return "", "", nil, fmt.Errorf("token exchange failed: %s - %s", resp.Status, string(body)) + } + + // Parse the response + var tokenResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + } + + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return "", "", nil, fmt.Errorf("failed to parse token exchange response: %w", err) + } + + // Validate the response + if tokenResponse.AccessToken == "" { + return "", "", nil, fmt.Errorf("no access token in response") + } + + scopes := strings.Split(tokenResponse.Scope, " ") + return tokenResponse.AccessToken, tokenResponse.IDToken, scopes, nil +} + +// GetUserInfo retrieves the user's profile information using the access token +func (p *Provider) GetUserInfo(accessToken string) (map[string]interface{}, error) { + // Create the user info request + baseURL := p.getBaseURL() + userInfoURL := baseURL + "protocol/openid-connect/userinfo" + + req, err := http.NewRequest("GET", userInfoURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create user info request: %w", err) + } + + // Add the access token to the request + req.Header.Add("Authorization", "Bearer "+accessToken) + + // Make the request + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make user info request: %w", err) + } + defer resp.Body.Close() + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read user info response: %w", err) + } + + // Check for error response + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("user info request failed: %s - %s", resp.Status, string(body)) + } + + // Parse the response + var userInfo map[string]interface{} + if err := json.Unmarshal(body, &userInfo); err != nil { + return nil, fmt.Errorf("failed to parse user info response: %w", err) + } + + return userInfo, nil +} + +// IntrospectToken validates a token using Keycloak's introspection endpoint +// This is used for MCP authentication (Bearer token validation) +func (p *Provider) IntrospectToken(token string) (*TokenInfo, error) { + baseURL := p.getBaseURL() + introspectionURL := baseURL + "protocol/openid-connect/token/introspect" + + // Log the introspection request details + log.Info(). + Str("url", introspectionURL). + Str("client_id", p.ClientID). + Str("token_prefix", token[:min(20, len(token))]). + Str("token_suffix", token[max(0, len(token)-10):]). + Int("token_length", len(token)). + Msg("[Keycloak] Starting token introspection") + + // Prepare introspection request + data := url.Values{} + data.Set("token", token) + data.Set("client_id", p.ClientID) + if p.ClientSecret != "" { + data.Set("client_secret", p.ClientSecret) + } + + // Make the request + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.PostForm(introspectionURL, data) + if err != nil { + log.Error().Err(err).Msg("[Keycloak] Introspection request failed") + return nil, fmt.Errorf("introspection request failed: %w", err) + } + defer resp.Body.Close() + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Error().Err(err).Msg("[Keycloak] Failed to read introspection response") + return nil, fmt.Errorf("failed to read introspection response: %w", err) + } + + // Check for error response + if resp.StatusCode != http.StatusOK { + log.Error(). + Int("status", resp.StatusCode). + Str("body", string(body)). + Msg("[Keycloak] Introspection failed") + return nil, fmt.Errorf("introspection failed: %s - %s", resp.Status, string(body)) + } + + // Parse the response + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + log.Error().Err(err).Msg("[Keycloak] Failed to parse introspection response") + return nil, fmt.Errorf("failed to parse introspection response: %w", err) + } + + // Log the full introspection response + log.Info(). + Interface("response", result). + Msg("[Keycloak] Introspection response received") + + // Check if token is active + active, ok := result["active"].(bool) + if !ok || !active { + log.Warn(). + Interface("full_response", result). + Bool("active_field_present", ok). + Bool("active_value", active). + Msg("[Keycloak] Token is inactive - full details") + return nil, fmt.Errorf("inactive token") + } + + // Validate audience if MCP server URL is configured + if p.MCPServerURL != "" { + if aud, exists := result["aud"]; exists { + if !p.checkResourceAllowed(aud) { + audiences := p.formatAudience(aud) + log.Warn(). + Str("expected", p.MCPServerURL). + Str("got", audiences). + Msg("[Keycloak] Audience validation failed") + return nil, fmt.Errorf("none of the provided audiences are allowed. Expected %s, got: %s", p.MCPServerURL, audiences) + } + } else { + log.Warn().Msg("[Keycloak] Resource indicator (aud) missing") + return nil, fmt.Errorf("resource indicator (aud) missing") + } + } + + // Extract token information + tokenInfo := &TokenInfo{ + Active: true, + } + + if clientID, ok := result["client_id"].(string); ok { + tokenInfo.ClientID = clientID + } + + if email, ok := result["email"].(string); ok { + tokenInfo.Email = email + } + + if scope, ok := result["scope"].(string); ok { + tokenInfo.Scopes = strings.Split(scope, " ") + } + + if exp, ok := result["exp"].(float64); ok { + tokenInfo.ExpiresAt = int64(exp) + } + + tokenInfo.Audience = result["aud"] + + return tokenInfo, nil +} + +// checkResourceAllowed validates if the audience matches the configured MCP server URL +func (p *Provider) checkResourceAllowed(aud interface{}) bool { + var audiences []string + + switch v := aud.(type) { + case string: + audiences = []string{v} + case []interface{}: + for _, a := range v { + if s, ok := a.(string); ok { + audiences = append(audiences, s) + } + } + case []string: + audiences = v + default: + return false + } + + // Check if any audience matches the MCP server URL + for _, audience := range audiences { + if p.resourceMatches(audience, p.MCPServerURL) { + return true + } + } + + return false +} + +// resourceMatches checks if the requested resource matches the configured resource +// This implements the same logic as checkResourceAllowed from the MCP SDK +func (p *Provider) resourceMatches(requested, configured string) bool { + // Parse both URLs + requestedURL, err := url.Parse(requested) + if err != nil { + return false + } + + configuredURL, err := url.Parse(configured) + if err != nil { + return false + } + + // Compare scheme, host, and path prefix + if requestedURL.Scheme != configuredURL.Scheme { + return false + } + + if requestedURL.Host != configuredURL.Host { + return false + } + + // Check if requested path starts with configured path + return strings.HasPrefix(requestedURL.Path, configuredURL.Path) +} + +// formatAudience converts the audience to a readable string +func (p *Provider) formatAudience(aud interface{}) string { + switch v := aud.(type) { + case string: + return v + case []interface{}: + var audiences []string + for _, a := range v { + if s, ok := a.(string); ok { + audiences = append(audiences, s) + } + } + return strings.Join(audiences, ", ") + case []string: + return strings.Join(v, ", ") + default: + return fmt.Sprintf("%v", aud) + } +} + +// ValidateJWT validates a JWT token locally without introspection +// This is the RECOMMENDED approach for token validation +func (p *Provider) ValidateJWT(token string) (*TokenInfo, error) { + log.Info().Msg("[Keycloak] Starting JWT validation (local, no introspection)") + + // Parse JWT header to get kid + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT format") + } + + // Decode header + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, fmt.Errorf("failed to decode JWT header: %w", err) + } + + var header struct { + Kid string `json:"kid"` + Alg string `json:"alg"` + } + if err := json.Unmarshal(headerBytes, &header); err != nil { + return nil, fmt.Errorf("failed to parse JWT header: %w", err) + } + + log.Info(). + Str("kid", header.Kid). + Str("alg", header.Alg). + Msg("[Keycloak] JWT header parsed") + + // Get public key for this kid + publicKey, err := p.getPublicKey(header.Kid) + if err != nil { + return nil, fmt.Errorf("failed to get public key: %w", err) + } + + // Verify signature + message := parts[0] + "." + parts[1] + signatureBytes, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("failed to decode signature: %w", err) + } + + // Hash the message + hashed := sha256.Sum256([]byte(message)) + + // Verify RSA signature (using SHA256 hash algorithm) + if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hashed[:], signatureBytes); err != nil { + log.Warn().Err(err).Msg("[Keycloak] JWT signature verification failed") + return nil, fmt.Errorf("invalid JWT signature: %w", err) + } + + log.Info().Msg("[Keycloak] JWT signature verified successfully") + + // Decode and parse claims + claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode JWT claims: %w", err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(claimsBytes, &claims); err != nil { + return nil, fmt.Errorf("failed to parse JWT claims: %w", err) + } + + // Validate expiration + if exp, ok := claims["exp"].(float64); ok { + if time.Now().Unix() > int64(exp) { + log.Warn().Int64("exp", int64(exp)).Msg("[Keycloak] JWT token expired") + return nil, fmt.Errorf("token expired") + } + } else { + return nil, fmt.Errorf("missing exp claim") + } + + // Validate issuer + baseURL := p.getBaseURL() + if iss, ok := claims["iss"].(string); ok { + if !strings.HasPrefix(iss, baseURL) && iss != strings.TrimSuffix(baseURL, "/") { + log.Warn(). + Str("expected", baseURL). + Str("got", iss). + Msg("[Keycloak] JWT issuer mismatch") + return nil, fmt.Errorf("invalid issuer") + } + } + + // Validate audience if configured + if p.MCPServerURL != "" { + if aud, exists := claims["aud"]; exists { + if !p.checkResourceAllowed(aud) { + audiences := p.formatAudience(aud) + log.Warn(). + Str("expected", p.MCPServerURL). + Str("got", audiences). + Msg("[Keycloak] JWT audience validation failed") + return nil, fmt.Errorf("audience validation failed. Expected %s, got: %s", p.MCPServerURL, audiences) + } + } else { + log.Warn().Msg("[Keycloak] JWT missing audience claim") + return nil, fmt.Errorf("missing audience claim") + } + } + + // Extract token info + tokenInfo := &TokenInfo{ + Active: true, + } + + if email, ok := claims["email"].(string); ok { + tokenInfo.Email = email + } + if preferredUsername, ok := claims["preferred_username"].(string); ok && tokenInfo.Email == "" { + tokenInfo.Email = preferredUsername + } + + if scope, ok := claims["scope"].(string); ok { + tokenInfo.Scopes = strings.Split(scope, " ") + } + + if exp, ok := claims["exp"].(float64); ok { + tokenInfo.ExpiresAt = int64(exp) + } + + if azp, ok := claims["azp"].(string); ok { + tokenInfo.ClientID = azp + } + + tokenInfo.Audience = claims["aud"] + + log.Info(). + Str("email", tokenInfo.Email). + Strs("scopes", tokenInfo.Scopes). + Str("client_id", tokenInfo.ClientID). + Int64("expires_at", tokenInfo.ExpiresAt). + Msg("[Keycloak] JWT validation successful") + + return tokenInfo, nil +} + +// getPublicKey retrieves the public key for a given kid, using cache +func (p *Provider) getPublicKey(kid string) (*rsa.PublicKey, error) { + cache := p.jwksCache + + cache.mu.RLock() + // Check if we have a cached key and it's not expired + if key, exists := cache.keys[kid]; exists && time.Since(cache.lastFetched) < cache.ttl { + cache.mu.RUnlock() + log.Debug().Str("kid", kid).Msg("[Keycloak] Using cached public key") + return key, nil + } + cache.mu.RUnlock() + + // Fetch JWKS + cache.mu.Lock() + defer cache.mu.Unlock() + + // Double-check after acquiring write lock + if key, exists := cache.keys[kid]; exists && time.Since(cache.lastFetched) < cache.ttl { + log.Debug().Str("kid", kid).Msg("[Keycloak] Using cached public key (race check)") + return key, nil + } + + log.Info().Str("jwks_url", cache.jwksURL).Msg("[Keycloak] Fetching JWKS") + + resp, err := http.Get(cache.jwksURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch JWKS: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("JWKS endpoint returned %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read JWKS response: %w", err) + } + + var keySet jwks + if err := json.Unmarshal(body, &keySet); err != nil { + return nil, fmt.Errorf("failed to parse JWKS: %w", err) + } + + log.Info().Int("key_count", len(keySet.Keys)).Msg("[Keycloak] JWKS fetched successfully") + + // Parse all keys and cache them + cache.keys = make(map[string]*rsa.PublicKey) + for _, key := range keySet.Keys { + if key.Kty != "RSA" { + continue + } + + // Decode modulus (n) + nBytes, err := base64.RawURLEncoding.DecodeString(key.N) + if err != nil { + log.Warn().Str("kid", key.Kid).Err(err).Msg("[Keycloak] Failed to decode key modulus") + continue + } + + // Decode exponent (e) + eBytes, err := base64.RawURLEncoding.DecodeString(key.E) + if err != nil { + log.Warn().Str("kid", key.Kid).Err(err).Msg("[Keycloak] Failed to decode key exponent") + continue + } + + // Convert exponent bytes to int + var eInt int + for _, b := range eBytes { + eInt = eInt<<8 + int(b) + } + + // Create RSA public key + pubKey := &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: eInt, + } + + cache.keys[key.Kid] = pubKey + log.Debug().Str("kid", key.Kid).Msg("[Keycloak] Cached public key") + } + + cache.lastFetched = time.Now() + + // Return the requested key + if key, exists := cache.keys[kid]; exists { + return key, nil + } + + return nil, fmt.Errorf("key with kid '%s' not found in JWKS", kid) +} diff --git a/server/server.go b/server/server.go index 5f8aa81..9b9cd17 100644 --- a/server/server.go +++ b/server/server.go @@ -16,10 +16,26 @@ import ( "mcpauth/server/database" "mcpauth/server/providers" "mcpauth/server/providers/google" + "mcpauth/server/providers/keycloak" "mcpauth/server/providers/local" "mcpauth/server/services" ) +// Helper functions +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + // Client represents an OAuth client type Client struct { ClientID string `json:"client_id"` @@ -35,6 +51,7 @@ type Server struct { Clients map[string]Client // Legacy in-memory clients for backward compatibility Provider providers.Provider InternalProvider *local.Provider + KeycloakProvider *keycloak.Provider // Keycloak provider for token introspection DB *database.DB UserService *services.UserService ClientService *services.ClientService @@ -45,6 +62,9 @@ type Server struct { AllowedScopes []string // list of scopes middleware is allowed to request RequiredScopes []string // list of scopes that must be present in tokens UseInternalAuth bool // Whether to use internal authentication + UseKeycloak bool // Whether to use Keycloak for authentication + EnableEdgeAuth bool // Whether to enable edge auth (CF_Authorization cookie) endpoints + CookieDomain string // Domain for the CF_Authorization cookie } // SessionData stores OAuth state and session information @@ -143,9 +163,11 @@ func (s *Server) ConfigureProvider(providerName, clientID, clientSecret, redirec case "google": s.Provider = google.NewProvider(clientID, clientSecret, redirectURI, scopes) s.UseInternalAuth = false + s.UseKeycloak = false return nil case "internal": s.UseInternalAuth = true + s.UseKeycloak = false log.Info().Msg("Configured to use internal authentication") return nil // Add more providers here as needed @@ -157,6 +179,33 @@ func (s *Server) ConfigureProvider(providerName, clientID, clientSecret, redirec } } +// ConfigureKeycloakProvider sets up Keycloak as the OAuth provider +func (s *Server) ConfigureKeycloakProvider(clientID, clientSecret, redirectURI string, scopes []string, authHost string, authPort int, authProtocol string, realm string) error { + protocol := "https" + if s.DevMode { + protocol = "http" + } + + // Construct the MCP server URL for audience validation + mcpServerURL := fmt.Sprintf("%s://%s", protocol, s.OAuthDomain) + + // Create the Keycloak provider + s.KeycloakProvider = keycloak.NewProvider(clientID, clientSecret, redirectURI, scopes, authHost, authPort, authProtocol, realm, mcpServerURL) + s.Provider = s.KeycloakProvider + s.UseInternalAuth = false + s.UseKeycloak = true + + log.Info(). + Str("auth_host", authHost). + Int("auth_port", authPort). + Str("auth_protocol", authProtocol). + Str("realm", realm). + Str("mcp_server_url", mcpServerURL). + Msg("Configured Keycloak provider") + + return nil +} + // CreateDefaultAdminUser creates a default admin user if no users exist func (s *Server) CreateDefaultAdminUser(username, email, password string) error { // Check if any users exist @@ -211,6 +260,10 @@ func (s *Server) SetupRoutes() { s.Router.GET("/.well-known/oauth-protected-resource", s.oauthProtectedResourceHandler) s.Router.OPTIONS("/.well-known/oauth-protected-resource", s.optionsHandler) + // Add MCP-specific protected resource metadata endpoint + s.Router.GET("/.well-known/oauth-protected-resource/mcp", s.oauthProtectedResourceHandler) + s.Router.OPTIONS("/.well-known/oauth-protected-resource/mcp", s.optionsHandler) + // Add OAuth client registration endpoint s.Router.POST("/register", s.registerHandler) s.Router.OPTIONS("/register", s.optionsHandler) @@ -253,6 +306,24 @@ func (s *Server) SetupRoutes() { // Generic auth handler for forwardAuth s.Router.Any("/auth", s.authHandler) + +} + +// SetupEdgeAuthRoutes registers edge auth routes. Must be called after EnableEdgeAuth is set. +func (s *Server) SetupEdgeAuthRoutes() { + if !s.EnableEdgeAuth { + return + } + + s.Router.GET("/auth/connect", s.edgeAuthConnectHandler) + s.Router.GET("/auth/connect/callback", s.edgeAuthConnectCallbackHandler) + s.Router.Any("/_ws_tunnel", s.edgeAuthWsTunnelHandler) + + // Register edge-auth client for internal auth redirect URI validation + if err := s.registerEdgeAuthClient(); err != nil { + log.Warn().Err(err).Msg("Failed to register edge auth client") + } + log.Info().Msg("Edge auth endpoints enabled: /auth/connect, /_ws_tunnel") } // healthCheckHandler returns a 200 OK response @@ -285,7 +356,25 @@ func (s *Server) oauthAuthorizationServerHandler(c *gin.Context) { protocol = "http" } - // Use the OAuthDomain field + // If using Keycloak, return Keycloak's endpoints + if s.UseKeycloak && s.KeycloakProvider != nil { + baseURL := s.KeycloakProvider.GetBaseURL() + c.JSON(200, gin.H{ + "issuer": baseURL, + "authorization_endpoint": baseURL + "protocol/openid-connect/auth", + "token_endpoint": baseURL + "protocol/openid-connect/token", + "introspection_endpoint": baseURL + "protocol/openid-connect/token/introspect", + "userinfo_endpoint": baseURL + "protocol/openid-connect/userinfo", + "jwks_uri": baseURL + "protocol/openid-connect/certs", + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code", "refresh_token"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, + "code_challenge_methods_supported": []string{"plain", "S256"}, + }) + return + } + + // Use the OAuthDomain field for internal/Google auth c.JSON(200, gin.H{ "issuer": fmt.Sprintf("%s://%s", protocol, s.OAuthDomain), "authorization_endpoint": fmt.Sprintf("%s://%s/authorize", protocol, s.OAuthDomain), @@ -334,10 +423,20 @@ func (s *Server) oauthProtectedResourceHandler(c *gin.Context) { // ✅ Always point to the canonical resource root (/mcp) resourceURL := fmt.Sprintf("%s://%s", protocol, host) + // Determine authorization server URL + var authServerURL string + if s.UseKeycloak && s.KeycloakProvider != nil { + // Point to Keycloak's authorization server + authServerURL = s.KeycloakProvider.GetBaseURL() + } else { + // Point to internal authorization server + authServerURL = fmt.Sprintf("%s://%s/", protocol, s.OAuthDomain) + } + // Return the protected resource metadata c.JSON(200, gin.H{ "resource": resourceURL, - "authorization_servers": []string{fmt.Sprintf("%s://%s/", protocol, s.OAuthDomain)}, + "authorization_servers": []string{authServerURL}, "scopes_supported": s.RequiredScopes, "resource_name": resourceURL, }) @@ -536,12 +635,17 @@ func (s *Server) callbackHandler(c *gin.Context) { return } - // Extract email from user info - email, ok := userInfo["email"].(string) - if !ok { - log.Error().Interface("user_info", userInfo).Msg("Email not found in user info") - c.JSON(500, gin.H{"error": "server_error"}) - return + // Extract email from user info, fall back to preferred_username + email, _ := userInfo["email"].(string) + if email == "" { + if username, ok := userInfo["preferred_username"].(string); ok && username != "" { + email = username + log.Warn().Str("preferred_username", username).Msg("Email not found in user info, using preferred_username") + } else { + log.Error().Interface("user_info", userInfo).Msg("Neither email nor preferred_username found in user info") + c.JSON(500, gin.H{"error": "server_error"}) + return + } } log.Info().Str("email", email).Msg("User authenticated") @@ -1828,7 +1932,7 @@ func (s *Server) buildWWWAuthenticateHeader() string { } resourceMetadataURL := fmt.Sprintf("%s://%s/.well-known/oauth-protected-resource", protocol, s.OAuthDomain) - return fmt.Sprintf("Bearer resource_metadata=\"%%s\", scope=\"mcp:read mcp:write\"", resourceMetadataURL) + return fmt.Sprintf("Bearer resource_metadata=\"%s\", scope=\"mcp:read mcp:write\"", resourceMetadataURL) } func normalizeScope(scope string) string { @@ -1887,8 +1991,37 @@ func (s *Server) authHandler(c *gin.Context) { // Extract token from header or query parameter if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { token = strings.TrimPrefix(authHeader, "Bearer ") + log.Info(). + Str("source", "header"). + Str("token_prefix", token[:min(20, len(token))]). + Str("token_suffix", token[max(0, len(token)-10):]). + Int("token_length", len(token)). + Msg("[Auth] Token extracted from Authorization header") } else if tokenParam != "" { token = tokenParam + log.Info(). + Str("source", "query_param"). + Str("token_prefix", token[:min(20, len(token))]). + Str("token_suffix", token[max(0, len(token)-10):]). + Int("token_length", len(token)). + Msg("[Auth] Token extracted from query parameter") + } else if s.EnableEdgeAuth { + // Try CF_Authorization cookie (edge auth flow) + if cfToken := extractCFAuthCookie(c); cfToken != "" { + token = cfToken + log.Info(). + Str("source", "cf_authorization_cookie"). + Int("token_length", len(token)). + Msg("[Auth] Token extracted from CF_Authorization cookie") + } else { + log.Warn().Msg("Missing authorization token") + c.Header("WWW-Authenticate", s.buildWWWAuthenticateHeader()) + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return + } } else { // No token provided, return 401 Unauthorized with WWW-Authenticate header log.Warn().Msg("Missing authorization token") @@ -1900,19 +2033,50 @@ func (s *Server) authHandler(c *gin.Context) { return } - // Validate the token by checking if it exists in the session store. - // This is now an efficient O(1) lookup. - sessionData, ok := s.Sessions.GetByToken(token) + var sessionData SessionData + var ok bool - if !ok { - // Token not found or expired - log.Warn().Msg("Invalid or expired token") - c.Header("WWW-Authenticate", s.buildWWWAuthenticateHeader()+" error=\"invalid_token\"") - c.JSON(401, gin.H{ - "status": 401, - "message": "Invalid or expired token", - }) - return + // If using Keycloak, validate JWT locally (RECOMMENDED - no introspection) + if s.UseKeycloak && s.KeycloakProvider != nil { + log.Info().Msg("[Auth] Using Keycloak JWT validation (local, no introspection)") + tokenInfo, err := s.KeycloakProvider.ValidateJWT(token) + if err != nil { + log.Warn().Err(err).Msg("Keycloak JWT validation failed") + c.Header("WWW-Authenticate", s.buildWWWAuthenticateHeader()+" error=\"invalid_token\"") + c.JSON(401, gin.H{ + "status": 401, + "message": "Invalid or expired token", + }) + return + } + + log.Info(). + Str("email", tokenInfo.Email). + Strs("scopes", tokenInfo.Scopes). + Int64("expires_at", tokenInfo.ExpiresAt). + Msg("[Auth] Keycloak JWT validation successful") + + // Convert token info to session data format + sessionData = SessionData{ + Email: tokenInfo.Email, + Scopes: tokenInfo.Scopes, + } + ok = true + } else { + // Validate the token by checking if it exists in the session store. + // This is now an efficient O(1) lookup. + sessionData, ok = s.Sessions.GetByToken(token) + + if !ok { + // Token not found or expired + log.Warn().Msg("Invalid or expired token") + c.Header("WWW-Authenticate", s.buildWWWAuthenticateHeader()+" error=\"invalid_token\"") + c.JSON(401, gin.H{ + "status": 401, + "message": "Invalid or expired token", + }) + return + } } // Check if the session has the required scopes diff --git a/server/services/user_service.go b/server/services/user_service.go index fcc7049..317ff6b 100644 --- a/server/services/user_service.go +++ b/server/services/user_service.go @@ -147,6 +147,11 @@ func (s *ClientService) CreateClient(clientName string, redirectURIs []string, s return s.clientRepo.CreateClient(clientName, redirectURIs, scopes) } +// CreateEdgeAuthClient creates an OAuth client with a specific client ID for edge auth. +func (s *ClientService) CreateEdgeAuthClient(clientID string, redirectURIs []string, scopes []string) (*database.OAuthClient, error) { + return s.clientRepo.CreateClientWithID(clientID, "Edge Auth Connect", redirectURIs, scopes) +} + // GetClient retrieves a client by client ID func (s *ClientService) GetClient(clientID string) (*database.OAuthClient, error) { return s.clientRepo.GetClientByID(clientID) diff --git a/server/session.go b/server/session.go index 5d62d24..1fe3e9d 100644 --- a/server/session.go +++ b/server/session.go @@ -119,16 +119,23 @@ func (s *SessionStore) GetByToken(token string) (SessionData, bool) { return SessionData{}, false } - // 🔑 Extract scopes (if present in JWT claims) + // Extract email (if present in JWT claims) + var email string + if rawEmail, ok := (*claims)["email"].(string); ok { + email = rawEmail + } + + // Extract scopes (if present in JWT claims) var scopes []string if rawScopes, ok := (*claims)["scopes"]; ok { - scopes = toStringSlice(rawScopes) // your helper already converts []interface{} → []string + scopes = toStringSlice(rawScopes) } session := SessionData{ AccessToken: token, ExpiresAt: time.Unix(int64(exp), 0), - Scopes: scopes, // ✅ now included + Email: email, + Scopes: scopes, } return session, true @@ -152,6 +159,20 @@ func (s *SessionStore) CleanupExpired() { } } +// mintCFAuthorizationJWT creates a signed JWT for the CF_Authorization cookie. +func mintCFAuthorizationJWT(email string, scopes []string, ttl time.Duration) (string, error) { + now := time.Now() + claims := jwt.MapClaims{ + "sub": email, + "email": email, + "scopes": scopes, + "iat": now.Unix(), + "exp": now.Add(ttl).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + func validateJWT(tokenStr string) (*jwt.MapClaims, error) { parsed, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { // Ensure HMAC is used