Files
krow_backend/go-api/internal/oauth/pkce.go
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

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[:])
}