858 lines
32 KiB
Go
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)
|
|
})
|
|
}
|