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

125 lines
3.6 KiB
Go

package security
import (
"errors"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
)
// introspectionCaller authenticates the caller of the revocation / introspection endpoints.
func (s *OAuthServer) introspectionCaller(r *http.Request) (*authedClient, *oauthError) {
ac, e := s.authenticateClient(r)
if e != nil {
return nil, e
}
if (ac == nil || ac.Method == "none") && !s.cfg.AllowAnonymousIntrospection {
return nil, invalidClient("client authentication required", true)
}
return ac, nil
}
// --------------------------------------------------------------------------
// RFC 7662 — Token introspection
// --------------------------------------------------------------------------
func (s *OAuthServer) introspectHandler(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
}
if _, e := s.introspectionCaller(r); e != nil {
e.write(w)
return
}
token := r.PostFormValue("token")
inactive := map[string]any{"active": false}
if token == "" {
writeJSON(w, http.StatusOK, inactive)
return
}
if r.PostFormValue("token_type_hint") != "access_token" {
if gs := s.grants(); gs != nil {
if rt, err := gs.PeekRefresh(r.Context(), hashToken(token)); err == nil && !isAccessRecord(rt) {
writeJSON(w, http.StatusOK, map[string]any{
"active": true, "sub": itoa(rt.UserID), "client_id": rt.ClientID, "scope": joinScopes(rt.Scopes),
"exp": rt.ExpiresAt.Unix(), "iss": s.cfg.Issuer, "token_type": "refresh_token",
})
return
}
}
}
ti := s.resolveAccessToken(r.Context(), token)
if ti == nil {
writeJSON(w, http.StatusOK, inactive)
return
}
writeJSON(w, http.StatusOK, s.introspectionInfo(ti))
}
// --------------------------------------------------------------------------
// RFC 7009 — Token revocation
// --------------------------------------------------------------------------
func (s *OAuthServer) revokeHandler(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.introspectionCaller(r)
if e != nil {
e.write(w)
return
}
token := r.PostFormValue("token")
if token == "" {
w.WriteHeader(http.StatusOK)
return
}
owns := func(clientID string) bool { return ac == nil || clientID == "" || ac.Client.ClientID == clientID }
ctx := r.Context()
if gs := s.grants(); gs != nil {
if rt, err := gs.PeekRefresh(ctx, hashToken(token)); err == nil && !isAccessRecord(rt) {
if owns(rt.ClientID) {
_ = gs.RevokeRefreshFamily(ctx, rt.FamilyID)
}
w.WriteHeader(http.StatusOK)
return
} else if err != nil && !errors.Is(err, lookup.ErrRefreshInvalid) {
w.WriteHeader(http.StatusOK)
return
}
if ti := s.resolveAccessToken(ctx, token); ti != nil {
if owns(ti.ClientID) {
id := token
if ti.JWT {
id = ti.JTI
}
_ = gs.RevokeRefreshFamily(ctx, accessKey(id))
if !ti.JWT && ti.ClientID != "" {
if a := s.anyAuth(); a != nil {
_ = a.OAuthRevokeToken(ctx, token)
}
}
}
w.WriteHeader(http.StatusOK)
return
}
}
// Tokens issued by earlier versions (session tokens without a recorded grant, pass-through refresh tokens).
if a := s.anyAuth(); a != nil {
_ = a.OAuthRevokeToken(ctx, token)
}
w.WriteHeader(http.StatusOK)
}