mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +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`_
649 lines
26 KiB
Go
649 lines
26 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type OAuth2ProviderApp struct {
|
|
ID uuid.UUID `json:"id" format:"uuid"`
|
|
Name string `json:"name"`
|
|
CallbackURL string `json:"callback_url"`
|
|
Icon string `json:"icon"`
|
|
|
|
// Endpoints are included in the app response for easier discovery. The OAuth2
|
|
// spec does not have a defined place to find these (for comparison, OIDC has
|
|
// a '/.well-known/openid-configuration' endpoint).
|
|
Endpoints OAuth2AppEndpoints `json:"endpoints"`
|
|
}
|
|
|
|
type OAuth2AppEndpoints struct {
|
|
Authorization string `json:"authorization"`
|
|
Token string `json:"token"`
|
|
TokenRevoke string `json:"token_revoke"`
|
|
// DeviceAuth is optional.
|
|
DeviceAuth string `json:"device_authorization"`
|
|
}
|
|
|
|
type OAuth2ProviderAppFilter struct {
|
|
UserID uuid.UUID `json:"user_id,omitempty" format:"uuid"`
|
|
}
|
|
|
|
// OAuth2ProviderApps returns the applications configured to authenticate using
|
|
// Coder as an OAuth2 provider.
|
|
func (c *Client) OAuth2ProviderApps(ctx context.Context, filter OAuth2ProviderAppFilter) ([]OAuth2ProviderApp, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, "/api/v2/oauth2-provider/apps", nil,
|
|
func(r *http.Request) {
|
|
if filter.UserID != uuid.Nil {
|
|
q := r.URL.Query()
|
|
q.Set("user_id", filter.UserID.String())
|
|
r.URL.RawQuery = q.Encode()
|
|
}
|
|
})
|
|
if err != nil {
|
|
return []OAuth2ProviderApp{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return []OAuth2ProviderApp{}, ReadBodyAsError(res)
|
|
}
|
|
var apps []OAuth2ProviderApp
|
|
return apps, json.NewDecoder(res.Body).Decode(&apps)
|
|
}
|
|
|
|
// OAuth2ProviderApp returns an application configured to authenticate using
|
|
// Coder as an OAuth2 provider.
|
|
func (c *Client) OAuth2ProviderApp(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), nil)
|
|
if err != nil {
|
|
return OAuth2ProviderApp{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return OAuth2ProviderApp{}, ReadBodyAsError(res)
|
|
}
|
|
var apps OAuth2ProviderApp
|
|
return apps, json.NewDecoder(res.Body).Decode(&apps)
|
|
}
|
|
|
|
type PostOAuth2ProviderAppRequest struct {
|
|
Name string `json:"name" validate:"required,oauth2_app_name"`
|
|
CallbackURL string `json:"callback_url" validate:"required,http_url"`
|
|
Icon string `json:"icon" validate:"omitempty"`
|
|
}
|
|
|
|
// PostOAuth2ProviderApp adds an application that can authenticate using Coder
|
|
// as an OAuth2 provider.
|
|
func (c *Client) PostOAuth2ProviderApp(ctx context.Context, app PostOAuth2ProviderAppRequest) (OAuth2ProviderApp, error) {
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/oauth2-provider/apps", app)
|
|
if err != nil {
|
|
return OAuth2ProviderApp{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusCreated {
|
|
return OAuth2ProviderApp{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ProviderApp
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
type PutOAuth2ProviderAppRequest struct {
|
|
Name string `json:"name" validate:"required,oauth2_app_name"`
|
|
CallbackURL string `json:"callback_url" validate:"required,http_url"`
|
|
Icon string `json:"icon" validate:"omitempty"`
|
|
}
|
|
|
|
// PutOAuth2ProviderApp updates an application that can authenticate using Coder
|
|
// as an OAuth2 provider.
|
|
func (c *Client) PutOAuth2ProviderApp(ctx context.Context, id uuid.UUID, app PutOAuth2ProviderAppRequest) (OAuth2ProviderApp, error) {
|
|
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), app)
|
|
if err != nil {
|
|
return OAuth2ProviderApp{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return OAuth2ProviderApp{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ProviderApp
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// DeleteOAuth2ProviderApp deletes an application, also invalidating any tokens
|
|
// that were generated from it.
|
|
func (c *Client) DeleteOAuth2ProviderApp(ctx context.Context, id uuid.UUID) error {
|
|
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type OAuth2ProviderAppSecretFull struct {
|
|
ID uuid.UUID `json:"id" format:"uuid"`
|
|
ClientSecretFull string `json:"client_secret_full"`
|
|
}
|
|
|
|
type OAuth2ProviderAppSecret struct {
|
|
ID uuid.UUID `json:"id" format:"uuid"`
|
|
LastUsedAt NullTime `json:"last_used_at"`
|
|
ClientSecretTruncated string `json:"client_secret_truncated"`
|
|
}
|
|
|
|
// OAuth2ProviderAppSecrets returns the truncated secrets for an OAuth2
|
|
// application.
|
|
func (c *Client) OAuth2ProviderAppSecrets(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets", appID), nil)
|
|
if err != nil {
|
|
return []OAuth2ProviderAppSecret{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return []OAuth2ProviderAppSecret{}, ReadBodyAsError(res)
|
|
}
|
|
var resp []OAuth2ProviderAppSecret
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// PostOAuth2ProviderAppSecret creates a new secret for an OAuth2 application.
|
|
// This is the only time the full secret will be revealed.
|
|
func (c *Client) PostOAuth2ProviderAppSecret(ctx context.Context, appID uuid.UUID) (OAuth2ProviderAppSecretFull, error) {
|
|
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets", appID), nil)
|
|
if err != nil {
|
|
return OAuth2ProviderAppSecretFull{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusCreated {
|
|
return OAuth2ProviderAppSecretFull{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ProviderAppSecretFull
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// DeleteOAuth2ProviderAppSecret deletes a secret from an OAuth2 application,
|
|
// also invalidating any tokens that generated from it.
|
|
func (c *Client) DeleteOAuth2ProviderAppSecret(ctx context.Context, appID uuid.UUID, secretID uuid.UUID) error {
|
|
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets/%s", appID, secretID), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type OAuth2ProviderGrantType string
|
|
|
|
// OAuth2ProviderGrantType values (RFC 6749).
|
|
const (
|
|
OAuth2ProviderGrantTypeAuthorizationCode OAuth2ProviderGrantType = "authorization_code"
|
|
OAuth2ProviderGrantTypeRefreshToken OAuth2ProviderGrantType = "refresh_token"
|
|
OAuth2ProviderGrantTypePassword OAuth2ProviderGrantType = "password"
|
|
OAuth2ProviderGrantTypeClientCredentials OAuth2ProviderGrantType = "client_credentials"
|
|
OAuth2ProviderGrantTypeImplicit OAuth2ProviderGrantType = "implicit"
|
|
)
|
|
|
|
func (e OAuth2ProviderGrantType) Valid() bool {
|
|
switch e {
|
|
case OAuth2ProviderGrantTypeAuthorizationCode,
|
|
OAuth2ProviderGrantTypeRefreshToken,
|
|
OAuth2ProviderGrantTypePassword,
|
|
OAuth2ProviderGrantTypeClientCredentials,
|
|
OAuth2ProviderGrantTypeImplicit:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type OAuth2ProviderResponseType string
|
|
|
|
// OAuth2ProviderResponseType values (RFC 6749).
|
|
const (
|
|
OAuth2ProviderResponseTypeCode OAuth2ProviderResponseType = "code"
|
|
OAuth2ProviderResponseTypeToken OAuth2ProviderResponseType = "token"
|
|
)
|
|
|
|
func (e OAuth2ProviderResponseType) Valid() bool {
|
|
return e == OAuth2ProviderResponseTypeCode || e == OAuth2ProviderResponseTypeToken
|
|
}
|
|
|
|
type OAuth2TokenEndpointAuthMethod string
|
|
|
|
const (
|
|
OAuth2TokenEndpointAuthMethodClientSecretBasic OAuth2TokenEndpointAuthMethod = "client_secret_basic"
|
|
OAuth2TokenEndpointAuthMethodClientSecretPost OAuth2TokenEndpointAuthMethod = "client_secret_post"
|
|
OAuth2TokenEndpointAuthMethodNone OAuth2TokenEndpointAuthMethod = "none"
|
|
)
|
|
|
|
func (m OAuth2TokenEndpointAuthMethod) Valid() bool {
|
|
switch m {
|
|
case OAuth2TokenEndpointAuthMethodClientSecretBasic,
|
|
OAuth2TokenEndpointAuthMethodClientSecretPost,
|
|
OAuth2TokenEndpointAuthMethodNone:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type OAuth2PKCECodeChallengeMethod string
|
|
|
|
// OAuth2PKCECodeChallengeMethod values (RFC 7636).
|
|
const (
|
|
OAuth2PKCECodeChallengeMethodS256 OAuth2PKCECodeChallengeMethod = "S256"
|
|
OAuth2PKCECodeChallengeMethodPlain OAuth2PKCECodeChallengeMethod = "plain"
|
|
)
|
|
|
|
func (m OAuth2PKCECodeChallengeMethod) Valid() bool {
|
|
switch m {
|
|
case OAuth2PKCECodeChallengeMethodS256, OAuth2PKCECodeChallengeMethodPlain:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type OAuth2TokenType string
|
|
|
|
// OAuth2TokenType values (RFC 6749, RFC 9449).
|
|
const (
|
|
OAuth2TokenTypeBearer OAuth2TokenType = "Bearer"
|
|
OAuth2TokenTypeDPoP OAuth2TokenType = "DPoP"
|
|
)
|
|
|
|
func (t OAuth2TokenType) Valid() bool {
|
|
switch t {
|
|
case OAuth2TokenTypeBearer, OAuth2TokenTypeDPoP:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type OAuth2RevocationTokenTypeHint string
|
|
|
|
const (
|
|
OAuth2RevocationTokenTypeHintAccessToken OAuth2RevocationTokenTypeHint = "access_token"
|
|
OAuth2RevocationTokenTypeHintRefreshToken OAuth2RevocationTokenTypeHint = "refresh_token"
|
|
)
|
|
|
|
func (h OAuth2RevocationTokenTypeHint) Valid() bool {
|
|
switch h {
|
|
case OAuth2RevocationTokenTypeHintAccessToken, OAuth2RevocationTokenTypeHintRefreshToken:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type OAuth2ErrorCode string
|
|
|
|
// OAuth2 error codes per RFC 6749, RFC 7009, RFC 8707.
|
|
// This is not comprehensive; it includes only codes relevant to this implementation.
|
|
const (
|
|
// RFC 6749 - Token endpoint errors.
|
|
OAuth2ErrorCodeInvalidRequest OAuth2ErrorCode = "invalid_request"
|
|
OAuth2ErrorCodeInvalidClient OAuth2ErrorCode = "invalid_client"
|
|
OAuth2ErrorCodeInvalidGrant OAuth2ErrorCode = "invalid_grant"
|
|
OAuth2ErrorCodeUnauthorizedClient OAuth2ErrorCode = "unauthorized_client"
|
|
OAuth2ErrorCodeUnsupportedGrantType OAuth2ErrorCode = "unsupported_grant_type"
|
|
OAuth2ErrorCodeInvalidScope OAuth2ErrorCode = "invalid_scope"
|
|
|
|
// RFC 6749 - Authorization endpoint errors.
|
|
OAuth2ErrorCodeAccessDenied OAuth2ErrorCode = "access_denied"
|
|
OAuth2ErrorCodeUnsupportedResponseType OAuth2ErrorCode = "unsupported_response_type"
|
|
OAuth2ErrorCodeServerError OAuth2ErrorCode = "server_error"
|
|
OAuth2ErrorCodeTemporarilyUnavailable OAuth2ErrorCode = "temporarily_unavailable"
|
|
|
|
// RFC 7009 - Token revocation errors.
|
|
OAuth2ErrorCodeUnsupportedTokenType OAuth2ErrorCode = "unsupported_token_type"
|
|
|
|
// RFC 8707 - Resource indicator errors.
|
|
OAuth2ErrorCodeInvalidTarget OAuth2ErrorCode = "invalid_target"
|
|
)
|
|
|
|
func (c OAuth2ErrorCode) Valid() bool {
|
|
switch c {
|
|
case OAuth2ErrorCodeInvalidRequest,
|
|
OAuth2ErrorCodeInvalidClient,
|
|
OAuth2ErrorCodeInvalidGrant,
|
|
OAuth2ErrorCodeUnauthorizedClient,
|
|
OAuth2ErrorCodeUnsupportedGrantType,
|
|
OAuth2ErrorCodeInvalidScope,
|
|
OAuth2ErrorCodeAccessDenied,
|
|
OAuth2ErrorCodeUnsupportedResponseType,
|
|
OAuth2ErrorCodeServerError,
|
|
OAuth2ErrorCodeTemporarilyUnavailable,
|
|
OAuth2ErrorCodeUnsupportedTokenType,
|
|
OAuth2ErrorCodeInvalidTarget:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// OAuth2Error represents an OAuth2-compliant error response per RFC 6749.
|
|
type OAuth2Error struct {
|
|
Error OAuth2ErrorCode `json:"error"`
|
|
ErrorDescription string `json:"error_description,omitempty"`
|
|
ErrorURI string `json:"error_uri,omitempty"`
|
|
}
|
|
|
|
// OAuth2TokenRequest represents a token request per RFC 6749. The actual wire
|
|
// format is application/x-www-form-urlencoded; this struct is for SDK docs.
|
|
type OAuth2TokenRequest struct {
|
|
GrantType OAuth2ProviderGrantType `json:"grant_type"`
|
|
Code string `json:"code,omitempty"`
|
|
RedirectURI string `json:"redirect_uri,omitempty"`
|
|
ClientID string `json:"client_id,omitempty"`
|
|
ClientSecret string `json:"client_secret,omitempty"`
|
|
CodeVerifier string `json:"code_verifier,omitempty"`
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
Resource string `json:"resource,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
}
|
|
|
|
// OAuth2TokenResponse represents a successful token response per RFC 6749.
|
|
type OAuth2TokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType OAuth2TokenType `json:"token_type"`
|
|
ExpiresIn int64 `json:"expires_in,omitempty"`
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
// Expiry is not part of RFC 6749 but is included for compatibility with
|
|
// golang.org/x/oauth2.Token and clients that expect a timestamp.
|
|
Expiry *time.Time `json:"expiry,omitempty" format:"date-time"`
|
|
}
|
|
|
|
// OAuth2TokenRevocationRequest represents a token revocation request per RFC 7009.
|
|
type OAuth2TokenRevocationRequest struct {
|
|
Token string `json:"token"`
|
|
TokenTypeHint OAuth2RevocationTokenTypeHint `json:"token_type_hint,omitempty"`
|
|
ClientID string `json:"client_id,omitempty"`
|
|
ClientSecret string `json:"client_secret,omitempty"`
|
|
}
|
|
|
|
// RevokeOAuth2Token revokes a specific OAuth2 token using RFC 7009 token revocation.
|
|
func (c *Client) RevokeOAuth2Token(ctx context.Context, clientID uuid.UUID, token string) error {
|
|
form := url.Values{}
|
|
form.Set("token", token)
|
|
// Client authentication is handled via the client_id in the app middleware
|
|
form.Set("client_id", clientID.String())
|
|
|
|
res, err := c.Request(ctx, http.MethodPost, "/oauth2/revoke", strings.NewReader(form.Encode()), func(r *http.Request) {
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RevokeOAuth2ProviderApp completely revokes an app's access for the
|
|
// authenticated user.
|
|
func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) error {
|
|
res, err := c.Request(ctx, http.MethodDelete, "/oauth2/tokens", nil, func(r *http.Request) {
|
|
q := r.URL.Query()
|
|
q.Set("client_id", appID.String())
|
|
r.URL.RawQuery = q.Encode()
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type OAuth2DeviceFlowCallbackResponse struct {
|
|
RedirectURL string `json:"redirect_url"`
|
|
}
|
|
|
|
// OAuth2AuthorizationServerMetadata represents RFC 8414 OAuth 2.0 Authorization Server Metadata.
|
|
type OAuth2AuthorizationServerMetadata struct {
|
|
Issuer string `json:"issuer"`
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
|
|
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
|
|
ResponseTypesSupported []OAuth2ProviderResponseType `json:"response_types_supported"`
|
|
GrantTypesSupported []OAuth2ProviderGrantType `json:"grant_types_supported,omitempty"`
|
|
CodeChallengeMethodsSupported []OAuth2PKCECodeChallengeMethod `json:"code_challenge_methods_supported,omitempty"`
|
|
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
|
TokenEndpointAuthMethodsSupported []OAuth2TokenEndpointAuthMethod `json:"token_endpoint_auth_methods_supported,omitempty"`
|
|
}
|
|
|
|
// OAuth2ProtectedResourceMetadata represents RFC 9728 OAuth 2.0 Protected Resource Metadata
|
|
type OAuth2ProtectedResourceMetadata struct {
|
|
Resource string `json:"resource"`
|
|
AuthorizationServers []string `json:"authorization_servers"`
|
|
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
|
BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"`
|
|
}
|
|
|
|
// OAuth2ClientRegistrationRequest represents RFC 7591 Dynamic Client Registration Request.
|
|
type OAuth2ClientRegistrationRequest struct {
|
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
|
ClientName string `json:"client_name,omitempty"`
|
|
ClientURI string `json:"client_uri,omitempty"`
|
|
LogoURI string `json:"logo_uri,omitempty"`
|
|
TOSURI string `json:"tos_uri,omitempty"`
|
|
PolicyURI string `json:"policy_uri,omitempty"`
|
|
JWKSURI string `json:"jwks_uri,omitempty"`
|
|
JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"`
|
|
SoftwareID string `json:"software_id,omitempty"`
|
|
SoftwareVersion string `json:"software_version,omitempty"`
|
|
SoftwareStatement string `json:"software_statement,omitempty"`
|
|
GrantTypes []OAuth2ProviderGrantType `json:"grant_types,omitempty"`
|
|
ResponseTypes []OAuth2ProviderResponseType `json:"response_types,omitempty"`
|
|
TokenEndpointAuthMethod OAuth2TokenEndpointAuthMethod `json:"token_endpoint_auth_method,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Contacts []string `json:"contacts,omitempty"`
|
|
}
|
|
|
|
func (req OAuth2ClientRegistrationRequest) ApplyDefaults() OAuth2ClientRegistrationRequest {
|
|
// Apply grant type defaults.
|
|
if len(req.GrantTypes) == 0 {
|
|
req.GrantTypes = []OAuth2ProviderGrantType{
|
|
OAuth2ProviderGrantTypeAuthorizationCode,
|
|
OAuth2ProviderGrantTypeRefreshToken,
|
|
}
|
|
}
|
|
|
|
// Apply response type defaults.
|
|
if len(req.ResponseTypes) == 0 {
|
|
req.ResponseTypes = []OAuth2ProviderResponseType{
|
|
OAuth2ProviderResponseTypeCode,
|
|
}
|
|
}
|
|
|
|
// Apply token endpoint auth method default (RFC 7591 section 2).
|
|
if req.TokenEndpointAuthMethod == "" {
|
|
// Default according to RFC 7591: "client_secret_basic" for confidential clients.
|
|
// For public clients, should be explicitly set to "none".
|
|
req.TokenEndpointAuthMethod = OAuth2TokenEndpointAuthMethodClientSecretBasic
|
|
}
|
|
|
|
// Apply client name default if not provided.
|
|
if req.ClientName == "" {
|
|
req.ClientName = "Dynamically Registered Client"
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
// DetermineClientType determines if client is public or confidential
|
|
func (*OAuth2ClientRegistrationRequest) DetermineClientType() string {
|
|
// For now, default to confidential
|
|
// In the future, we might detect based on:
|
|
// - token_endpoint_auth_method == "none" -> public
|
|
// - application_type == "native" -> might be public
|
|
// - Other heuristics
|
|
return "confidential"
|
|
}
|
|
|
|
// GenerateClientName generates a client name if not provided
|
|
func (req *OAuth2ClientRegistrationRequest) GenerateClientName() string {
|
|
if req.ClientName != "" {
|
|
// Ensure client name fits database constraint (varchar(64))
|
|
if len(req.ClientName) > 64 {
|
|
// Preserve uniqueness by including a hash of the original name
|
|
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(req.ClientName)))[:8]
|
|
maxPrefix := 64 - 1 - len(hash) // 1 for separator
|
|
return req.ClientName[:maxPrefix] + "-" + hash
|
|
}
|
|
return req.ClientName
|
|
}
|
|
|
|
// Try to derive from client_uri
|
|
if req.ClientURI != "" {
|
|
if uri, err := url.Parse(req.ClientURI); err == nil && uri.Host != "" {
|
|
name := fmt.Sprintf("Client (%s)", uri.Host)
|
|
if len(name) > 64 {
|
|
return name[:64]
|
|
}
|
|
return name
|
|
}
|
|
}
|
|
|
|
// Try to derive from first redirect URI
|
|
if len(req.RedirectURIs) > 0 {
|
|
if uri, err := url.Parse(req.RedirectURIs[0]); err == nil && uri.Host != "" {
|
|
name := fmt.Sprintf("Client (%s)", uri.Host)
|
|
if len(name) > 64 {
|
|
return name[:64]
|
|
}
|
|
return name
|
|
}
|
|
}
|
|
|
|
return "Dynamically Registered Client"
|
|
}
|
|
|
|
// OAuth2ClientRegistrationResponse represents RFC 7591 Dynamic Client Registration Response.
|
|
type OAuth2ClientRegistrationResponse struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientSecret string `json:"client_secret,omitempty"`
|
|
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
|
|
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
|
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
|
ClientName string `json:"client_name,omitempty"`
|
|
ClientURI string `json:"client_uri,omitempty"`
|
|
LogoURI string `json:"logo_uri,omitempty"`
|
|
TOSURI string `json:"tos_uri,omitempty"`
|
|
PolicyURI string `json:"policy_uri,omitempty"`
|
|
JWKSURI string `json:"jwks_uri,omitempty"`
|
|
JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"`
|
|
SoftwareID string `json:"software_id,omitempty"`
|
|
SoftwareVersion string `json:"software_version,omitempty"`
|
|
GrantTypes []OAuth2ProviderGrantType `json:"grant_types"`
|
|
ResponseTypes []OAuth2ProviderResponseType `json:"response_types"`
|
|
TokenEndpointAuthMethod OAuth2TokenEndpointAuthMethod `json:"token_endpoint_auth_method"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Contacts []string `json:"contacts,omitempty"`
|
|
RegistrationAccessToken string `json:"registration_access_token"`
|
|
RegistrationClientURI string `json:"registration_client_uri"`
|
|
}
|
|
|
|
// PostOAuth2ClientRegistration dynamically registers a new OAuth2 client (RFC 7591)
|
|
func (c *Client) PostOAuth2ClientRegistration(ctx context.Context, req OAuth2ClientRegistrationRequest) (OAuth2ClientRegistrationResponse, error) {
|
|
res, err := c.Request(ctx, http.MethodPost, "/oauth2/register", req)
|
|
if err != nil {
|
|
return OAuth2ClientRegistrationResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusCreated {
|
|
return OAuth2ClientRegistrationResponse{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ClientRegistrationResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// GetOAuth2ClientConfiguration retrieves client configuration (RFC 7592)
|
|
func (c *Client) GetOAuth2ClientConfiguration(ctx context.Context, clientID string, registrationAccessToken string) (OAuth2ClientConfiguration, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/oauth2/clients/%s", clientID), nil,
|
|
func(r *http.Request) {
|
|
r.Header.Set("Authorization", "Bearer "+registrationAccessToken)
|
|
})
|
|
if err != nil {
|
|
return OAuth2ClientConfiguration{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return OAuth2ClientConfiguration{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ClientConfiguration
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// PutOAuth2ClientConfiguration updates client configuration (RFC 7592)
|
|
func (c *Client) PutOAuth2ClientConfiguration(ctx context.Context, clientID string, registrationAccessToken string, req OAuth2ClientRegistrationRequest) (OAuth2ClientConfiguration, error) {
|
|
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/oauth2/clients/%s", clientID), req,
|
|
func(r *http.Request) {
|
|
r.Header.Set("Authorization", "Bearer "+registrationAccessToken)
|
|
})
|
|
if err != nil {
|
|
return OAuth2ClientConfiguration{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return OAuth2ClientConfiguration{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ClientConfiguration
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// DeleteOAuth2ClientConfiguration deletes client registration (RFC 7592)
|
|
func (c *Client) DeleteOAuth2ClientConfiguration(ctx context.Context, clientID string, registrationAccessToken string) error {
|
|
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/oauth2/clients/%s", clientID), nil,
|
|
func(r *http.Request) {
|
|
r.Header.Set("Authorization", "Bearer "+registrationAccessToken)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// OAuth2ClientConfiguration represents RFC 7592 Client Read Response.
|
|
type OAuth2ClientConfiguration struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientIDIssuedAt int64 `json:"client_id_issued_at"`
|
|
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
|
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
|
ClientName string `json:"client_name,omitempty"`
|
|
ClientURI string `json:"client_uri,omitempty"`
|
|
LogoURI string `json:"logo_uri,omitempty"`
|
|
TOSURI string `json:"tos_uri,omitempty"`
|
|
PolicyURI string `json:"policy_uri,omitempty"`
|
|
JWKSURI string `json:"jwks_uri,omitempty"`
|
|
JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"`
|
|
SoftwareID string `json:"software_id,omitempty"`
|
|
SoftwareVersion string `json:"software_version,omitempty"`
|
|
GrantTypes []OAuth2ProviderGrantType `json:"grant_types"`
|
|
ResponseTypes []OAuth2ProviderResponseType `json:"response_types"`
|
|
TokenEndpointAuthMethod OAuth2TokenEndpointAuthMethod `json:"token_endpoint_auth_method"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Contacts []string `json:"contacts,omitempty"`
|
|
RegistrationAccessToken string `json:"registration_access_token,omitempty"`
|
|
RegistrationClientURI string `json:"registration_client_uri"`
|
|
}
|