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
+204
View File
@@ -0,0 +1,204 @@
package security
import (
"bytes"
"context"
"net/http"
"net/url"
"strconv"
"time"
"github.com/golang-jwt/jwt/v5"
)
const backchannelEvent = "http://schemas.openid.net/event/backchannel-logout"
// hintSubject returns the subject and client of an id_token_hint issued by this server ("" when
// the hint is not valid). The hint may be expired.
func (s *OAuthServer) hintSubject(hint string) (sub, clientID string) {
claims, err := s.parseOwnJWTOpts(hint, "", true)
if err != nil {
return "", ""
}
sub, _ = claims["sub"].(string)
if auds := audOf(claims["aud"]); len(auds) > 0 {
clientID = auds[0]
}
return sub, clientID
}
type logoutState struct {
ClientID string `json:"c,omitempty"`
RedirectURI string `json:"r,omitempty"`
State string `json:"s,omitempty"`
Sid string `json:"i,omitempty"`
Sub string `json:"u,omitempty"`
}
// --------------------------------------------------------------------------
// OIDC RP-Initiated Logout — GET/POST /oauth/logout
// --------------------------------------------------------------------------
func (s *OAuthServer) logoutHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
// Confirmation answer to the page rendered below.
if blob := r.PostFormValue("lreq"); blob != "" {
var st logoutState
if err := s.open("logout", blob, &st); err != nil {
s.renderMessage(w, http.StatusBadRequest, "Request expired", "This sign-out request has expired.", true)
return
}
if r.PostFormValue("confirm") != "yes" {
s.finishLogout(w, r, &st, false)
return
}
s.performLogout(w, r, &st)
return
}
st := logoutState{State: r.FormValue("state")}
hint := r.FormValue("id_token_hint")
if hint != "" {
claims, err := s.parseOwnJWTOpts(hint, "", true)
if err != nil {
s.renderMessage(w, http.StatusBadRequest, "Invalid request", "The id_token_hint is not valid.", true)
return
}
st.Sub, _ = claims["sub"].(string)
st.Sid, _ = claims["sid"].(string)
if auds := audOf(claims["aud"]); len(auds) > 0 {
st.ClientID = auds[0]
}
if p := r.FormValue("client_id"); p != "" && p != st.ClientID {
s.renderMessage(w, http.StatusBadRequest, "Invalid request", "client_id does not match the id_token_hint.", true)
return
}
} else {
st.ClientID = r.FormValue("client_id")
}
if uri := r.FormValue("post_logout_redirect_uri"); uri != "" {
client, ok := s.lookupOrFetchClient(r.Context(), st.ClientID)
if !ok || !oauthSliceContains(client.PostLogoutRedirectURIs, uri) {
s.renderMessage(w, http.StatusBadRequest, "Invalid request", "post_logout_redirect_uri is not registered for this client.", true)
return
}
st.RedirectURI = uri
}
if hint != "" {
s.performLogout(w, r, &st)
return
}
blob, err := s.seal("logout", st, 15*time.Minute)
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
s.renderHTML(w, http.StatusOK, nil, "logout", oauthLogoutPage{
Title: "Sign out", Action: "logout", Hidden: map[string]string{"lreq": blob},
})
}
// performLogout ends the SSO session, revokes the refresh tokens bound to it and notifies the
// clients that hold tokens for it.
func (s *OAuthServer) performLogout(w http.ResponseWriter, r *http.Request, st *logoutState) {
ctx := r.Context()
sids := map[string]struct{}{}
if st.Sid != "" {
sids[st.Sid] = struct{}{}
}
clients := map[string]struct{}{}
if st.ClientID != "" {
clients[st.ClientID] = struct{}{}
}
sub, sid := st.Sub, st.Sid
if c, err := r.Cookie(s.cfg.SSOCookie.Name); err == nil {
var sess ssoSession
if s.open("sso", c.Value, &sess) == nil && (st.Sub == "" || st.Sub == strconv.Itoa(sess.UserID)) {
sids[sess.SID] = struct{}{}
for _, cl := range sess.Clients {
clients[cl] = struct{}{}
}
sub, sid = strconv.Itoa(sess.UserID), sess.SID
if a := s.anyAuth(); a != nil {
_ = a.Logout(ctx, LogoutRequest{Token: sess.Token})
}
}
}
if gs := s.grants(); gs != nil {
for id := range sids {
_ = gs.RevokeRefreshBySession(ctx, id)
}
}
s.clearSSO(w)
for id := range clients {
if client, ok := s.lookupOrFetchClient(ctx, id); ok && client.BackchannelLogoutURI != "" {
s.sendBackchannelLogout(client, sub, sid)
}
}
s.finishLogout(w, r, st, true)
}
func (s *OAuthServer) finishLogout(w http.ResponseWriter, r *http.Request, st *logoutState, done bool) {
if st.RedirectURI != "" {
u, err := url.Parse(st.RedirectURI)
if err == nil {
if st.State != "" {
q := u.Query()
q.Set("state", st.State)
u.RawQuery = q.Encode()
}
http.Redirect(w, r, u.String(), http.StatusFound)
return
}
}
if done {
s.renderMessage(w, http.StatusOK, "Signed out", "You have been signed out.", false)
return
}
s.renderMessage(w, http.StatusOK, "Still signed in", "You are still signed in.", false)
}
// sendBackchannelLogout posts a logout token to the client (OIDC Back-Channel Logout 1.0), best effort.
func (s *OAuthServer) sendBackchannelLogout(client *OAuthServerClient, sub, sid string) {
jti, err := randomOAuthToken()
if err != nil {
return
}
claims := jwt.MapClaims{
"iss": s.cfg.Issuer, "aud": client.ClientID, "iat": time.Now().Unix(), "jti": jti, "sub": sub,
"events": map[string]any{backchannelEvent: map[string]any{}},
}
if sid != "" {
claims["sid"] = sid
}
token, err := s.keys.forAlg(client.IDTokenSignedResponseAlg).sign(claims, "logout+jwt")
if err != nil {
return
}
target := client.BackchannelLogoutURI
s.bcWG.Add(1)
go func() {
defer s.bcWG.Done()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
body := url.Values{"logout_token": {token}}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewBufferString(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.jwks.client.Do(req)
if err == nil {
_ = resp.Body.Close()
}
}()
}