mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
Authorization server: consent and scopes, OIDC (nonce, auth_time, acr, sid, at_hash, signed userinfo, RP-initiated and back-channel logout), managed refresh tokens with rotation and reuse detection, RFC 9068 JWT access tokens, DPoP, PAR, device grant, token exchange, private_key_jwt, RFC 7591/7592 registration, RFC 9207 iss, signing keyring with rotation. State is DB-backed through a new lookup.OAuthGrantStore (procedure and direct backends, four dialect DDLs, conformance cases). Client side: WithOIDC discovery, PKCE, nonce, id_token validation, OAuth2LogoutURL. PeekRefresh now returns already rotated tokens so RotateRefresh can detect reuse. Docs: OAUTH2_SERVER.md, oauth2_full_example.go, breaking_changes.md step 8.
353 lines
12 KiB
Go
353 lines
12 KiB
Go
package security
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var supportedGrantTypes = []string{"authorization_code", "refresh_token", "client_credentials", grantDeviceCode, grantTokenExchange}
|
|
|
|
// validateRedirectURI checks a redirect (or post-logout) URI at registration: absolute, no
|
|
// fragment, https, http only for loopback, or a private-use scheme (RFC 8252).
|
|
func validateRedirectURI(raw string) bool {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || !u.IsAbs() || u.Fragment != "" || strings.Contains(raw, "#") {
|
|
return false
|
|
}
|
|
switch strings.ToLower(u.Scheme) {
|
|
case "https":
|
|
return u.Host != ""
|
|
case "http":
|
|
return isLoopbackHost(u.Hostname())
|
|
case "javascript", "data", "file", "vbscript", "about", "blob", "ftp":
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *OAuthServer) validateClientMetadata(c *OAuthServerClient) *oauthError {
|
|
meta := func(desc string) *oauthError { return oerr("invalid_client_metadata", desc, http.StatusBadRequest) }
|
|
|
|
for _, g := range c.GrantTypes {
|
|
if !oauthSliceContains(supportedGrantTypes, g) {
|
|
return meta("unsupported grant type " + g)
|
|
}
|
|
}
|
|
if oauthSliceContains(c.GrantTypes, grantDeviceCode) && !s.cfg.EnableDeviceFlow {
|
|
return meta("the device grant is not enabled on this server")
|
|
}
|
|
if oauthSliceContains(c.GrantTypes, grantTokenExchange) && !s.cfg.EnableTokenExchange {
|
|
return meta("token exchange is not enabled on this server")
|
|
}
|
|
for _, rt := range c.ResponseTypes {
|
|
if rt != "code" {
|
|
return meta("unsupported response type " + rt)
|
|
}
|
|
}
|
|
needsRedirect := false
|
|
for _, g := range c.GrantTypes {
|
|
if g == "authorization_code" {
|
|
needsRedirect = true
|
|
}
|
|
}
|
|
if needsRedirect && len(c.RedirectURIs) == 0 {
|
|
return oerr("invalid_redirect_uri", "redirect_uris required", http.StatusBadRequest)
|
|
}
|
|
for _, u := range c.RedirectURIs {
|
|
if !validateRedirectURI(u) {
|
|
return oerr("invalid_redirect_uri", "invalid redirect_uri "+u, http.StatusBadRequest)
|
|
}
|
|
}
|
|
for _, u := range c.PostLogoutRedirectURIs {
|
|
if !validateRedirectURI(u) {
|
|
return meta("invalid post_logout_redirect_uri " + u)
|
|
}
|
|
}
|
|
if !oauthSliceContains(s.authMethodsSupported(), c.TokenEndpointAuthMethod) {
|
|
return meta("unsupported token_endpoint_auth_method")
|
|
}
|
|
if c.TokenEndpointAuthMethod == "private_key_jwt" {
|
|
if (len(c.JWKS) == 0) == (c.JWKSURI == "") {
|
|
return meta("private_key_jwt needs exactly one of jwks and jwks_uri")
|
|
}
|
|
if len(c.JWKS) > 0 {
|
|
if _, err := parseJWKS(c.JWKS); err != nil {
|
|
return meta("jwks: " + err.Error())
|
|
}
|
|
}
|
|
}
|
|
if c.JWKSURI != "" && !validWebURL(c.JWKSURI, s.cfg.AllowPrivateNetworkFetch) {
|
|
return meta("jwks_uri must be an https URL")
|
|
}
|
|
if a := c.TokenEndpointAuthSigningAlg; a != "" && !oauthSliceContains([]string{"RS256", "PS256", "ES256", "ES384"}, a) {
|
|
return meta("unsupported token_endpoint_auth_signing_alg")
|
|
}
|
|
if a := c.IDTokenSignedResponseAlg; a != "" && !oauthSliceContains(s.keys.algs(), a) {
|
|
return meta("id_token_signed_response_alg is not offered by this server")
|
|
}
|
|
if a := c.UserinfoSignedResponseAlg; a != "" && a != "none" && !oauthSliceContains(s.keys.algs(), a) {
|
|
return meta("userinfo_signed_response_alg is not offered by this server")
|
|
}
|
|
if c.BackchannelLogoutURI != "" && !validWebURL(c.BackchannelLogoutURI, s.cfg.AllowPrivateNetworkFetch) {
|
|
return meta("backchannel_logout_uri must be an https URL")
|
|
}
|
|
for _, u := range []string{c.ClientURI, c.LogoURI} {
|
|
if u != "" && !validWebURL(u, true) {
|
|
return meta("client_uri and logo_uri must be http(s) URLs")
|
|
}
|
|
}
|
|
if len(c.ClientName) > 200 {
|
|
return meta("client_name too long")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validWebURL accepts https URLs, and http URLs when loose is set or the host is loopback.
|
|
func validWebURL(raw string, loose bool) bool {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" || u.Fragment != "" {
|
|
return false
|
|
}
|
|
return u.Scheme == "https" || (u.Scheme == "http" && (loose || isLoopbackHost(u.Hostname())))
|
|
}
|
|
|
|
// clientView is the RFC 7591 representation of a client: no secret hash, no registration token hash.
|
|
func clientView(c *OAuthServerClient) map[string]any {
|
|
v := *c
|
|
v.ClientSecretHash, v.RegistrationAccessTokenHash = "", ""
|
|
raw, _ := json.Marshal(v)
|
|
m := map[string]any{}
|
|
_ = json.Unmarshal(raw, &m)
|
|
m["client_id"] = c.ClientID
|
|
m["token_endpoint_auth_method"] = c.TokenEndpointAuthMethod
|
|
m["grant_types"] = c.GrantTypes
|
|
m["redirect_uris"] = c.RedirectURIs
|
|
if len(c.AllowedScopes) > 0 {
|
|
m["scope"] = strings.Join(c.AllowedScopes, " ")
|
|
}
|
|
return m
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// RFC 7591 — Dynamic client registration
|
|
// --------------------------------------------------------------------------
|
|
|
|
func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if s.cfg.InitialAccessToken != "" {
|
|
tok, _ := bearerFromHeader(r)
|
|
if subtle.ConstantTimeCompare([]byte(tok), []byte(s.cfg.InitialAccessToken)) != 1 {
|
|
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
|
|
writeOAuthError(w, "invalid_token", "an initial access token is required to register clients", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
c, e := s.decodeClientMetadata(r)
|
|
if e != nil {
|
|
e.write(w)
|
|
return
|
|
}
|
|
|
|
// client_credentials is a machine-to-machine grant and requires a confidential
|
|
// client (RFC 6749 §4.4), so it always forces secret issuance regardless of the
|
|
// requested auth method.
|
|
if oauthSliceContains(c.GrantTypes, "client_credentials") && c.TokenEndpointAuthMethod == "none" {
|
|
c.TokenEndpointAuthMethod = "client_secret_basic"
|
|
}
|
|
if e := s.validateClientMetadata(c); e != nil {
|
|
e.write(w)
|
|
return
|
|
}
|
|
|
|
id, err := randomOAuthToken()
|
|
if err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
c.ClientID = id
|
|
c.ClientIDIssuedAt = time.Now().Unix()
|
|
var secret string
|
|
if strings.HasPrefix(c.TokenEndpointAuthMethod, "client_secret_") {
|
|
if secret, err = randomOAuthToken(); err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
c.ClientSecretHash = hashClientSecret(secret)
|
|
}
|
|
regToken, err := randomOAuthToken()
|
|
if err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
c.RegistrationAccessTokenHash = hashToken(regToken)
|
|
|
|
if err := s.saveClient(r.Context(), c, false); err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// RFC 7591 registration response: the plaintext secret and registration token are returned
|
|
// exactly once here and never persisted or served again — only their hashes are stored.
|
|
resp := clientView(c)
|
|
if secret != "" {
|
|
resp["client_secret"] = secret
|
|
resp["client_secret_expires_at"] = 0
|
|
}
|
|
resp["registration_access_token"] = regToken
|
|
resp["registration_client_uri"] = s.endpoint("/oauth/register/" + c.ClientID)
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
// decodeClientMetadata reads the registration document and strips every field the caller must not
|
|
// control.
|
|
func (s *OAuthServer) decodeClientMetadata(r *http.Request) (*OAuthServerClient, *oauthError) {
|
|
var req struct {
|
|
OAuthServerClient
|
|
Scope string `json:"scope"`
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<10)).Decode(&req); err != nil {
|
|
return nil, oerr("invalid_client_metadata", "malformed JSON", http.StatusBadRequest)
|
|
}
|
|
c := req.OAuthServerClient
|
|
c.ClientID, c.ClientSecretHash, c.RegistrationAccessTokenHash = "", "", ""
|
|
c.FirstParty, c.ClientSecretExpiresAt, c.ClientIDIssuedAt = false, 0, 0
|
|
if len(c.GrantTypes) == 0 {
|
|
c.GrantTypes = []string{"authorization_code", "refresh_token"}
|
|
}
|
|
if len(c.ResponseTypes) == 0 {
|
|
c.ResponseTypes = []string{"code"}
|
|
}
|
|
if len(c.AllowedScopes) == 0 {
|
|
if f := strings.Fields(req.Scope); len(f) > 0 {
|
|
c.AllowedScopes = f
|
|
} else {
|
|
c.AllowedScopes = s.cfg.DefaultScopes
|
|
}
|
|
}
|
|
if c.TokenEndpointAuthMethod == "" {
|
|
c.TokenEndpointAuthMethod = "none"
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
func bearerFromHeader(r *http.Request) (string, bool) {
|
|
h := r.Header.Get("Authorization")
|
|
if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") {
|
|
return strings.TrimSpace(h[7:]), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// registrationClient authenticates a RFC 7592 management request and returns the client.
|
|
func (s *OAuthServer) registrationClient(w http.ResponseWriter, r *http.Request) *OAuthServerClient {
|
|
deny := func() *OAuthServerClient {
|
|
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
|
|
writeOAuthError(w, "invalid_token", "invalid registration access token", http.StatusUnauthorized)
|
|
return nil
|
|
}
|
|
c, ok := s.lookupOrFetchClient(r.Context(), r.PathValue("id"))
|
|
tok, _ := bearerFromHeader(r)
|
|
if !ok || c.RegistrationAccessTokenHash == "" || tok == "" ||
|
|
subtle.ConstantTimeCompare([]byte(hashToken(tok)), []byte(c.RegistrationAccessTokenHash)) != 1 {
|
|
return deny()
|
|
}
|
|
return c
|
|
}
|
|
|
|
// registrationManageHandler serves GET, PUT and DELETE /oauth/register/{id} (RFC 7592).
|
|
func (s *OAuthServer) registrationManageHandler(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodPut, http.MethodDelete:
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
cur := s.registrationClient(w, r)
|
|
if cur == nil {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
resp := clientView(cur)
|
|
resp["registration_client_uri"] = s.endpoint("/oauth/register/" + cur.ClientID)
|
|
writeJSON(w, http.StatusOK, resp)
|
|
case http.MethodDelete:
|
|
if err := s.removeClient(r.Context(), cur.ClientID); err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case http.MethodPut:
|
|
next, e := s.decodeClientMetadata(r)
|
|
if e != nil {
|
|
e.write(w)
|
|
return
|
|
}
|
|
if oauthSliceContains(next.GrantTypes, "client_credentials") && next.TokenEndpointAuthMethod == "none" {
|
|
next.TokenEndpointAuthMethod = "client_secret_basic"
|
|
}
|
|
if e := s.validateClientMetadata(next); e != nil {
|
|
e.write(w)
|
|
return
|
|
}
|
|
wantsSecret := strings.HasPrefix(next.TokenEndpointAuthMethod, "client_secret_")
|
|
if wantsSecret && cur.ClientSecretHash == "" {
|
|
oerr("invalid_client_metadata", "the client has no secret; register a new client instead", http.StatusBadRequest).write(w)
|
|
return
|
|
}
|
|
next.ClientID = cur.ClientID
|
|
next.ClientIDIssuedAt = cur.ClientIDIssuedAt
|
|
next.RegistrationAccessTokenHash = cur.RegistrationAccessTokenHash
|
|
next.FirstParty = cur.FirstParty // only trusted code may change this
|
|
next.ClientSecretExpiresAt = cur.ClientSecretExpiresAt
|
|
if wantsSecret {
|
|
next.ClientSecretHash = cur.ClientSecretHash
|
|
}
|
|
if err := s.saveClient(r.Context(), next, true); err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
resp := clientView(next)
|
|
resp["registration_client_uri"] = s.endpoint("/oauth/register/" + next.ClientID)
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
}
|
|
|
|
// registrationRotateHandler issues a new client secret: POST /oauth/register/{id}/rotate-secret.
|
|
func (s *OAuthServer) registrationRotateHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
cur := s.registrationClient(w, r)
|
|
if cur == nil {
|
|
return
|
|
}
|
|
if cur.ClientSecretHash == "" {
|
|
writeOAuthError(w, "invalid_request", "this client has no secret", http.StatusBadRequest)
|
|
return
|
|
}
|
|
secret, err := randomOAuthToken()
|
|
if err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
next := *cur
|
|
next.ClientSecretHash = hashClientSecret(secret)
|
|
if err := s.saveClient(r.Context(), &next, true); err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
resp := clientView(&next)
|
|
resp["client_secret"] = secret
|
|
resp["client_secret_expires_at"] = 0
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|