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

77 lines
2.8 KiB
Go

package security
import (
"net/http"
"net/url"
"strings"
)
const (
tokenTypeAccess = "urn:ietf:params:oauth:token-type:access_token" //nolint:gosec // RFC 8693 URN, not a credential
tokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" //nolint:gosec // RFC 8693 URN, not a credential
)
// handleTokenExchange implements RFC 8693 for access tokens issued by this server: the caller
// trades a subject token for one with a narrower scope and/or another audience. Delegation with an
// actor_token is not supported.
func (s *OAuthServer) handleTokenExchange(r *http.Request) (map[string]any, *oauthError) {
ac, e := s.requireClient(r)
if e != nil {
return nil, e
}
client := ac.Client
if ac.Method == "none" {
return nil, invalidClient("token exchange requires a confidential client", true)
}
if !grantAllowed(client, grantTokenExchange) {
return nil, oerr("unauthorized_client", "client may not use token exchange", http.StatusBadRequest)
}
if r.PostFormValue("actor_token") != "" {
return nil, oerr("invalid_request", "actor_token is not supported", http.StatusBadRequest)
}
if t := r.PostFormValue("subject_token_type"); t != "" && t != tokenTypeAccess && t != tokenTypeJWT {
return nil, oerr("invalid_request", "unsupported subject_token_type", http.StatusBadRequest)
}
if t := r.PostFormValue("requested_token_type"); t != "" && t != tokenTypeAccess {
return nil, oerr("invalid_request", "only access tokens can be issued", http.StatusBadRequest)
}
subject := r.PostFormValue("subject_token")
if subject == "" {
return nil, oerr("invalid_request", "subject_token required", http.StatusBadRequest)
}
ti := s.resolveAccessToken(r.Context(), subject)
if ti == nil {
return nil, oerr("invalid_grant", "subject_token is invalid or inactive", http.StatusBadRequest)
}
if ti.JKT != "" {
return nil, oerr("invalid_request", "DPoP-bound tokens cannot be exchanged", http.StatusBadRequest)
}
scopes := ti.Scopes
if req := strings.Fields(r.PostFormValue("scope")); len(req) > 0 {
if !scopesCovered(ti.Scopes, req) || (len(client.AllowedScopes) > 0 && !scopesCovered(client.AllowedScopes, req)) {
return nil, oerr("invalid_scope", "scope exceeds the subject token or the client", http.StatusBadRequest)
}
scopes = req
}
var resource []string
for _, v := range append(r.PostForm["audience"], r.PostForm["resource"]...) {
if v == "" {
continue
}
if u, err := url.Parse(v); r.PostForm["resource"] != nil && (err != nil || !u.IsAbs() || u.Fragment != "") {
return nil, oerr("invalid_target", "resource must be an absolute URI", http.StatusBadRequest)
}
resource = append(resource, v)
}
resp, e := s.mintTokens(r.Context(), &tokenGrant{
Client: client, UserID: ti.UserID, Scopes: scopes, Resource: resource, SID: ti.SID,
})
if e != nil {
return nil, e
}
resp["issued_token_type"] = tokenTypeAccess
return resp, nil
}