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) }) }