mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-03 20:11:57 +00:00
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:
@@ -0,0 +1,154 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/hmac"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// deriveOAuthSecret derives the HMAC key for cookies and form state from the default signing key.
|
||||
func deriveOAuthSecret(kr *oauthKeyring) []byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("resolvespec-oauth-state-v1"))
|
||||
switch k := kr.keys[0].signer.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
h.Write(k.D.Bytes())
|
||||
case *ecdsa.PrivateKey:
|
||||
h.Write(k.D.Bytes())
|
||||
}
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
type sealed struct {
|
||||
Exp int64 `json:"e"`
|
||||
Kind string `json:"k"`
|
||||
V json.RawMessage `json:"v"`
|
||||
}
|
||||
|
||||
func (s *OAuthServer) mac(data []byte) []byte {
|
||||
m := hmac.New(sha256.New, s.secret)
|
||||
m.Write(data)
|
||||
return m.Sum(nil)
|
||||
}
|
||||
|
||||
// seal returns v as a tamper-proof string valid for ttl. kind separates the uses (a sealed login
|
||||
// form cannot be replayed as a cookie).
|
||||
func (s *OAuthServer) seal(kind string, v any, ttl time.Duration) (string, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := json.Marshal(sealed{Exp: time.Now().Add(ttl).Unix(), Kind: kind, V: raw})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(body) + "." + base64.RawURLEncoding.EncodeToString(s.mac(body)), nil
|
||||
}
|
||||
|
||||
// open verifies and decodes a value produced by seal.
|
||||
func (s *OAuthServer) open(kind, token string, v any) error {
|
||||
i := strings.IndexByte(token, '.')
|
||||
if i < 0 {
|
||||
return fmt.Errorf("malformed")
|
||||
}
|
||||
body, err := base64.RawURLEncoding.DecodeString(token[:i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("malformed")
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(token[i+1:])
|
||||
if err != nil || !hmac.Equal(sig, s.mac(body)) {
|
||||
return fmt.Errorf("bad signature")
|
||||
}
|
||||
var env sealed
|
||||
if err := json.Unmarshal(body, &env); err != nil || env.Kind != kind {
|
||||
return fmt.Errorf("malformed")
|
||||
}
|
||||
if time.Now().Unix() > env.Exp {
|
||||
return fmt.Errorf("expired")
|
||||
}
|
||||
return json.Unmarshal(env.V, v)
|
||||
}
|
||||
|
||||
// ssoSession is the content of the SSO cookie. The login session token never reaches a client.
|
||||
type ssoSession struct {
|
||||
Token string `json:"t"` // login session token (user_sessions row)
|
||||
UserID int `json:"u"`
|
||||
AuthTime int64 `json:"a"`
|
||||
SID string `json:"s"` // OIDC session id
|
||||
Provider string `json:"p,omitempty"` // external provider that authenticated the user
|
||||
Clients []string `json:"c,omitempty"` // clients that received tokens (back-channel logout)
|
||||
}
|
||||
|
||||
func (s *OAuthServer) cookieSecure() bool {
|
||||
return !s.cfg.SSOCookie.Insecure && s.issuerURL.Scheme == "https"
|
||||
}
|
||||
|
||||
// ssoFromRequest returns the live SSO session of the request, or nil.
|
||||
func (s *OAuthServer) ssoFromRequest(r *http.Request) *ssoSession {
|
||||
if s.cfg.SSOCookie.Disable {
|
||||
return nil
|
||||
}
|
||||
c, err := r.Cookie(s.cfg.SSOCookie.Name)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var sess ssoSession
|
||||
if err := s.open("sso", c.Value, &sess); err != nil {
|
||||
return nil
|
||||
}
|
||||
a := s.anyAuth()
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
if info, err := a.OAuthIntrospectToken(r.Context(), sess.Token); err != nil || !info.Active {
|
||||
return nil
|
||||
}
|
||||
return &sess
|
||||
}
|
||||
|
||||
func (s *OAuthServer) setSSO(w http.ResponseWriter, sess *ssoSession) {
|
||||
if s.cfg.SSOCookie.Disable {
|
||||
return
|
||||
}
|
||||
v, err := s.seal("sso", sess, s.cfg.SSOCookie.TTL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{ //nolint:gosec // Secure follows the issuer scheme (cookieSecure)
|
||||
Name: s.cfg.SSOCookie.Name, Value: v, Path: s.cfg.SSOCookie.Path,
|
||||
MaxAge: int(s.cfg.SSOCookie.TTL.Seconds()), HttpOnly: true, Secure: s.cookieSecure(),
|
||||
SameSite: s.cfg.SSOCookie.SameSite,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *OAuthServer) clearSSO(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{ //nolint:gosec // Secure follows the issuer scheme (cookieSecure)
|
||||
Name: s.cfg.SSOCookie.Name, Value: "", Path: s.cfg.SSOCookie.Path, MaxAge: -1,
|
||||
HttpOnly: true, Secure: s.cookieSecure(), SameSite: s.cfg.SSOCookie.SameSite,
|
||||
})
|
||||
}
|
||||
|
||||
// newSSO builds the session for a freshly authenticated user.
|
||||
func (s *OAuthServer) newSSO(token string, userID int, provider string) (*ssoSession, error) {
|
||||
sid, err := randomOAuthToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ssoSession{Token: token, UserID: userID, AuthTime: time.Now().Unix(), SID: sid[:22], Provider: provider}, nil
|
||||
}
|
||||
|
||||
// noteClient records that clientID received tokens in this SSO session.
|
||||
func (s *OAuthServer) noteClient(w http.ResponseWriter, sess *ssoSession, clientID string) {
|
||||
if sess == nil || oauthSliceContains(sess.Clients, clientID) || len(sess.Clients) >= 12 {
|
||||
return
|
||||
}
|
||||
sess.Clients = append(sess.Clients, clientID)
|
||||
s.setSSO(w, sess)
|
||||
}
|
||||
Reference in New Issue
Block a user