131 lines
5.5 KiB
Go
131 lines
5.5 KiB
Go
// Package oauth is KROW's OAuth 2.1 authorization server and the token store
|
|
// behind it.
|
|
//
|
|
// It exists for one caller: the MCP surface, which needs a way to authenticate
|
|
// a client that cannot hold a cookie. Everything here is in service of turning
|
|
// a browser-based approval into an opaque bearer token that
|
|
// mcpserver.TokenAuthenticator can resolve back into the SAME
|
|
// authctx.Identity the cookie path produces.
|
|
//
|
|
// # WHAT THIS PACKAGE DOES NOT DO
|
|
//
|
|
// It does not authorize anything. It establishes WHO is calling; what they may
|
|
// then read is decided by the existing policy table, in the existing tool
|
|
// layer, exactly as it is for a cookie session. There is no OAuth scope that
|
|
// grants access to a row. `krow.read` says "this client may use the read
|
|
// tools"; whether this user may see a particular row is a question
|
|
// tools/scope.go answers and this package never touches.
|
|
//
|
|
// It also does not store a password, check one, or keep a second user table.
|
|
// The authorization endpoint authenticates the person using the session they
|
|
// already have — see authserver.go.
|
|
package oauth
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"regexp"
|
|
)
|
|
|
|
// PKCE — Proof Key for Code Exchange, RFC 7636.
|
|
//
|
|
// The problem it solves: an authorization code travels back through a browser
|
|
// redirect, which is the least trustworthy hop in the flow. Anything that can
|
|
// observe that redirect — a malicious app registered for the same custom URL
|
|
// scheme, a proxy, a shoulder — can steal the code. For a confidential client
|
|
// that does not matter, because redeeming the code also requires a client
|
|
// secret. A public client has no secret, so the code alone would be enough.
|
|
//
|
|
// PKCE gives the client a per-request secret instead. It invents a random
|
|
// `code_verifier`, sends only SHA-256 of it with the authorization request, and
|
|
// presents the verifier itself at the token endpoint. A stolen code is useless
|
|
// without the verifier, which never travelled through the browser.
|
|
//
|
|
// S256 ONLY. RFC 7636 also defines `plain`, where the challenge IS the
|
|
// verifier. That protects against nothing — anyone who stole the code from the
|
|
// redirect also stole the challenge, and the challenge is the verifier — and
|
|
// OAuth 2.1 forbids it for public clients. It is refused here, and refused
|
|
// again by a CHECK constraint in migration 000013, so no code path can relax it.
|
|
|
|
// MethodS256 is the only code_challenge_method this server accepts.
|
|
const MethodS256 = "S256"
|
|
|
|
var (
|
|
// ErrUnsupportedChallengeMethod covers `plain` and anything else.
|
|
ErrUnsupportedChallengeMethod = errors.New("oauth: code_challenge_method must be S256")
|
|
|
|
// ErrMalformedChallenge covers a challenge that is not base64url of a
|
|
// SHA-256 digest.
|
|
ErrMalformedChallenge = errors.New("oauth: malformed code_challenge")
|
|
|
|
// ErrMalformedVerifier covers a verifier outside RFC 7636's length or
|
|
// character set.
|
|
ErrMalformedVerifier = errors.New("oauth: malformed code_verifier")
|
|
|
|
// ErrVerifierMismatch is the one that matters: a verifier that does not
|
|
// hash to the stored challenge.
|
|
ErrVerifierMismatch = errors.New("oauth: code_verifier does not match code_challenge")
|
|
)
|
|
|
|
// challengePattern is base64url of a 32-byte digest: 43 characters, unpadded.
|
|
// The same pattern migration 000013 enforces in oauth_grants_challenge_shape.
|
|
var challengePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
|
|
|
|
// verifierPattern is RFC 7636 section 4.1's `code_verifier` grammar:
|
|
// unreserved characters only, 43 to 128 of them.
|
|
var verifierPattern = regexp.MustCompile(`^[A-Za-z0-9._~-]{43,128}$`)
|
|
|
|
// ValidateChallenge checks a code_challenge and its method at the authorization
|
|
// endpoint, before any row is written.
|
|
//
|
|
// Rejecting a malformed challenge here rather than at the token endpoint means
|
|
// the failure lands where the client can act on it — on its own authorization
|
|
// request — instead of after a person has been walked through a consent screen
|
|
// for a flow that was never going to complete.
|
|
func ValidateChallenge(challenge, method string) error {
|
|
if method != MethodS256 {
|
|
return ErrUnsupportedChallengeMethod
|
|
}
|
|
if !challengePattern.MatchString(challenge) {
|
|
return ErrMalformedChallenge
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VerifyChallenge reports whether a verifier matches a stored challenge.
|
|
//
|
|
// The comparison is constant-time. A byte-by-byte comparison that returned
|
|
// early would leak, through timing, how much of a guessed verifier was correct
|
|
// — which turns an infeasible search into a feasible one, one character at a
|
|
// time. The values being compared are both base64url text of the same fixed
|
|
// length, so subtle.ConstantTimeCompare is exactly the right tool.
|
|
func VerifyChallenge(verifier, challenge, method string) error {
|
|
if method != MethodS256 {
|
|
return ErrUnsupportedChallengeMethod
|
|
}
|
|
if !verifierPattern.MatchString(verifier) {
|
|
return ErrMalformedVerifier
|
|
}
|
|
if !challengePattern.MatchString(challenge) {
|
|
return ErrMalformedChallenge
|
|
}
|
|
|
|
computed := ChallengeFor(verifier)
|
|
if subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) != 1 {
|
|
return ErrVerifierMismatch
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ChallengeFor derives the S256 challenge for a verifier.
|
|
//
|
|
// base64url WITHOUT padding, per RFC 7636 appendix A. Padding would add a '='
|
|
// that has to be escaped in a query string, and a client that padded would
|
|
// produce a challenge this server did not recognise.
|
|
func ChallengeFor(verifier string) string {
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
|
}
|