347 lines
14 KiB
Go
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 · 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
|
|
}
|