Files
ResolveSpec/pkg/security/oauth_refresh.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

214 lines
6.2 KiB
Go

package security
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
)
// newRefreshToken stores a new managed refresh token (the first of a family) and returns it.
func (s *OAuthServer) newRefreshToken(ctx context.Context, g *tokenGrant, now time.Time) (string, *oauthError) {
gs := s.grants()
if gs == nil {
return "", serverErr()
}
rt, err := randomOAuthToken()
if err != nil {
return "", serverErr()
}
family := g.FamilyID
if family == "" {
if family, err = randomOAuthToken(); err != nil {
return "", serverErr()
}
family = family[:30]
}
extra := map[string]any{}
if g.AuthTime != 0 {
extra["auth_time"] = g.AuthTime
}
if g.ACR != "" {
extra["acr"] = g.ACR
}
if len(g.AMR) > 0 {
extra["amr"] = g.AMR
}
if len(g.Resource) > 0 {
extra["aud"] = g.Resource
}
if g.DPoPJKT != "" {
extra["dpop_jkt"] = g.DPoPJKT
}
if len(g.Claims) > 0 {
extra["claims"] = g.Claims
}
if err := gs.SaveRefresh(ctx, lookup.RefreshToken{
TokenHash: hashToken(rt), FamilyID: family, ClientID: g.Client.ClientID, UserID: g.UserID,
SessionToken: g.SID, Scopes: g.Scopes, Extra: extra, ExpiresAt: now.Add(s.cfg.RefreshTokenTTL),
}); err != nil {
return "", serverErr()
}
return rt, nil
}
func (s *OAuthServer) handleRefreshGrant(r *http.Request) (map[string]any, *oauthError) {
refreshToken := r.PostFormValue("refresh_token")
if refreshToken == "" {
return nil, oerr("invalid_request", "refresh_token required", http.StatusBadRequest)
}
if s.cfg.ManagedRefreshTokens {
resp, e, handled := s.handleManagedRefresh(r, refreshToken)
if handled {
return resp, e
}
// Not one of ours: it may be a pass-through token issued before ManagedRefreshTokens was enabled.
}
return s.handleLegacyRefresh(r, refreshToken)
}
// handleManagedRefresh rotates a server-issued refresh token. handled is false when the token is
// not known to the store.
func (s *OAuthServer) handleManagedRefresh(r *http.Request, refreshToken string) (map[string]any, *oauthError, bool) {
ctx := r.Context()
gs := s.grants()
if gs == nil {
return nil, nil, false
}
hash := hashToken(refreshToken)
peek, err := gs.PeekRefresh(ctx, hash)
if errors.Is(err, lookup.ErrRefreshInvalid) || (err == nil && isAccessRecord(peek)) {
return nil, nil, false
}
if err != nil {
return nil, serverErr(), true
}
ac, e := s.requireClient(r)
if e != nil {
return nil, e, true
}
client := ac.Client
if peek.ClientID != client.ClientID {
return nil, oerr("invalid_grant", "refresh token was issued to another client", http.StatusBadRequest), true
}
if !grantAllowed(client, "refresh_token") && !oauthSliceContains(peek.Scopes, "offline_access") {
return nil, oerr("unauthorized_client", "client may not use refresh_token", http.StatusBadRequest), true
}
scopes := peek.Scopes
if req := strings.Fields(r.PostFormValue("scope")); len(req) > 0 {
if !scopesCovered(peek.Scopes, req) {
return nil, oerr("invalid_scope", "scope exceeds the original grant", http.StatusBadRequest), true
}
scopes = req
}
bound, _ := peek.Extra["dpop_jkt"].(string)
proof, e := s.verifyDPoP(r, "")
if e != nil {
return nil, e, true
}
jkt := bound
switch {
case bound != "" && (proof == nil || proof.JKT != bound):
return nil, oerr("invalid_dpop_proof", "the refresh token is bound to another DPoP key", http.StatusBadRequest), true
case proof != nil:
jkt = proof.JKT
case client.DPoPBoundAccessTokens:
return nil, oerr("invalid_dpop_proof", "this client must present a DPoP proof", http.StatusBadRequest), true
}
next, err := randomOAuthToken()
if err != nil {
return nil, serverErr(), true
}
extra := map[string]any{}
for k, v := range peek.Extra {
extra[k] = v
}
if jkt != "" && bound == "" && proof != nil && ac.Method == "none" {
extra["dpop_jkt"] = jkt // a public client's first DPoP refresh binds the token
}
old, err := gs.RotateRefresh(ctx, hash, lookup.RefreshToken{
TokenHash: hashToken(next), Scopes: peek.Scopes, Extra: extra, ExpiresAt: peek.ExpiresAt,
})
switch {
case errors.Is(err, lookup.ErrRefreshReused):
return nil, oerr("invalid_grant", "refresh token reuse detected; the session was ended", http.StatusBadRequest), true
case errors.Is(err, lookup.ErrRefreshInvalid):
return nil, oerr("invalid_grant", "refresh token expired or revoked", http.StatusBadRequest), true
case err != nil:
return nil, serverErr(), true
}
g := &tokenGrant{
Client: client, UserID: old.UserID, Scopes: scopes, SID: old.SessionToken, DPoPJKT: jkt,
NextRefresh: next, IDToken: true,
}
g.AuthTime = int64(numberOf(old.Extra["auth_time"]))
g.ACR, _ = old.Extra["acr"].(string)
g.AMR = stringsOf(old.Extra["amr"])
g.Resource = stringsOf(old.Extra["aud"])
if cl, ok := old.Extra["claims"].(map[string]any); ok {
g.Claims = cl
}
resp, e := s.mintTokens(ctx, g)
return resp, e, true
}
func numberOf(v any) float64 {
f, _ := v.(float64)
return f
}
func stringsOf(v any) []string {
list, _ := v.([]any)
var out []string
for _, it := range list {
if str, ok := it.(string); ok {
out = append(out, str)
}
}
return out
}
// handleLegacyRefresh passes the token through to the authenticators (the behaviour of earlier versions).
func (s *OAuthServer) handleLegacyRefresh(r *http.Request, refreshToken string) (map[string]any, *oauthError) {
providerName := r.PostFormValue("provider")
clientID := r.PostFormValue("client_id")
ac, e := s.authenticateClient(r)
if e != nil {
return nil, e
}
var client *OAuthServerClient
if ac != nil {
client = ac.Client
} else if c, ok := s.lookupOrFetchClient(r.Context(), clientID); ok {
client = c
}
if client == nil {
client = &OAuthServerClient{ClientID: clientID}
}
var resp *LoginResponse
var err error
if provider := s.providerByName(providerName); provider != nil {
resp, err = provider.auth.OAuth2RefreshToken(r.Context(), refreshToken, providerName)
} else if s.auth != nil {
resp, err = s.auth.RefreshToken(r.Context(), refreshToken)
} else {
return nil, oerr("invalid_grant", "no provider available for refresh", http.StatusBadRequest)
}
if err != nil {
return nil, oerr("invalid_grant", err.Error(), http.StatusBadRequest)
}
return s.mintTokens(r.Context(), &tokenGrant{
Client: client, LegacyAccess: resp.Token, LegacyRefresh: resp.RefreshToken,
})
}