internal/oauth/oauth.go
Rama main · réplica de lectura del repositorio original.
// Package oauth is Cairn's in-process OAuth 2.1 authorization server core
// (SPEC-0007, ADR-0004). It implements the authorization-code grant with PKCE
// mandatory (S256 only), RFC 7591 dynamic client registration, RFC 8707
// audience-bound access tokens with rotating refresh tokens, and RFC 7009
// revocation — persisting clients, codes, grants, and token families in the
// same Postgres the artifact core uses (ADR-0012: one binary, one database).
//
// The package is transport-free: the httpapi adapter renders the discovery
// documents, the consent screen, and the RFC-shaped endpoint responses on top
// of this service, exactly as the web/CLI adapters sit over the artifact core
// (ADR-0003). Secrets (codes, access tokens, refresh tokens) are returned to
// the caller exactly once and persisted only as SHA-256 digests.
//
// Governing: ADR-0004 (MCP OAuth 2.1 with scoped consent),
// SPEC-0007 REQ "OAuth 2.1 Authorization-Code + PKCE",
// SPEC-0007 REQ "Exactly Three Consent Scopes",
// SPEC-0007 REQ "Token Model — Short Audience-Bound Access + Rotating Refresh".
package oauth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
)
// The exactly-three consent scopes of ADR-0004 / SPEC-0007. The authorization
// server recognizes these and nothing else — no sharing:manage, no
// artifacts:delete — so an agent grant can never exceed the consent screen
// (SPEC-0007 REQ "Exactly Three Consent Scopes").
const (
ScopeArtifactsRead = "artifacts:read"
ScopeArtifactsWrite = "artifacts:write"
ScopeAnnotationsWrite = "annotations:write"
)
// AllScopes is the canonical ordered scope list — the three consent lines.
func AllScopes() []string {
return []string{ScopeArtifactsRead, ScopeArtifactsWrite, ScopeAnnotationsWrite}
}
// ConsentLine maps a scope to its exact consent-screen line (SPEC-0007 REQ
// "Consent Screen Content"). Unknown scopes have no line and return "".
func ConsentLine(scope string) string {
switch scope {
case ScopeArtifactsRead:
return "Read artifacts you can access"
case ScopeArtifactsWrite:
return "Create & push new artifacts"
case ScopeAnnotationsWrite:
return "Comment & react on your behalf"
}
return ""
}
// Sentinel protocol errors the transport adapter maps onto RFC 6749 error
// codes. Wrapping with %w keeps them discoverable via errors.Is (SPEC-0007 REQ
// "Error Handling Standards": distinguishable typed errors at layer
// boundaries).
var (
ErrInvalidRequest = errors.New("oauth: invalid_request")
ErrInvalidClient = errors.New("oauth: invalid_client")
ErrInvalidGrant = errors.New("oauth: invalid_grant")
ErrInvalidScope = errors.New("oauth: invalid_scope")
ErrInvalidTarget = errors.New("oauth: invalid_target")
ErrInvalidRedirect = errors.New("oauth: invalid_redirect_uri")
)
// ParseScope validates and canonicalizes a space-delimited scope string
// against the exactly-three scope set. An empty input defaults to all three
// (the client asks for the full consent screen). Any token outside the three
// is ErrInvalidScope — the server never issues an unknown or elevated scope
// (SPEC-0007 scenario "Unknown or elevated scope requested"). The result is
// deduplicated and in canonical order.
func ParseScope(raw string) ([]string, error) {
fields := strings.Fields(raw)
if len(fields) == 0 {
return AllScopes(), nil
}
want := map[string]bool{}
for _, f := range fields {
if ConsentLine(f) == "" {
return nil, fmt.Errorf("scope %q is not one of the three consent scopes: %w", f, ErrInvalidScope)
}
want[f] = true
}
var out []string
for _, s := range AllScopes() {
if want[s] {
out = append(out, s)
}
}
return out, nil
}
// JoinScope renders a canonical scope list as the space-delimited wire form.
func JoinScope(scopes []string) string { return strings.Join(scopes, " ") }
// --- PKCE (RFC 7636, S256 only) ---------------------------------------------
// ValidVerifier reports whether v is a syntactically legal PKCE code_verifier:
// 43–128 characters of the unreserved set ALPHA / DIGIT / "-" / "." / "_" /
// "~" (RFC 7636 §4.1).
func ValidVerifier(v string) bool {
if len(v) < 43 || len(v) > 128 {
return false
}
for _, c := range v {
switch {
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9',
c == '-', c == '.', c == '_', c == '~':
default:
return false
}
}
return true
}
// ValidChallenge reports whether c is shaped like an S256 code_challenge: the
// unpadded base64url form of a SHA-256 digest is exactly 43 characters.
func ValidChallenge(c string) bool {
if len(c) != 43 {
return false
}
_, err := base64.RawURLEncoding.DecodeString(c)
return err == nil
}
// S256Challenge derives the S256 code_challenge for a verifier — used by tests
// and the CLI client, and the single definition the server verifies against.
func S256Challenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// VerifyPKCE reports whether verifier satisfies the stored S256 challenge
// under a constant-time compare. A malformed verifier fails closed — the token
// endpoint issues nothing without a valid verifier (SPEC-0007 scenario "Code
// exchange without a verifier").
func VerifyPKCE(verifier, challenge string) bool {
if !ValidVerifier(verifier) || challenge == "" {
return false
}
derived := S256Challenge(verifier)
return subtle.ConstantTimeCompare([]byte(derived), []byte(challenge)) == 1
}
// --- Redirect URI validation (OAuth 2.1 / RFC 8252) --------------------------
// ValidateRedirectURI enforces the registration-time redirect rules: the URI
// must be absolute with no fragment, and its scheme must be https — or http
// only for a loopback host (native/CLI clients, RFC 8252 §7.3). Everything
// else (custom schemes, plain-http remote hosts, relative paths) is rejected,
// so a hostile registration cannot point the authorization response at an
// attacker-controlled or server-side-reachable target (SPEC-0007 REQ
// "Redirect & SSRF Validation").
//
// The host itself must be a syntactically clean hostname or IP literal
// (ValidHostSyntax). url.Parse is permissive about what it accepts as a
// Host — e.g. "https://a;b/c" parses cleanly with Host `a;b` — so without
// this check a registration could smuggle a delimiter character through
// registration and into the consent screen's form-action CSP directive
// (consentCSP), producing a malformed rather than weakened header (#64).
func ValidateRedirectURI(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("redirect uri %q: %w", raw, ErrInvalidRedirect)
}
if !u.IsAbs() || u.Host == "" || u.Fragment != "" || u.User != nil {
return fmt.Errorf("redirect uri %q must be absolute, host-bearing, fragment- and userinfo-free: %w", raw, ErrInvalidRedirect)
}
if !ValidHostSyntax(u.Hostname()) {
return fmt.Errorf("redirect uri %q: host %q contains characters outside the hostname charset: %w", raw, u.Hostname(), ErrInvalidRedirect)
}
switch u.Scheme {
case "https":
return nil
case "http":
if isLoopbackHost(u.Hostname()) {
return nil
}
return fmt.Errorf("redirect uri %q: http is allowed only for loopback hosts: %w", raw, ErrInvalidRedirect)
default:
return fmt.Errorf("redirect uri %q: scheme %q is not allowed: %w", raw, u.Scheme, ErrInvalidRedirect)
}
}
// isLoopbackHost reports whether host names the local loopback interface.
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// ValidHostSyntax reports whether host (as returned by url.URL.Hostname,
// i.e. with any IPv6 brackets already stripped) is a syntactically legal
// hostname or IP literal: an IPv4/IPv6 address, or the RFC 1123 "preferred
// name syntax" charset — ALPHA / DIGIT / "-" / "." only.
//
// url.Parse does not enforce this: it happily accepts delimiter characters
// like ";" into Host as long as they don't collide with its own port/query
// splitting, e.g. "https://a;b/c" parses with Host `a;b`. Both
// ValidateRedirectURI (registration time) and consentCSP (the value gets
// interpolated into a CSP directive) call this so such a host is rejected
// or falls back to the strict policy rather than producing a malformed
// header (#64).
func ValidHostSyntax(host string) bool {
if host == "" {
return false
}
if net.ParseIP(host) != nil {
return true
}
for i := 0; i < len(host); i++ {
c := host[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.':
default:
return false
}
}
return true
}
// MatchRedirectURI reports whether presented matches one of the client's
// registered redirect URIs. Matching is exact string equality, with the single
// OAuth 2.1 / RFC 8252 §7.3 allowance: for a loopback http URI the PORT may
// vary between registration and authorization (a native client binds an
// ephemeral port), while scheme, host, and path must still match exactly. No
// request-supplied URI outside the registered set is ever honored (SPEC-0007
// scenario "Redirect to an unregistered URI").
func MatchRedirectURI(registered []string, presented string) bool {
for _, reg := range registered {
if reg == presented {
return true
}
}
// Loopback port flexibility only.
p, err := url.Parse(presented)
if err != nil || p.Scheme != "http" || !isLoopbackHost(p.Hostname()) || p.Fragment != "" {
return false
}
for _, reg := range registered {
r, err := url.Parse(reg)
if err != nil || r.Scheme != "http" || !isLoopbackHost(r.Hostname()) {
continue
}
if r.Hostname() == p.Hostname() && r.Path == p.Path && r.RawQuery == p.RawQuery {
return true
}
}
return false
}
// --- Secrets -----------------------------------------------------------------
// newSecret mints a 32-byte high-entropy URL-safe secret with a legibility
// prefix ("cairn_ac_", "cairn_at_", "cairn_rt_") so leaked strings are
// identifiable in scanners without weakening entropy.
func newSecret(prefix string) (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("oauth: mint secret: %w", err)
}
return prefix + base64.RawURLEncoding.EncodeToString(b), nil
}
// hashSecret maps a raw secret to the lowercase-hex SHA-256 stored at rest, so
// lookups are constant-cost and a database disclosure yields nothing
// replayable (SPEC-0007 REQ "Database Operation Standards").
func hashSecret(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
// --- Domain records -----------------------------------------------------------
// Client is a dynamically registered OAuth client (RFC 7591). All Cairn
// clients are public clients authenticated by PKCE (token_endpoint_auth_method
// "none"), per the MCP authorization spec.
type Client struct {
ID string
Name string
RedirectURIs []string
CreatedAt time.Time
}
// TokenSet is one issue/rotation result: the raw secrets (shown exactly once)
// plus the metadata the token response renders.
type TokenSet struct {
GrantID string
AccessToken string
RefreshToken string
AccessExpiresIn int64 // seconds
Scope string
}
// Identity is the resolved principal behind a valid access token: the human
// subject the grant binds (agents inherit the human's reach, ADR-0004
// subject-vs-actor) plus the granted scope subset.
type Identity struct {
// UserID is the grant's user (SPEC-0023 REQ "Owner Model"); ActorID is
// that user as rendered on the wire.
UserID string
ActorID string
ClientID string
GrantID string
Scopes []string
// ExpiresAt is the access token's own expiry, exposed so a bearer-token
// adapter (e.g. the MCP transport's auth.TokenVerifier, SPEC-0007) can
// populate its own token-info expiration without a second lookup.
ExpiresAt time.Time
}