feat(security): full OAuth 2.1 / OpenID Connect server and OIDC relying-party client

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.
This commit is contained in:
Hein
2026-10-01 14:42:12 +02:00
parent f54b707040
commit 640faeeeaf
51 changed files with 8682 additions and 1125 deletions
+85
View File
@@ -0,0 +1,85 @@
package security
import (
"context"
"net/http"
"net/url"
"time"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
)
const parURIPrefix = "urn:ietf:params:oauth:request_uri:"
// --------------------------------------------------------------------------
// RFC 9126 — Pushed authorization requests: POST /oauth/par
// --------------------------------------------------------------------------
func (s *OAuthServer) parHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
writeOAuthError(w, "invalid_request", "cannot parse form", http.StatusBadRequest)
return
}
ac, e := s.requireClient(r)
if e != nil {
e.write(w)
return
}
if r.PostForm.Get("request_uri") != "" {
writeOAuthError(w, "invalid_request", "request_uri must not be pushed", http.StatusBadRequest)
return
}
form := url.Values{}
for k, v := range r.PostForm {
switch k {
case "client_secret", "client_assertion", "client_assertion_type":
continue
}
form[k] = v
}
form.Set("client_id", ac.Client.ClientID)
if _, fail := s.parseAuthz(r.Context(), form, true); fail != nil {
writeOAuthError(w, fail.code, fail.desc, http.StatusBadRequest)
return
}
id, err := randomOAuthToken()
gs := s.grants()
if err != nil || gs == nil {
writeOAuthError(w, "server_error", "", http.StatusInternalServerError)
return
}
uri := parURIPrefix + id
if err := gs.SavePushedRequest(r.Context(), lookup.PushedRequest{
RequestURI: uri, ClientID: ac.Client.ClientID, Params: map[string]string{"q": form.Encode()},
ExpiresAt: time.Now().Add(s.cfg.PARTTL),
}); err != nil {
writeOAuthError(w, "server_error", "", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"request_uri": uri, "expires_in": int(s.cfg.PARTTL.Seconds())})
}
// resolvePushedRequest consumes the pushed request behind request_uri (single use).
func (s *OAuthServer) resolvePushedRequest(ctx context.Context, clientID, uri string) (url.Values, *oauthError) {
gs := s.grants()
if gs == nil || (!s.cfg.EnablePAR && !s.cfg.RequirePAR) {
return nil, oerr("request_uri_not_supported", "pushed authorization requests are not enabled", http.StatusBadRequest)
}
pr, err := gs.ConsumePushedRequest(ctx, uri)
if err != nil {
return nil, oerr("invalid_request_uri", "request_uri is unknown, expired or already used", http.StatusBadRequest)
}
if clientID != "" && clientID != pr.ClientID {
return nil, oerr("invalid_request", "client_id does not match the pushed request", http.StatusBadRequest)
}
q, err := url.ParseQuery(pr.Params["q"])
if err != nil {
return nil, oerr("invalid_request_uri", "stored request is unreadable", http.StatusBadRequest)
}
q.Set("client_id", pr.ClientID)
return q, nil
}