mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
Adds an OAuth2 client type (public vs confidential, RFC 7591 §2) derived from the requested auth method instead of hardcoded confidential. The type is stored and guarded here, but no endpoint enforces on it yet; public behavior at the token endpoint follows in the next PR in the stack. - Client type is derived once and reused by both registration and redirect URI validation, so they can't disagree - IsPublic() fails closed: an unrecognized or missing value reads as confidential - RFC 7592 update (PUT) now rejects moving a client between public and confidential (400) instead of silently flipping it when the auth method is omitted - Discovery still doesn't advertise "none"; follows once the token endpoint honors it ### Behavior by client shape `client_type` is derived from `token_endpoint_auth_method` at POST and pinned at PUT. RFC 7592 GET/PUT authenticate with the registration access token, not the client secret, so neither endpoint reads a secret. | Registered with | Stored `client_type` / method | GET reports | PUT that flips the method | |------------------------------------|---------------------------------------|-----------------------|-----------------------------------------| | omitted, or `client_secret_basic` | `confidential` / `client_secret_basic` | `client_secret_basic` | `none` → 400 `invalid_client_metadata` | | `none` (new) | `public` / `none` | `none` | `client_secret_*` → 400 `invalid_client_metadata` | | `none` (before this PR) | `confidential` / `none` | `none` | either → 200, type stays `confidential` | - PUT still replaces every other RFC 7591 field. `client_type` is the only pinned one; the method may move within a type (`client_secret_basic` ↔ `client_secret_post`). - Row 3 is the only shape where the two columns disagree. The guard fires only on a method change that crosses the type line, so those clients keep managing themselves instead of being locked out of their own configuration endpoint. - The token endpoint does not consult `client_type` yet, so every client still authenticates with a secret and registration still issues one. Split out of #27873, second in the stack (on top of #28041). Refs https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
729 lines
29 KiB
Go
729 lines
29 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"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, ReadBodyAsJSON(res, &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, ReadBodyAsJSON(res, &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, ReadBodyAsJSON(res, &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, ReadBodyAsJSON(res, &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, ReadBodyAsJSON(res, &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, ReadBodyAsJSON(res, &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
|
|
}
|
|
|
|
// OAuth2ProviderSettings controls deployment-wide OAuth2 provider behavior.
|
|
//
|
|
// DynamicClientRegistrationEnabled is a pointer so a PUT can omit it to leave
|
|
// the current value unchanged, rather than a decoded zero value silently
|
|
// resetting it to false. This matters once a second field lands in this
|
|
// struct (e.g. a future initial-access-token requirement): a client built
|
|
// against an older, single-field version of this struct would otherwise
|
|
// always encode the newer field's zero value, silently clearing it on every
|
|
// unrelated update. GET always returns a non-nil value.
|
|
type OAuth2ProviderSettings struct {
|
|
DynamicClientRegistrationEnabled *bool `json:"dynamic_client_registration_enabled,omitempty"`
|
|
}
|
|
|
|
// OAuth2ProviderSettings retrieves the deployment-wide OAuth2 provider settings.
|
|
func (c *Client) OAuth2ProviderSettings(ctx context.Context) (OAuth2ProviderSettings, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, "/api/v2/oauth2-provider/settings", nil)
|
|
if err != nil {
|
|
return OAuth2ProviderSettings{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return OAuth2ProviderSettings{}, ReadBodyAsError(res)
|
|
}
|
|
var settings OAuth2ProviderSettings
|
|
return settings, ReadBodyAsJSON(res, &settings)
|
|
}
|
|
|
|
// PutOAuth2ProviderSettings modifies the deployment-wide OAuth2 provider settings.
|
|
func (c *Client) PutOAuth2ProviderSettings(ctx context.Context, settings OAuth2ProviderSettings) (OAuth2ProviderSettings, error) {
|
|
res, err := c.Request(ctx, http.MethodPut, "/api/v2/oauth2-provider/settings", settings)
|
|
if err != nil {
|
|
return OAuth2ProviderSettings{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return OAuth2ProviderSettings{}, ReadBodyAsError(res)
|
|
}
|
|
var updated OAuth2ProviderSettings
|
|
return updated, ReadBodyAsJSON(res, &updated)
|
|
}
|
|
|
|
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"
|
|
)
|
|
|
|
// AllOAuth2TokenEndpointAuthMethods returns every accepted token endpoint auth
|
|
// method. Valid() is defined in terms of it, so what registration accepts
|
|
// cannot drift from what this function reports.
|
|
//
|
|
// Discovery metadata does not yet derive from it:
|
|
// coderd/oauth2provider/metadata.go's TokenEndpointAuthMethodsSupported is
|
|
// hardcoded to {client_secret_basic, client_secret_post} and does not
|
|
// advertise "none", even though "none" is accepted here. A follow-up PR
|
|
// wires the token endpoint to honor "none"; only once that lands should
|
|
// discovery advertise it too.
|
|
func AllOAuth2TokenEndpointAuthMethods() []OAuth2TokenEndpointAuthMethod {
|
|
return []OAuth2TokenEndpointAuthMethod{
|
|
OAuth2TokenEndpointAuthMethodClientSecretBasic,
|
|
OAuth2TokenEndpointAuthMethodClientSecretPost,
|
|
OAuth2TokenEndpointAuthMethodNone,
|
|
}
|
|
}
|
|
|
|
func (m OAuth2TokenEndpointAuthMethod) Valid() bool {
|
|
return slices.Contains(AllOAuth2TokenEndpointAuthMethods(), m)
|
|
}
|
|
|
|
// OAuth2ClientType is how a client authenticates at the token endpoint
|
|
// (RFC 7591 §2, OAuth 2.1 §2.1). A confidential client authenticates with a
|
|
// secret; a public client authenticates with PKCE alone. It is derived from
|
|
// the requested token_endpoint_auth_method and stored on the app. A
|
|
// follow-up PR wires the token endpoint to read it when deciding whether to
|
|
// require a client secret.
|
|
type OAuth2ClientType string
|
|
|
|
const (
|
|
OAuth2ClientTypeConfidential OAuth2ClientType = "confidential"
|
|
OAuth2ClientTypePublic OAuth2ClientType = "public"
|
|
)
|
|
|
|
func (t OAuth2ClientType) Valid() bool {
|
|
switch t {
|
|
case OAuth2ClientTypeConfidential, OAuth2ClientTypePublic:
|
|
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, based
|
|
// on the requested token_endpoint_auth_method (RFC 7591 §2, OAuth 2.1 §2.1).
|
|
//
|
|
// Only "none" reads as public; every other value, including an omitted one,
|
|
// reads as confidential, so this is safe to call before ApplyDefaults(). A
|
|
// caller that also compares the request's auth method against a stored one must
|
|
// apply defaults first, or an omitted field compares as "" and looks like a
|
|
// change the client did not request.
|
|
func (req *OAuth2ClientRegistrationRequest) DetermineClientType() OAuth2ClientType {
|
|
if req.TokenEndpointAuthMethod == OAuth2TokenEndpointAuthMethodNone {
|
|
return OAuth2ClientTypePublic
|
|
}
|
|
return OAuth2ClientTypeConfidential
|
|
}
|
|
|
|
// 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, ReadBodyAsJSON(res, &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, ReadBodyAsJSON(res, &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, ReadBodyAsJSON(res, &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"`
|
|
}
|