Files
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

347 lines
14 KiB
Go

package oauth
import (
"html/template"
"net/http"
"net/url"
"strings"
"github.com/krow/krow-backend/go-api/internal/authctx"
)
// The consent step: the one place a person decides.
//
// Phase 3 approved a signed-in user's authorization immediately. That was
// honest scaffolding and is not a flow anybody should ship: OAuth's entire
// premise is that a RESOURCE OWNER grants access, and an authorization nobody
// was asked about is a token minted on their behalf without their knowledge.
// Any page on the internet could have linked a person to a crafted authorize
// URL and had Claude connected to their workspace before they read anything.
//
// HOW THIS RESISTS THAT
//
// The consent form carries a CSRF token bound to the session, and approval is
// a POST. A cross-site GET to /oauth/authorize can therefore render the form —
// which is harmless, it is a question — but cannot answer it. Without the POST
// and the token, an attacker who can make a browser navigate cannot make it
// consent.
//
// WHAT IT SHOWS
//
// The client's self-declared name, the organisation being granted, the scope in
// plain words, and the resource. The client name is UNTRUSTED — it is whatever
// the registering client sent — so it is escaped by html/template and is never
// the basis of a decision, only of a label. The organisation is read from the
// signed-in identity, so a person can see which tenant they are about to hand
// over even when they belong to more than one.
// consentTemplate is the approval page.
//
// Deliberately one self-contained page with inline styles: it renders before a
// person is willing to trust anything, it must work with no stylesheet, no
// script and no font available, and a consent screen that depends on assets is
// a consent screen that can fail open into a blank page with two buttons.
//
// Every interpolation is escaped by html/template. The `.ClientName` in
// particular is attacker-controlled — anyone may register a client called
// `<script>…` — and the escaping is what makes displaying it safe.
var consentTemplate = template.Must(template.New("consent").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorize access &middot; Krow</title>
<style>
:root { color-scheme: light dark; }
body { margin:0; min-height:100vh; display:flex; align-items:center;
justify-content:center; background:#f4f5f7;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
color:#14161a; padding:16px; box-sizing:border-box; }
.card { background:#fff; border:1px solid #e3e5e8; border-radius:12px;
max-width:440px; width:100%; padding:28px; box-sizing:border-box; }
h1 { font-size:19px; margin:0 0 4px; }
.sub { color:#5c6270; font-size:14px; margin:0 0 20px; }
dl { margin:0 0 20px; border-top:1px solid #eceef0; }
.row { display:flex; justify-content:space-between; gap:16px;
padding:11px 0; border-bottom:1px solid #eceef0; font-size:14px; }
dt { color:#5c6270; margin:0; flex:0 0 auto; }
dd { margin:0; text-align:right; word-break:break-word; font-weight:500; }
.grants { background:#f7f8f9; border-radius:8px; padding:14px 16px;
font-size:14px; margin:0 0 20px; }
.grants strong { display:block; margin-bottom:6px; font-size:13px;
text-transform:uppercase; letter-spacing:.04em; color:#5c6270; }
.grants ul { margin:0; padding-left:18px; }
.grants li { margin:3px 0; }
.actions { display:flex; gap:10px; }
button { flex:1; padding:11px 16px; border-radius:8px; font-size:15px;
font-weight:500; cursor:pointer; border:1px solid transparent; }
.approve { background:#14161a; color:#fff; }
.deny { background:#fff; color:#14161a; border-color:#d4d7dc; }
.note { margin:16px 0 0; font-size:12.5px; color:#787e8a; line-height:1.5; }
@media (prefers-color-scheme: dark) {
body { background:#0e1013; color:#e9eaec; }
.card { background:#16191d; border-color:#282c33; }
dl,.row { border-color:#282c33; }
.grants { background:#1c2026; }
.approve { background:#e9eaec; color:#14161a; }
.deny { background:#16191d; color:#e9eaec; border-color:#3a3f47; }
dt,.sub,.note,.grants strong { color:#9aa1ad; }
}
</style>
</head>
<body>
<main class="card">
<h1>Authorize access to Krow</h1>
<p class="sub"><strong>{{.ClientName}}</strong> is asking to connect to your Krow workspace.</p>
<dl>
<div class="row"><dt>Application</dt><dd>{{.ClientName}}</dd></div>
<div class="row"><dt>Signed in as</dt><dd>{{.UserEmail}}</dd></div>
<div class="row"><dt>Organisation</dt><dd>{{.OrgName}}</dd></div>
<div class="row"><dt>Connecting to</dt><dd>{{.Resource}}</dd></div>
</dl>
<div class="grants">
<strong>This will allow it to</strong>
<ul>{{range .Grants}}<li>{{.}}</li>{{end}}</ul>
</div>
<form method="POST" action="{{.FormAction}}">
{{range $k, $v := .Hidden}}<input type="hidden" name="{{$k}}" value="{{$v}}">{{end}}
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="actions">
<button type="submit" name="decision" value="deny" class="deny">Deny</button>
<button type="submit" name="decision" value="approve" class="approve">Approve</button>
</div>
</form>
<p class="note">Approving lets this application read Krow data that you can
already see, as you, in this organisation. It cannot make changes. You can
disconnect it at any time from your Krow settings.</p>
</main>
</body>
</html>`))
// consentView is what the template renders.
type consentView struct {
ClientName string
UserEmail string
OrgName string
Resource string
Grants []string
FormAction string
Hidden map[string]string
CSRF string
}
// grantsFor renders scopes as sentences a person can act on.
//
// "krow.read" means nothing to the person being asked. A consent screen that
// shows a scope identifier is a consent screen that has not obtained informed
// consent — it has obtained a click.
func grantsFor(scopes []string) []string {
out := make([]string, 0, len(scopes))
for _, scope := range scopes {
switch scope {
case ScopeRead:
out = append(out,
"Read workforce activity, staff, candidates and positions",
"See only what your own Krow account can see",
)
case ScopeWrite:
// Unreachable: krow.write is never issued and never registered.
// Present so that if it ever is, it arrives with words attached
// rather than as a bare identifier on a screen.
out = append(out, "Make changes to your Krow data")
default:
out = append(out, scope)
}
}
return out
}
// renderConsent shows the approval form.
func (s *Server) renderConsent(w http.ResponseWriter, r *http.Request, p authorizeParams, identity authctx.Identity, csrf string) {
client, err := s.store.FindClient(r.Context(), p.ClientID)
if err != nil {
writeOAuthError(w, http.StatusBadRequest, errInvalidClient, "unknown client")
return
}
name := strings.TrimSpace(client.ClientName)
if name == "" {
name = "An application"
}
orgName := s.orgNameFor(r, identity.OrgID)
// Everything needed to complete the flow rides in hidden fields, so the
// POST carries its own context and the server keeps no pending-request
// state. State on the server would be state to expire and to clean up, for
// a decision that is made in the next few seconds.
hidden := map[string]string{
"client_id": p.ClientID,
"redirect_uri": p.RedirectURI,
"response_type": p.ResponseType,
"scope": strings.Join(p.Scopes, " "),
"state": p.State,
"code_challenge": p.CodeChallenge,
"code_challenge_method": p.CodeChallengeMethod,
"resource": p.Resource,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// A consent page names a client and an organisation and must never be
// served from a cache to the next person on a shared machine.
w.Header().Set("Cache-Control", "no-store, private")
w.Header().Set("Pragma", "no-cache")
// Defence in depth for a page that renders an attacker-supplied name:
// no framing (so it cannot be clickjacked into an invisible overlay), no
// referrer (so the query string does not leak to the client's site), and a
// CSP that forbids script entirely — this page has none.
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", consentCSP(client.RedirectURIs))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_ = consentTemplate.Execute(w, consentView{
ClientName: name,
UserEmail: identity.Email,
OrgName: orgName,
Resource: p.Resource,
Grants: grantsFor(p.Scopes),
FormAction: s.cfg.AuthorizePath,
Hidden: hidden,
CSRF: csrf,
})
}
// orgNameFor resolves an organisation's display name.
//
// Best effort: a missing name degrades to the id rather than failing the flow.
// A consent screen that will not render because of a display lookup is worse
// than one that shows a uuid.
func (s *Server) orgNameFor(r *http.Request, orgID string) string {
if orgID == "" {
return "your organisation"
}
var name string
if err := s.store.db.QueryRow(r.Context(),
`SELECT name FROM organizations WHERE id = $1::uuid`, orgID).Scan(&name); err != nil {
return orgID
}
if strings.TrimSpace(name) == "" {
return orgID
}
return name
}
/* ── The consent page's Content-Security-Policy ─────────────────────────── */
// consentCSP builds the policy for the consent page.
//
// WHY form-action CANNOT BE 'self' ALONE
//
// It was, and that was a real bug: the consent form is blocked in the browser
// before it can submit. A consent form's successful submission ends, by
// definition, at the OAuth client's registered redirect_uri — a third party's
// callback, always cross-origin. Browsers enforce form-action across the whole
// navigation chain including redirects (MDN carries an explicit warning that
// this is inconsistent between engines; Chrome blocks, older Firefox did not),
// so `form-action 'self'` makes the flow impossible to complete rather than
// merely strict.
//
// The tests did not catch it because httptest executes no CSP. They asserted
// the header's value, which was set exactly as intended; only a real browser
// could show that what was intended was wrong.
//
// # WHAT IS ALLOWED INSTEAD
//
// 'self', plus the ORIGINS OF THIS CLIENT'S OWN REGISTERED REDIRECT URIs, and
// nothing else. That is narrower than it may look:
//
// - The URIs were validated at registration — absolute, https (or http on
// loopback), no fragment. That validation is untouched.
// - The authorization endpoint still matches the presented redirect_uri
// against the registration byte-for-byte. This policy does not widen what
// a flow may redirect to; it only stops the browser blocking the redirect
// the server was already going to permit.
// - Each client gets its own policy, built from its own registration, so one
// client's callback never appears in another's page.
//
// A URI that cannot be reduced to a safe origin is DROPPED rather than
// broadened. The failure mode is a consent page whose form the browser blocks —
// visible, and the safe direction — never a policy that permits more.
func consentCSP(redirectURIs []string) string {
directives := []string{
"default-src 'none'",
"style-src 'unsafe-inline'",
"frame-ancestors 'none'",
}
formAction := "form-action 'self'"
for _, origin := range redirectOrigins(redirectURIs) {
formAction += " " + origin
}
directives = append(directives, formAction)
return strings.Join(directives, "; ")
}
// redirectOrigins reduces registered redirect URIs to CSP source expressions.
//
// A CSP source is an ORIGIN — scheme, host and port — never a path. Emitting
// the full URI would be wrong twice: CSP would match it as a path prefix, and a
// path is not what a form navigation is checked against.
//
// Every value is dropped unless it is unambiguously safe:
//
// unparseable → dropped (never widened to a bare scheme)
// no scheme or no host → dropped
// scheme other than
// http/https → dropped; a custom scheme in a policy is a source
// any app on the machine could claim
// wildcard or separator → dropped; '*', ';' ',' or whitespace in a source
// would either broaden the policy or split the
// header. Registration already refuses these, so
// this is the second lock on the same door.
//
// Duplicates are collapsed so two URIs on one host produce one source, and the
// order registered is preserved so the header is stable and diffable.
func redirectOrigins(redirectURIs []string) []string {
seen := make(map[string]bool, len(redirectURIs))
out := make([]string, 0, len(redirectURIs))
for _, raw := range redirectURIs {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
continue
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
continue
}
// parsed.Host carries host and port together, which is exactly a CSP
// source's host-part. Empty means the URI was relative or malformed.
host := parsed.Host
if host == "" {
continue
}
origin := scheme + "://" + host
// Nothing that could broaden the policy or break the header out of its
// directive. A registered URI cannot contain these — validateRedirectURI
// rejects them — and this refuses to depend on that being true.
if strings.ContainsAny(origin, "*; ,\t\r\n'\"") {
continue
}
if seen[origin] {
continue
}
seen[origin] = true
out = append(out, origin)
}
return out
}