mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-04 20:41:58 +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,124 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user