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