mirror of
https://github.com/coder/coder.git
synced 2026-06-04 13:38:21 +00:00
b776a14b46
## Summary Harden the OAuth2 provider with multiple security fixes addressing `coder/security#121` (CSRF session takeover) and converge on OAuth 2.1 compliance. ### Security Fixes | Fix | Description | Commits | |-----|-------------|---------| | **CSRF on `/oauth2/authorize`** | Enforce CSRF protection on the authorize endpoint POST (consent form submission) | `ba7d646`, `b94a64e` | | **Clickjacking: `frame-ancestors` CSP** | Prevent consent page from being iframed (`Content-Security-Policy: frame-ancestors 'none'` + `X-Frame-Options: DENY`) | `597aeb2` | | **Exact redirect URI matching** | Changed from prefix matching to full string exact matching per OAuth 2.1 §4.1.2.1 | `73d64b1`, `93897f1` | | **Store & verify `redirect_uri`** | Store redirect_uri with auth code in DB, verify at token exchange matches exactly (RFC 6749 §4.1.3) | `50569b9`, `d7ca315` | | **Mandatory PKCE** | Require `code_challenge` at authorization (for `response_type=code`) + unconditional `code_verifier` verification at token exchange | `d7ca315`, `1cda1a9` | | **Reject implicit grant** | `response_type=token` now returns `unsupported_response_type` error page (OAuth 2.1 removes implicit flow) | `d7ca315`, `91b8863` | ### Changes by File **`coderd/httpmw/csrf.go`** — Extended the CSRF `ExemptFunc` to enforce CSRF on `/oauth2/authorize` in addition to `/api` routes. The consent form POST is now CSRF-protected to prevent cross-site authorization code theft. **`site/site.go`** — Added `Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY` headers to `RenderOAuthAllowPage` (consent page only — does not affect the SPA/global CSP used by AI tasks). **`coderd/httpapi/queryparams.go`** — Changed `RedirectURL` from prefix matching (`strings.HasPrefix(v.Path, base.Path)`) to full URI exact matching (`v.String() != base.String()`), comparing scheme, host, path, and query. **`coderd/oauth2provider/authorize.go`** — Added PKCE enforcement: `code_challenge` is required when `response_type=code` (via a conditional check, not `RequiredNotEmpty`, so `response_type=token` can reach the explicit rejection path). `ShowAuthorizePage` (GET) validates `response_type` before rendering and returns a 400 error page for unsupported types. `ProcessAuthorize` (POST) stores the `redirect_uri` with the auth code when explicitly provided. **`coderd/oauth2provider/tokens.go`** — PKCE verification is now unconditional (not gated on `code_challenge` being present in DB). If the stored code has a `redirect_uri`, the token endpoint verifies it matches exactly — mismatch returns `errBadCode` → `invalid_grant`. Missing `code_verifier` returns `invalid_grant`. **`codersdk/oauth2.go`** — `OAuth2ProviderResponseTypeToken` constant and `Valid()` acceptance are **kept** so the authorize handler can parse `response_type=token` and return the proper `unsupported_response_type` error rather than failing at parameter validation. **`coderd/database/migrations/000421_*`** — Added `redirect_uri text` column to `oauth2_provider_app_codes`. ### Design Decisions **`state` parameter remains optional** — The plan initially required `state` via `RequiredNotEmpty`, but this was reverted in `376a753` to avoid breaking existing clients. The `state` is still hashed and stored when provided (via `state_hash` column), securing clients that opt in. **`response_type=token` kept in `Valid()`** — Removing it from `Valid()` would cause the parameter parser to reject the request before the authorize handler can return the proper `unsupported_response_type` error. The constant is kept for correct error handling flow. **CSP scoped to consent page only** — `frame-ancestors 'none'` is set only on the OAuth consent page renderer, not globally. The SPA/global CSP was previously changed to allow framing for AI tasks ([#18102](https://github.com/coder/coder/pull/18102)); this change does not regress that. ### Out of Scope (follow-up PRs) - Bearer tokens in query strings (needs internal caller audit) - Scope enforcement on OAuth2 tokens - Rate limiting on dynamic client registration --- <details> <summary>📋 Implementation Plan</summary> # Plan: Harden OAuth2 Provider — Security Fixes + OAuth 2.1 Compliance ## Context & Why Security issue `coder/security#121` reports a critical session takeover via CSRF on the OAuth2 provider. This plan covers all remaining security fixes from that issue **plus** convergence on OAuth 2.1 requirements. The goal is a single PR that closes all actionable gaps. ## Current State (already committed on branch `csrf-sjx1`) | Fix | Status | Commits | |-----|--------|---------| | Fix 1: CSRF on `/oauth2/authorize` | ✅ Done | `ba7d646`, `b94a64e` | | CSRF token in consent form HTML | ✅ Done | `b94a64e` | | `state_hash` column + storage | ✅ Done (hash stored, but state still optional) | `9167d83`, `b94a64e` | | Tests for CSRF + state hash | ✅ Done | `e4119b5` | ## Remaining Work ### ~~Fix 2 — Require `state` parameter~~ (DROPPED) > **Decision:** Do not enforce `state` as required. The `state` parameter is still hashed and stored when provided (via `hashOAuth2State` / `state_hash` column from prior commits), but clients are not forced to supply it. This avoids breaking existing integrations that omit state. **Rollback:** Remove `"state"` from the `RequiredNotEmpty` call in `coderd/oauth2provider/authorize.go:42`: ```go // BEFORE (current on branch) p.RequiredNotEmpty("response_type", "client_id", "state", "code_challenge") // AFTER p.RequiredNotEmpty("response_type", "client_id", "code_challenge") ``` No test changes needed — tests already pass `state` voluntarily. ### Fix 4 — Exact redirect URI matching Currently `coderd/httpapi/queryparams.go:233` uses prefix matching: ```go // CURRENT — prefix match if v.Host != base.Host || !strings.HasPrefix(v.Path, base.Path) { ``` OAuth 2.1 requires **exact string matching**. Change to: ```go // AFTER — exact match (OAuth 2.1 §4.1.2.1) if v.Host != base.Host || v.Path != base.Path { ``` **File: `coderd/httpapi/queryparams.go` — `RedirectURL` method** Also update the error message from "must be a subset of" to "must exactly match". **Additionally**, store `redirect_uri` with the auth code and verify at the token endpoint (RFC 6749 §4.1.3): 1. **New migration** (same migration file or a new `000421`): Add `redirect_uri text` column to `oauth2_provider_app_codes` 2. **Update INSERT query** in `coderd/database/queries/oauth2.sql` to include `redirect_uri` 3. **`coderd/oauth2provider/authorize.go`**: Store `params.redirectURL.String()` when inserting the code 4. **`coderd/oauth2provider/tokens.go`**: After retrieving the code from DB, verify that `redirect_uri` from the token request matches the stored value exactly. Currently `tokens.go:103` calls `p.RedirectURL(vals, callbackURL, "redirect_uri")` for prefix validation only — it must compare against the stored redirect_uri from the code, not just the app's callback URL. <details> <summary>Why both exact match AND store+verify?</summary> Exact matching at the authorize endpoint prevents open redirectors (attacker can't use a sub-path). Storing and verifying at the token endpoint prevents code injection — an attacker who steals a code can't exchange it with a different redirect_uri than was originally authorized. This is required by RFC 6749 §4.1.3 and OAuth 2.1. </details> ### Fix 7 — `frame-ancestors` CSP on consent page The consent page can be iframed by a workspace app (same-site), which is the attack vector. Add a `Content-Security-Policy` header to prevent framing. **File: `site/site.go` — `RenderOAuthAllowPage` function (~line 731)** Before writing the response, add: ```go func RenderOAuthAllowPage(rw http.ResponseWriter, r *http.Request, data RenderOAuthAllowData) { rw.Header().Set("Content-Type", "text/html; charset=utf-8") // Prevent the consent page from being framed to mitigate // clickjacking attacks (coder/security#121). rw.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") rw.Header().Set("X-Frame-Options", "DENY") ... ``` Both headers for defense-in-depth (CSP for modern browsers, X-Frame-Options for legacy). ### OAuth 2.1 — Mandatory PKCE Currently PKCE is checked only when `code_challenge` was provided during authorization (`tokens.go:258`): ```go // CURRENT — conditional check if dbCode.CodeChallenge.Valid && dbCode.CodeChallenge.String != "" { // verify PKCE } ``` OAuth 2.1 requires PKCE for ALL authorization code flows. Change to: **File: `coderd/oauth2provider/authorize.go`** — Add `"code_challenge"` to required params: ```go p.RequiredNotEmpty("response_type", "client_id", "code_challenge") ``` **File: `coderd/oauth2provider/tokens.go:257-265`** — Make PKCE verification unconditional: ```go // AFTER — PKCE always required (OAuth 2.1) if req.CodeVerifier == "" { return codersdk.OAuth2TokenResponse{}, errInvalidPKCE } if !dbCode.CodeChallenge.Valid || dbCode.CodeChallenge.String == "" { // Code was issued without a challenge — should not happen // with the authorize endpoint enforcement, but defend in // depth. return codersdk.OAuth2TokenResponse{}, errInvalidPKCE } if !VerifyPKCE(dbCode.CodeChallenge.String, req.CodeVerifier) { return codersdk.OAuth2TokenResponse{}, errInvalidPKCE } ``` **File: `codersdk/oauth2.go`** — Remove `OAuth2ProviderResponseTypeToken` from the enum or reject it explicitly in the authorize handler. Currently it's defined at line 216 but the handler ignores `response_type` and always issues a code. We should either: - (a) Remove the `"token"` variant from the enum and reject it with `unsupported_response_type`, OR - (b) Add an explicit check in `ProcessAuthorize` that rejects `response_type=token` Option (b) is simpler and more backwards-compatible: ```go // In ProcessAuthorize, after extracting params: if params.responseType != codersdk.OAuth2ProviderResponseTypeCode { httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeUnsupportedResponseType, "Only response_type=code is supported") return } ``` ### OAuth 2.1 — Bearer tokens in query strings `coderd/httpmw/apikey.go:743` accepts `access_token` from URL query parameters. OAuth 2.1 prohibits this. However, this may be used internally (e.g., workspace apps, DERP). Need to audit callers before removing. **Approach:** This is a larger change with potential breakage. Mark as a **separate follow-up issue** rather than including in this PR. Document the finding. ### OAuth 2.1 — Removed flows ✅ **Already compliant.** `tokens.go` only supports `authorization_code` and `refresh_token` grant types. The implicit grant (`response_type=token`) will be explicitly rejected per the PKCE section above. ### OAuth 2.1 — Refresh token rotation ✅ **Already compliant.** `tokens.go:442` deletes the old API key when a refresh token is used. ## Migration Plan All DB changes can go in a single new migration (or extend 000420 if the branch is rebased before merge). Columns to add: - `redirect_uri text` on `oauth2_provider_app_codes` The `state_hash` column is already added by migration 000420. ## Implementation Order 1. **Fix 7** — CSP headers on consent page (isolated, no deps) 2. ~~**Fix 2** — Require `state` parameter~~ (DROPPED — state stays optional) 3. **Fix 4** — Exact redirect URI matching + store/verify redirect_uri 4. **PKCE mandatory** — Require `code_challenge` + reject `response_type=token` 5. **Rollback** — Remove `"state"` from `RequiredNotEmpty` in `authorize.go` 6. **Tests** — Update/add tests for all changes 7. **`make gen`** after DB changes ## Out of Scope (separate PRs) - Bearer tokens in query strings (needs internal caller audit) - Scope enforcement on OAuth2 tokens - Rate limiting / quota on dynamic client registration </details> --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh`_
296 lines
9.4 KiB
Markdown
296 lines
9.4 KiB
Markdown
# OAuth2 Provider (Experimental)
|
|
|
|
> [!WARNING]
|
|
> The OAuth2 provider functionality is currently **experimental and unstable**. This feature:
|
|
>
|
|
> - Is subject to breaking changes without notice
|
|
> - May have incomplete functionality
|
|
> - Is not recommended for production use
|
|
> - Requires the `oauth2` experiment flag to be enabled
|
|
>
|
|
> Use this feature for development and testing purposes only.
|
|
|
|
Coder can act as an OAuth2 authorization server, allowing third-party applications to authenticate users through Coder and access the Coder API on their behalf. This enables integrations where external applications can leverage Coder's authentication and user management.
|
|
|
|
## Requirements
|
|
|
|
- Admin privileges in Coder
|
|
- OAuth2 experiment flag enabled
|
|
- HTTPS recommended for production deployments
|
|
|
|
## Enable OAuth2 Provider
|
|
|
|
Add the `oauth2` experiment flag to your Coder server:
|
|
|
|
```bash
|
|
coder server --experiments oauth2
|
|
```
|
|
|
|
Or set the environment variable:
|
|
|
|
```env
|
|
CODER_EXPERIMENTS=oauth2
|
|
```
|
|
|
|
## Creating OAuth2 Applications
|
|
|
|
### Method 1: Web UI
|
|
|
|
1. Navigate to **Deployment Settings** → **OAuth2 Applications**
|
|
2. Click **Create Application**
|
|
3. Fill in the application details:
|
|
- **Name**: Your application name
|
|
- **Callback URL**: `https://yourapp.example.com/callback`
|
|
- **Icon**: Optional icon URL
|
|
|
|
### Method 2: Management API
|
|
|
|
Create an application using the Coder API:
|
|
|
|
```bash
|
|
curl -X POST \
|
|
-H "Authorization: Bearer $CODER_SESSION_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"name": "My Application",
|
|
"callback_url": "https://myapp.example.com/callback",
|
|
"icon": "https://myapp.example.com/icon.png"
|
|
}' \
|
|
"$CODER_URL/api/v2/oauth2-provider/apps"
|
|
```
|
|
|
|
Generate a client secret:
|
|
|
|
```bash
|
|
curl -X POST \
|
|
-H "Authorization: Bearer $CODER_SESSION_TOKEN" \
|
|
"$CODER_URL/api/v2/oauth2-provider/apps/$APP_ID/secrets"
|
|
```
|
|
|
|
## Integration Patterns
|
|
|
|
### Client Authentication Methods
|
|
|
|
Coder supports the following OAuth2 client authentication methods at the token endpoint (`/oauth2/tokens`):
|
|
|
|
- `client_secret_basic` (recommended): HTTP Basic authentication (RFC 6749 §2.3.1). The username is `client_id` and the password is `client_secret`.
|
|
- `client_secret_post`: Form-based authentication where `client_id` and `client_secret` are sent in the request body.
|
|
|
|
Coder supports both methods for compatibility; existing integrations using `client_secret_post` do not need to change.
|
|
|
|
If you use Dynamic Client Registration (RFC 7591) and omit `token_endpoint_auth_method`, clients default to `client_secret_basic`. To request `client_secret_post`, set `token_endpoint_auth_method` to `client_secret_post` in the registration request.
|
|
|
|
If client authentication fails, the token endpoint returns **HTTP 401** with an OAuth2 `invalid_client` error and a `WWW-Authenticate: Basic realm="coder"` response header.
|
|
|
|
### Standard OAuth2 Flow
|
|
|
|
1. **Authorization Request**: Redirect users to Coder's authorization endpoint:
|
|
|
|
```url
|
|
https://coder.example.com/oauth2/authorize?
|
|
client_id=your-client-id&
|
|
response_type=code&
|
|
redirect_uri=https://yourapp.example.com/callback&
|
|
state=random-string
|
|
```
|
|
|
|
2. **Token Exchange**: Exchange the authorization code for an access token.
|
|
|
|
**Option A: HTTP Basic authentication (`client_secret_basic`, recommended)**
|
|
|
|
```bash
|
|
curl -X POST \
|
|
-u "$CLIENT_ID:$CLIENT_SECRET" \
|
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
-d "grant_type=authorization_code" \
|
|
-d "code=$AUTH_CODE" \
|
|
-d "redirect_uri=https://yourapp.example.com/callback" \
|
|
"$CODER_URL/oauth2/tokens"
|
|
```
|
|
|
|
**Option B: Form parameters (`client_secret_post`)**
|
|
|
|
```bash
|
|
curl -X POST \
|
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
-d "grant_type=authorization_code" \
|
|
-d "code=$AUTH_CODE" \
|
|
-d "client_id=$CLIENT_ID" \
|
|
-d "client_secret=$CLIENT_SECRET" \
|
|
-d "redirect_uri=https://yourapp.example.com/callback" \
|
|
"$CODER_URL/oauth2/tokens"
|
|
```
|
|
|
|
3. **API Access**: Use the access token to call Coder's API:
|
|
|
|
```bash
|
|
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
|
|
"$CODER_URL/api/v2/users/me"
|
|
```
|
|
|
|
> [!NOTE]
|
|
> The PKCE flow below is the **required** integration path. The example
|
|
> above is shown for reference but omits the mandatory `code_challenge`
|
|
> parameter. See [PKCE Flow](#pkce-flow-required) for the complete flow.
|
|
|
|
### PKCE Flow (Required)
|
|
|
|
PKCE is **required** for all OAuth2 authorization code flows. Coder enforces
|
|
PKCE in compliance with the OAuth 2.1 specification. Both public and
|
|
confidential clients must include PKCE parameters:
|
|
|
|
1. Generate a code verifier and challenge:
|
|
|
|
```bash
|
|
CODE_VERIFIER=$(openssl rand -base64 96 | tr -d "=+/" | cut -c1-128)
|
|
CODE_CHALLENGE=$(echo -n $CODE_VERIFIER | openssl dgst -sha256 -binary | base64 | tr -d "=+/" | cut -c1-43)
|
|
```
|
|
|
|
2. Include PKCE parameters in the authorization request:
|
|
|
|
```url
|
|
https://coder.example.com/oauth2/authorize?
|
|
client_id=your-client-id&
|
|
response_type=code&
|
|
code_challenge=$CODE_CHALLENGE&
|
|
code_challenge_method=S256&
|
|
redirect_uri=https://yourapp.example.com/callback
|
|
```
|
|
|
|
3. Include the code verifier in the token exchange (see [Client Authentication Methods](#client-authentication-methods)):
|
|
|
|
```bash
|
|
curl -X POST \
|
|
-u "$CLIENT_ID:$CLIENT_SECRET" \
|
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
-d "grant_type=authorization_code" \
|
|
-d "code=$AUTH_CODE" \
|
|
-d "code_verifier=$CODE_VERIFIER" \
|
|
-d "redirect_uri=https://yourapp.example.com/callback" \
|
|
"$CODER_URL/oauth2/tokens"
|
|
```
|
|
|
|
## Discovery Endpoints
|
|
|
|
Coder provides OAuth2 discovery endpoints for programmatic integration:
|
|
|
|
- **Authorization Server Metadata**: `GET /.well-known/oauth-authorization-server`
|
|
- **Protected Resource Metadata**: `GET /.well-known/oauth-protected-resource`
|
|
|
|
These endpoints return server capabilities and endpoint URLs according to [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) and [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728).
|
|
|
|
## Token Management
|
|
|
|
### Refresh Tokens
|
|
|
|
Refresh an expired access token.
|
|
|
|
**Option A: HTTP Basic authentication (`client_secret_basic`)**
|
|
|
|
```bash
|
|
curl -X POST \
|
|
-u "$CLIENT_ID:$CLIENT_SECRET" \
|
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
-d "grant_type=refresh_token" \
|
|
-d "refresh_token=$REFRESH_TOKEN" \
|
|
"$CODER_URL/oauth2/tokens"
|
|
```
|
|
|
|
**Option B: Form parameters (`client_secret_post`)**
|
|
|
|
```bash
|
|
curl -X POST \
|
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
-d "grant_type=refresh_token" \
|
|
-d "refresh_token=$REFRESH_TOKEN" \
|
|
-d "client_id=$CLIENT_ID" \
|
|
-d "client_secret=$CLIENT_SECRET" \
|
|
"$CODER_URL/oauth2/tokens"
|
|
```
|
|
|
|
### Revoke Access
|
|
|
|
Revoke all tokens for an application:
|
|
|
|
```bash
|
|
curl -X DELETE \
|
|
-H "Authorization: Bearer $CODER_SESSION_TOKEN" \
|
|
"$CODER_URL/oauth2/tokens?client_id=$CLIENT_ID"
|
|
```
|
|
|
|
## Testing and Development
|
|
|
|
Coder provides comprehensive test scripts for OAuth2 development:
|
|
|
|
```bash
|
|
# Navigate to the OAuth2 test scripts
|
|
cd scripts/oauth2/
|
|
|
|
# Run the full automated test suite
|
|
./test-mcp-oauth2.sh
|
|
|
|
# Create a test application for manual testing
|
|
eval $(./setup-test-app.sh)
|
|
|
|
# Run an interactive browser-based test
|
|
./test-manual-flow.sh
|
|
|
|
# Clean up when done
|
|
./cleanup-test-app.sh
|
|
```
|
|
|
|
For more details on testing, see the [OAuth2 test scripts README](../../../scripts/oauth2/README.md).
|
|
|
|
## Common Issues
|
|
|
|
### "OAuth2 experiment not enabled"
|
|
|
|
Add `oauth2` to your experiment flags: `coder server --experiments oauth2`
|
|
|
|
### "Invalid redirect_uri"
|
|
|
|
Ensure the redirect URI in your request exactly matches the one registered for your application.
|
|
|
|
### "PKCE verification failed"
|
|
|
|
Verify that the `code_verifier` used in the token request matches the one used to generate the `code_challenge`.
|
|
|
|
## Security Considerations
|
|
|
|
- **Use HTTPS**: Always use HTTPS in production to protect tokens in transit
|
|
- **Implement PKCE**: PKCE is mandatory for all authorization code clients
|
|
(public and confidential)
|
|
- **Validate redirect URLs**: Only register trusted redirect URIs for your applications
|
|
- **Rotate secrets**: Periodically rotate client secrets using the management API
|
|
|
|
## Limitations
|
|
|
|
As an experimental feature, the current implementation has limitations:
|
|
|
|
- No scope system - all tokens have full API access
|
|
- No client credentials grant support
|
|
- Implicit grant (`response_type=token`) is not supported; OAuth 2.1
|
|
deprecated this flow due to token leakage risks, and requests return
|
|
`unsupported_response_type`
|
|
- Limited to opaque access tokens (no JWT support)
|
|
|
|
## Standards Compliance
|
|
|
|
This implementation follows established OAuth2 standards including
|
|
[RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) (OAuth2 core),
|
|
[RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) (PKCE), and the
|
|
[OAuth 2.1 draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12).
|
|
Coder enforces OAuth 2.1 requirements including mandatory PKCE for all
|
|
authorization code grants, exact redirect URI string matching, rejection
|
|
of the implicit grant, and CSRF protections on consent pages.
|
|
|
|
## Next Steps
|
|
|
|
- Review the [API Reference](../../reference/api/index.md) for complete endpoint documentation
|
|
- Check [External Authentication](../external-auth/index.md) for configuring Coder as an OAuth2 client
|
|
- See [Security Best Practices](../security/index.md) for deployment security guidance
|
|
|
|
## Feedback
|
|
|
|
This is an experimental feature under active development. Please report issues and feedback through [GitHub Issues](https://github.com/coder/coder/issues) with the `oauth2` label.
|