Files
krow_backend/go-api/internal/oauth/authserver.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

858 lines
32 KiB
Go

package oauth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/krow/krow-backend/go-api/internal/authctx"
)
// The authorization server's HTTP surface: register, authorize, token, revoke.
//
// HOW A PERSON IS AUTHENTICATED HERE
//
// They are not, by this package. The authorization endpoint requires a KROW
// user to already be signed in, and it learns who that is from the SessionResolver
// the server was built with — which the HTTP layer implements using the
// existing cookie session. There is no second password store, no second login
// form, and no credential of any kind in this package.
//
// That is also why the authorization endpoint is the only part of OAuth that
// touches cookies: it runs in a browser, as a person, mid-redirect. Everything
// after it — the token endpoint, the MCP endpoint — is a back-channel call from
// the client and uses no cookie at all.
// SessionResolver reports who is signed in, for the authorization endpoint.
//
// Implemented by the HTTP layer over the existing session manager. An interface
// rather than a direct dependency so this package does not reach into
// httpserver, and so a test can drive the flow without a browser.
type SessionResolver interface {
// CurrentUser returns the signed-in identity, or false when there is none.
CurrentUser(r *http.Request) (authctx.Identity, bool)
}
// Server is the OAuth authorization server.
type Server struct {
cfg Config
store *Store
sessions SessionResolver
log *slog.Logger
// loginPath is where an unauthenticated person is sent, with a return
// target, so they can sign in and come back to the consent screen.
loginPath string
// csrfKey signs consent-form tokens. Per-process and never persisted —
// see csrfFor.
csrfKey []byte
}
// NewServer builds the authorization server.
func NewServer(cfg Config, store *Store, sessions SessionResolver, loginPath string, log *slog.Logger) *Server {
if log == nil {
log = slog.Default()
}
if loginPath == "" {
loginPath = "/login"
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
// Unreachable short of the OS entropy source failing. Panicking is
// correct: a server that cannot generate a CSRF key cannot render a
// consent form safely, and starting without one would mean serving a
// form nothing protects.
panic("oauth: could not generate a consent CSRF key: " + err.Error())
}
return &Server{
cfg: cfg.Normalise(),
store: store,
sessions: sessions,
log: log,
loginPath: loginPath,
csrfKey: key,
}
}
/* ── Errors ─────────────────────────────────────────────────────────────── */
// oauthError is RFC 6749's error shape.
type oauthError struct {
Code string `json:"error"`
Description string `json:"error_description,omitempty"`
}
// Standard error codes. Kept to the set RFC 6749 and 7591 define, because a
// client's error handling switches on these strings.
const (
errInvalidRequest = "invalid_request"
errInvalidClient = "invalid_client"
errInvalidGrant = "invalid_grant"
errUnauthorizedClient = "unauthorized_client"
errUnsupportedGrantType = "unsupported_grant_type"
errInvalidScope = "invalid_scope"
errInvalidRedirectURI = "invalid_redirect_uri"
errInvalidTarget = "invalid_target" // RFC 8707, for a bad resource
errServerError = "server_error"
)
func writeOAuthError(w http.ResponseWriter, status int, code, description string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// A token or error response must never be cached: it is specific to one
// request and may carry a credential.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
writeJSONBody(w, status, oauthError{Code: code, Description: description})
}
func writeJSONBody(w http.ResponseWriter, status int, payload any) {
encoded, err := json.Marshal(payload)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(status)
_, _ = w.Write(encoded)
}
/* ── RFC 7591: Dynamic Client Registration ──────────────────────────────── */
type registrationRequest struct {
ClientName string `json:"client_name"`
RedirectURIs []string `json:"redirect_uris"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
Scope string `json:"scope,omitempty"`
}
type registrationResponse struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
RedirectURIs []string `json:"redirect_uris"`
GrantTypes []string `json:"grant_types"`
ResponseTypes []string `json:"response_types"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
Scope string `json:"scope"`
ClientIDIssuedAt int64 `json:"client_id_issued_at"`
}
// maxRegistrationBytes bounds a registration body. A registration is a name and
// a handful of URIs.
const maxRegistrationBytes = 16 << 10
// RegisterHandler serves dynamic client registration.
//
// Open by necessity: a client that has never registered has no credential to
// present, which is the entire point of RFC 7591 and what lets Claude connect
// without anyone provisioning anything by hand.
//
// That openness is why redirect URI validation below is strict, and why
// PHASE 5 MUST ADD RATE LIMITING HERE. This endpoint writes a row for any
// caller that can reach it. It is structured for that — one handler, one
// validation pass, nothing that would have to move — but today it has no limit,
// and that is recorded as a known gap rather than quietly left unsaid.
func (s *Server) RegisterHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeOAuthError(w, http.StatusMethodNotAllowed, errInvalidRequest, "POST only")
return
}
var req registrationRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRegistrationBytes)).Decode(&req); err != nil {
writeOAuthError(w, http.StatusBadRequest, errInvalidRequest, "the request body was not valid JSON")
return
}
if len(req.RedirectURIs) == 0 {
writeOAuthError(w, http.StatusBadRequest, errInvalidRedirectURI, "at least one redirect_uri is required")
return
}
if len(req.RedirectURIs) > 10 {
writeOAuthError(w, http.StatusBadRequest, errInvalidRedirectURI, "too many redirect_uris")
return
}
for _, uri := range req.RedirectURIs {
if err := validateRedirectURI(uri); err != nil {
writeOAuthError(w, http.StatusBadRequest, errInvalidRedirectURI, err.Error())
return
}
}
// Only the scopes this server issues. A client asking for krow.write
// is refused rather than quietly downgraded: silently granting less
// than was asked for produces a client that believes it has a
// capability and fails later, somewhere less obvious.
scopes := []string{ScopeRead}
if strings.TrimSpace(req.Scope) != "" {
requested := strings.Fields(req.Scope)
for _, sc := range requested {
if sc != ScopeRead {
writeOAuthError(w, http.StatusBadRequest, errInvalidScope,
"the only scope available is "+ScopeRead)
return
}
}
scopes = requested
}
clientID, err := newUUID()
if err != nil {
s.log.Error("oauth: client id generation failed", "error", err)
writeOAuthError(w, http.StatusInternalServerError, errServerError, "")
return
}
name := strings.TrimSpace(req.ClientName)
if len(name) > 200 {
name = name[:200]
}
client := Client{
ClientID: clientID,
ClientName: name,
RedirectURIs: req.RedirectURIs,
GrantTypes: []string{"authorization_code", "refresh_token"},
Scopes: scopes,
}
if err := s.store.CreateClient(r.Context(), client); err != nil {
s.log.Error("oauth: client registration failed", "error", err)
writeOAuthError(w, http.StatusInternalServerError, errServerError, "")
return
}
s.log.Info("oauth client registered",
"client_id", clientID, "client_name", name, "redirect_uris", len(req.RedirectURIs))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
writeJSONBody(w, http.StatusCreated, registrationResponse{
ClientID: clientID,
ClientName: name,
RedirectURIs: req.RedirectURIs,
GrantTypes: []string{"authorization_code", "refresh_token"},
// No client_secret. A public client that was issued one would ship
// it to every user's machine, and a secret everybody has is not a
// secret — OAuth 2.1 handles public clients with PKCE instead.
ResponseTypes: []string{"code"},
TokenEndpointAuthMethod: "none",
Scope: strings.Join(scopes, " "),
ClientIDIssuedAt: s.store.now().Unix(),
})
})
}
// validateRedirectURI refuses a redirect target that cannot be trusted.
//
// The rules, and why each one is here:
//
// - absolute, with a scheme and host — a relative URI has no meaning in a
// redirect and a client sending one is confused about the flow.
// - no fragment — RFC 6749 forbids it, and the authorization response appends
// its own query parameters; a fragment would be silently dropped or would
// mangle them.
// - https, OR http on loopback only. Plain http anywhere else means the
// authorization code travels in clear text. Loopback is the documented
// exception for native clients (RFC 8252) and is safe because the traffic
// never leaves the machine.
//
// Custom schemes (myapp://callback) are NOT accepted. They are legal per RFC
// 8252 and are a real mechanism for native apps, but any application on the
// machine can register the same scheme and steal the code. Claude's connectors
// use https and loopback, so accepting custom schemes would widen the surface
// for no caller that exists.
func validateRedirectURI(raw string) error {
parsed, err := url.Parse(raw)
if err != nil {
return errMsg("redirect_uri is not a valid URI")
}
if parsed.Scheme == "" || parsed.Host == "" {
return errMsg("redirect_uri must be absolute, with a scheme and host")
}
if parsed.Fragment != "" || strings.Contains(raw, "#") {
return errMsg("redirect_uri must not contain a fragment")
}
switch strings.ToLower(parsed.Scheme) {
case "https":
return nil
case "http":
if isLoopbackHost(parsed.Hostname()) {
return nil
}
return errMsg("http is only permitted for loopback redirect URIs")
default:
return errMsg("redirect_uri must use https, or http on loopback")
}
}
func isLoopbackHost(host string) bool {
switch host {
case "127.0.0.1", "::1", "localhost":
return true
}
return false
}
type errString string
func (e errString) Error() string { return string(e) }
func errMsg(s string) error { return errString(s) }
/* ── Authorization endpoint ─────────────────────────────────────────────── */
// authorizeParams is a validated authorization request.
type authorizeParams struct {
ClientID string
RedirectURI string
ResponseType string
Scopes []string
State string
CodeChallenge string
CodeChallengeMethod string
Resource string
}
// AuthorizeHandler serves the authorization endpoint.
//
// THE ORDER OF VALIDATION IS A SECURITY PROPERTY, not a style choice.
//
// The client_id and redirect_uri are validated FIRST, against the registration,
// before anything else is looked at. Only once the redirect target is known to
// be one this client registered may an error be delivered BY REDIRECTING to it.
// Getting this backwards — redirecting an error to an unvalidated URI — is an
// open redirect, and it is the most common way this endpoint is got wrong.
//
// So: a bad client_id or a bad redirect_uri is answered as a direct HTTP error
// that the browser displays. Everything after that is delivered as a redirect
// with `error=` and the client's `state`, because by then the target is known
// to be legitimate.
func (s *Server) AuthorizeHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
w.Header().Set("Allow", "GET, POST")
writeOAuthError(w, http.StatusMethodNotAllowed, errInvalidRequest, "GET or POST only")
return
}
// A POST carries the decision and the flow's parameters in its body,
// re-posted from the consent form's hidden fields. Merging them into
// the query is what lets every validation below read from one place
// regardless of method — and means the POST is validated exactly as
// strictly as the GET that produced it, rather than trusting the form.
q := r.URL.Query()
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
writeOAuthError(w, http.StatusBadRequest, errInvalidRequest,
"the form could not be parsed")
return
}
q = r.PostForm
}
// ── Stage 1: the client and its redirect target. Errors here are
// direct responses, never redirects.
clientID := strings.TrimSpace(q.Get("client_id"))
if clientID == "" {
writeOAuthError(w, http.StatusBadRequest, errInvalidClient, "client_id is required")
return
}
client, err := s.store.FindClient(r.Context(), clientID)
if err != nil {
s.log.Warn("oauth authorize refused", "reason", "unknown_client", "client_id", clientID)
writeOAuthError(w, http.StatusBadRequest, errInvalidClient, "unknown client")
return
}
redirectURI := strings.TrimSpace(q.Get("redirect_uri"))
if redirectURI == "" {
writeOAuthError(w, http.StatusBadRequest, errInvalidRedirectURI, "redirect_uri is required")
return
}
if !client.AllowsRedirect(redirectURI) {
// Deliberately NOT redirected. This is the open-redirect guard.
s.log.Warn("oauth authorize refused",
"reason", "redirect_uri_mismatch", "client_id", clientID)
writeOAuthError(w, http.StatusBadRequest, errInvalidRedirectURI,
"redirect_uri does not match a registered URI for this client")
return
}
// ── Stage 2: everything else. The target is trusted now, so failures
// are delivered to it.
state := strings.TrimSpace(q.Get("state"))
if state == "" {
// Required, not optional. state is the client's CSRF defence for
// the callback; a flow without one can be completed by an attacker
// who injects their own authorization response.
s.redirectError(w, r, redirectURI, "", errInvalidRequest, "state is required")
return
}
if rt := q.Get("response_type"); rt != "code" {
s.redirectError(w, r, redirectURI, state, "unsupported_response_type",
"only response_type=code is supported")
return
}
challenge := strings.TrimSpace(q.Get("code_challenge"))
method := strings.TrimSpace(q.Get("code_challenge_method"))
if challenge == "" {
s.redirectError(w, r, redirectURI, state, errInvalidRequest,
"code_challenge is required; this server requires PKCE")
return
}
if method == "" {
// RFC 7636 defaults an absent method to `plain`. This server does
// not accept plain, so an absent method is an error rather than a
// silent downgrade to the weaker mode.
s.redirectError(w, r, redirectURI, state, errInvalidRequest,
"code_challenge_method is required and must be S256")
return
}
if err := ValidateChallenge(challenge, method); err != nil {
s.redirectError(w, r, redirectURI, state, errInvalidRequest, err.Error())
return
}
// RFC 8707. The resource must be THIS server's canonical MCP URI. A
// token is bound to it, so accepting an arbitrary value would let a
// client mint a token aimed at something else.
resource := strings.TrimSpace(q.Get("resource"))
if resource == "" {
s.redirectError(w, r, redirectURI, state, errInvalidTarget,
"resource is required")
return
}
if strings.TrimRight(resource, "/") != s.cfg.Resource {
s.log.Warn("oauth authorize refused",
"reason", "resource_mismatch", "client_id", clientID, "presented", resource)
s.redirectError(w, r, redirectURI, state, errInvalidTarget,
"resource is not a resource this server issues tokens for")
return
}
scopes := []string{ScopeRead}
if raw := strings.TrimSpace(q.Get("scope")); raw != "" {
scopes = strings.Fields(raw)
for _, sc := range scopes {
if sc != ScopeRead {
s.redirectError(w, r, redirectURI, state, errInvalidScope,
"the only scope available is "+ScopeRead)
return
}
}
}
if !client.AllowsScopes(scopes) {
s.redirectError(w, r, redirectURI, state, errInvalidScope,
"this client is not registered for the requested scope")
return
}
params := authorizeParams{
ClientID: clientID, RedirectURI: redirectURI, ResponseType: "code",
Scopes: scopes, State: state, CodeChallenge: challenge,
CodeChallengeMethod: method, Resource: resource,
}
// ── Stage 3: who is this?
identity, signedIn := s.sessions.CurrentUser(r)
if !signedIn {
// Not signed in. Send them to the existing login, with a return
// target that brings them back to this exact authorization request.
// No credential is handled here — the existing cookie login does
// that, unchanged.
s.redirectToLogin(w, r)
return
}
// ── Stage 4: consent.
//
// A GET renders the question. Only a POST carrying a session-bound
// CSRF token answers it, so a cross-site navigation can show a person
// the form but cannot approve on their behalf.
csrf := s.csrfFor(identity)
if r.Method != http.MethodPost {
s.renderConsent(w, r, params, identity, csrf)
return
}
if !s.csrfValid(identity, r.PostFormValue("csrf")) {
// Not an OAuth protocol error — it is a request that did not come
// from the form this server rendered. Answered directly rather
// than redirected, because the client is not the party at fault
// and telling it "access_denied" would be a lie.
s.log.Warn("oauth consent refused", "reason", "csrf_mismatch",
"client_id", params.ClientID, "user_id", identity.UserID)
writeOAuthError(w, http.StatusForbidden, errInvalidRequest,
"this consent form has expired; start the authorization again")
return
}
switch r.PostFormValue("decision") {
case "approve":
s.log.Info("oauth consent approved",
"client_id", params.ClientID, "user_id", identity.UserID,
"org_id", identity.OrgID, "scopes", params.Scopes)
s.issueCode(w, r, params, identity)
case "deny":
// RFC 6749 section 4.1.2.1: a refusal is `access_denied`, returned
// to the client at its registered redirect with the state intact.
// NO CODE IS ISSUED — the deny path never reaches issueCode.
s.log.Info("oauth consent denied",
"client_id", params.ClientID, "user_id", identity.UserID)
s.redirectError(w, r, params.RedirectURI, params.State,
"access_denied", "the user declined this authorization")
default:
// A POST with neither decision. Re-render rather than guess: the
// one thing that must not happen is inferring approval.
s.renderConsent(w, r, params, identity, csrf)
}
})
}
/* ── Consent CSRF ───────────────────────────────────────────────────────── */
// csrfFor derives a token binding the consent form to the signed-in user.
//
// An HMAC over the user id under a per-process key, rather than a random value
// in server-side state. The property needed is only "this form was rendered by
// this server for this user", and an HMAC gives that with nothing to store and
// nothing to expire.
//
// The key is generated at startup and never leaves the process, so a token does
// not survive a restart — which ends any consent form open at that moment. That
// is acceptable: the window between rendering and deciding is seconds, and the
// failure mode is a person clicking Approve and being asked to start again.
func (s *Server) csrfFor(identity authctx.Identity) string {
mac := hmac.New(sha256.New, s.csrfKey)
mac.Write([]byte(identity.UserID))
return hex.EncodeToString(mac.Sum(nil))
}
// csrfValid checks a submitted token in constant time.
func (s *Server) csrfValid(identity authctx.Identity, presented string) bool {
if presented == "" {
return false
}
return hmac.Equal([]byte(s.csrfFor(identity)), []byte(presented))
}
// issueCode stores an authorization code and redirects it to the client.
func (s *Server) issueCode(w http.ResponseWriter, r *http.Request, p authorizeParams, identity authctx.Identity) {
code, err := s.store.CreateGrant(r.Context(), Grant{
ClientID: p.ClientID,
UserID: identity.UserID,
OrgID: identity.OrgID,
RedirectURI: p.RedirectURI,
Scopes: p.Scopes,
Resource: p.Resource,
CodeChallenge: p.CodeChallenge,
CodeChallengeMethod: p.CodeChallengeMethod,
})
if err != nil {
s.log.Error("oauth: could not create grant", "error", err, "client_id", p.ClientID)
s.redirectError(w, r, p.RedirectURI, p.State, errServerError, "")
return
}
// The code id is not logged, and neither is the code. What is logged is who
// approved what, which is the audit question worth answering.
s.log.Info("oauth code issued",
"client_id", p.ClientID, "user_id", identity.UserID,
"org_id", identity.OrgID, "scopes", p.Scopes, "resource", p.Resource)
target, err := url.Parse(p.RedirectURI)
if err != nil {
writeOAuthError(w, http.StatusBadRequest, errInvalidRedirectURI, "redirect_uri is not a valid URI")
return
}
q := target.Query()
q.Set("code", code)
q.Set("state", p.State)
target.RawQuery = q.Encode()
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, target.String(), http.StatusFound)
}
// redirectError delivers an error to a VALIDATED redirect target.
//
// Only ever called after the redirect_uri has been matched against the client's
// registration. See the note on AuthorizeHandler.
func (s *Server) redirectError(w http.ResponseWriter, r *http.Request, redirectURI, state, code, description string) {
target, err := url.Parse(redirectURI)
if err != nil {
writeOAuthError(w, http.StatusBadRequest, errInvalidRedirectURI, "redirect_uri is not a valid URI")
return
}
q := target.Query()
q.Set("error", code)
if description != "" {
q.Set("error_description", description)
}
if state != "" {
q.Set("state", state)
}
target.RawQuery = q.Encode()
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, target.String(), http.StatusFound)
}
// redirectToLogin sends an unauthenticated person to the existing login.
//
// The return target is this server's own path plus the original query, so the
// authorization request survives the round trip. It is built from r.URL rather
// than from anything the caller supplied, so it cannot be pointed elsewhere.
func (s *Server) redirectToLogin(w http.ResponseWriter, r *http.Request) {
returnTo := r.URL.Path
if r.URL.RawQuery != "" {
returnTo += "?" + r.URL.RawQuery
}
target := s.loginPath + "?returnTo=" + url.QueryEscape(returnTo)
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, target, http.StatusFound)
}
/* ── Token endpoint ─────────────────────────────────────────────────────── */
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}
// TokenHandler serves the token endpoint: code exchange and refresh.
func (s *Server) TokenHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeOAuthError(w, http.StatusMethodNotAllowed, errInvalidRequest, "POST only")
return
}
if err := r.ParseForm(); err != nil {
writeOAuthError(w, http.StatusBadRequest, errInvalidRequest, "the request body could not be parsed")
return
}
switch r.PostFormValue("grant_type") {
case "authorization_code":
s.exchangeCode(w, r)
case "refresh_token":
s.refresh(w, r)
case "":
writeOAuthError(w, http.StatusBadRequest, errInvalidRequest, "grant_type is required")
default:
// password, client_credentials, implicit and anything else. Named
// explicitly in the metadata as unsupported, and refused here.
writeOAuthError(w, http.StatusBadRequest, errUnsupportedGrantType,
"only authorization_code and refresh_token are supported")
}
})
}
// exchangeCode turns an authorization code into a token pair.
//
// Every binding recorded at authorization is re-verified. A code is not a
// bearer credential on its own: it is a credential for one client, one redirect
// target, one resource, and one PKCE verifier, and a mismatch on any of them
// means the code is being spent by someone other than the client it was issued
// to.
func (s *Server) exchangeCode(w http.ResponseWriter, r *http.Request) {
code := r.PostFormValue("code")
clientID := r.PostFormValue("client_id")
redirectURI := r.PostFormValue("redirect_uri")
verifier := r.PostFormValue("code_verifier")
resource := strings.TrimSpace(r.PostFormValue("resource"))
if code == "" || clientID == "" || redirectURI == "" {
writeOAuthError(w, http.StatusBadRequest, errInvalidRequest,
"code, client_id and redirect_uri are required")
return
}
if verifier == "" {
writeOAuthError(w, http.StatusBadRequest, errInvalidRequest,
"code_verifier is required; this server requires PKCE")
return
}
// Redeeming CONSUMES the code, whatever happens next. That is deliberate:
// if a later check fails, the code is still spent, so an attacker cannot
// probe the remaining bindings by retrying the same code with different
// values. One code, one attempt.
grant, err := s.store.RedeemGrant(r.Context(), code)
if err != nil {
s.log.Warn("oauth token refused", "reason", "grant_unusable", "client_id", clientID)
writeOAuthError(w, http.StatusBadRequest, errInvalidGrant,
"the authorization code is invalid, expired or already used")
return
}
if grant.ClientID != clientID {
s.log.Warn("oauth token refused", "reason", "client_mismatch", "client_id", clientID)
writeOAuthError(w, http.StatusBadRequest, errInvalidGrant, "this code was not issued to this client")
return
}
if grant.RedirectURI != redirectURI {
s.log.Warn("oauth token refused", "reason", "redirect_uri_mismatch", "client_id", clientID)
writeOAuthError(w, http.StatusBadRequest, errInvalidGrant, "redirect_uri does not match the authorization request")
return
}
// The resource is optional at the token endpoint when the code already
// carries one, but if it IS supplied it must agree.
if resource != "" && strings.TrimRight(resource, "/") != grant.Resource {
writeOAuthError(w, http.StatusBadRequest, errInvalidTarget, "resource does not match the authorization request")
return
}
if err := VerifyChallenge(verifier, grant.CodeChallenge, grant.CodeChallengeMethod); err != nil {
s.log.Warn("oauth token refused", "reason", "pkce_mismatch", "client_id", clientID)
writeOAuthError(w, http.StatusBadRequest, errInvalidGrant, "code_verifier does not match")
return
}
pair, err := s.store.IssuePair(r.Context(), Token{
ClientID: grant.ClientID,
UserID: grant.UserID,
OrgID: grant.OrgID,
Scopes: grant.Scopes,
Audience: grant.Resource,
}, "")
if err != nil {
s.log.Error("oauth: could not issue tokens", "error", err)
writeOAuthError(w, http.StatusInternalServerError, errServerError, "")
return
}
// The tokens themselves are NOT in this log line and never will be.
s.log.Info("oauth tokens issued",
"grant_type", "authorization_code", "client_id", grant.ClientID,
"user_id", grant.UserID, "org_id", grant.OrgID, "family_id", pair.FamilyID)
writeTokenResponse(w, pair)
}
// refresh rotates a refresh token.
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
raw := r.PostFormValue("refresh_token")
clientID := r.PostFormValue("client_id")
if raw == "" || clientID == "" {
writeOAuthError(w, http.StatusBadRequest, errInvalidRequest,
"refresh_token and client_id are required")
return
}
old, err := s.store.RedeemRefreshToken(r.Context(), raw)
switch {
case err == nil:
// fall through
case errors.Is(err, ErrRefreshReuse):
// The family has already been revoked by the store. Logged at warn
// because it is either a client bug or a stolen token, and both are
// worth seeing. The CLIENT is told the same thing as for any other bad
// token — distinguishing "reused" would confirm the token was once
// real.
s.log.Warn("oauth refresh refused", "reason", "reuse_detected", "client_id", clientID)
writeOAuthError(w, http.StatusBadRequest, errInvalidGrant, "the refresh token is invalid")
return
default:
s.log.Warn("oauth refresh refused", "reason", "token_unusable", "client_id", clientID)
writeOAuthError(w, http.StatusBadRequest, errInvalidGrant, "the refresh token is invalid")
return
}
if old.ClientID != clientID {
// Not this client's token. Revoke the family: a refresh token that has
// reached the wrong client has leaked.
_ = s.store.RevokeFamily(r.Context(), old.FamilyID, "client_mismatch_on_refresh")
s.log.Warn("oauth refresh refused", "reason", "client_mismatch", "client_id", clientID)
writeOAuthError(w, http.StatusBadRequest, errInvalidGrant, "the refresh token is invalid")
return
}
// Same family: the rotation continues the lineage, so reuse detection can
// still revoke every descendant if an older token reappears.
pair, err := s.store.IssuePair(r.Context(), Token{
ClientID: old.ClientID,
UserID: old.UserID,
OrgID: old.OrgID,
Scopes: old.Scopes,
Audience: old.Audience,
}, old.FamilyID)
if err != nil {
s.log.Error("oauth: could not rotate tokens", "error", err)
writeOAuthError(w, http.StatusInternalServerError, errServerError, "")
return
}
s.log.Info("oauth tokens issued",
"grant_type", "refresh_token", "client_id", old.ClientID,
"user_id", old.UserID, "family_id", pair.FamilyID)
writeTokenResponse(w, pair)
}
func writeTokenResponse(w http.ResponseWriter, pair TokenPair) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// RFC 6749 section 5.1 requires both of these on a token response. The
// body is a credential; nothing may cache it.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
writeJSONBody(w, http.StatusOK, tokenResponse{
AccessToken: pair.AccessToken,
TokenType: "Bearer",
ExpiresIn: pair.ExpiresIn,
RefreshToken: pair.RefreshToken,
Scope: strings.Join(pair.Scopes, " "),
})
}
/* ── Revocation (RFC 7009) ──────────────────────────────────────────────── */
// RevokeHandler serves token revocation.
//
// RFC 7009 requires 200 for an unknown token: answering 404 would turn this
// into an oracle for whether a token exists. The store already behaves that
// way; this handler just does not undo it.
func (s *Server) RevokeHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeOAuthError(w, http.StatusMethodNotAllowed, errInvalidRequest, "POST only")
return
}
if err := r.ParseForm(); err != nil {
writeOAuthError(w, http.StatusBadRequest, errInvalidRequest, "the request body could not be parsed")
return
}
token := r.PostFormValue("token")
if token == "" {
writeOAuthError(w, http.StatusBadRequest, errInvalidRequest, "token is required")
return
}
if err := s.store.RevokeToken(r.Context(), token, "client_revocation"); err != nil {
s.log.Error("oauth: revocation failed", "error", err)
writeOAuthError(w, http.StatusInternalServerError, errServerError, "")
return
}
s.log.Info("oauth token revoked", "client_id", r.PostFormValue("client_id"))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
})
}