Files
ResolveSpec/pkg/security/oauth_session.go
T
Hein 640faeeeaf 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.
2026-10-01 14:42:12 +02:00

155 lines
4.5 KiB
Go

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)
}