feat(auth): one approval link for device sign-in, and a page that says what it grants (#83)

The headless flow printed a short code to retype into a bare "Approve"
form. Now `bdrive login --device` prints a single link — the token lives in
the path (/auth/device/<token>), so there is nothing to read off one screen
and type into another.

The page it opens is a consent page rather than a text field: it names the
account the device would act as, offers Switch account (logout now honors
?next, so you land back here), and shows the device name, OS, and the
address the server observed. That matters because this flow's weakness is a
stranger sending you their pending link; an anonymous "Approve" gives you
nothing to notice with. Approval is still a POST from the page, so a link
alone cannot grant, and SameSite=Lax keeps a cross-site form out.

Also aligns the /auth/* pages with the app's tokens, which had drifted:
card #0c0e10 vs --color-card #15171b, 8px controls vs --radius-ctl 7px,
hand-picked #ff9b91/#6fd699 vs --color-del/--color-add. The style block now
declares the tw.css tokens by name and every rule uses them.

Older CLIs still print /auth/device?code=…, so that shape 303s to the path
form; a pre-0.13 hub returning no verify_url still gets the old
type-the-code instruction from the CLI.


Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-30 10:41:27 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 2f68bbe92e
commit dfb9da3260
11 changed files with 198 additions and 70 deletions
+133 -42
View File
@@ -82,6 +82,8 @@ type pendingGrant struct {
kind string // "code" (CLI callback), "device" (poll flow), "reset"
user string // set once granted
device string // device flow: requested device name
os string // device flow: requested device's OS
ip string // device flow: where the request came from, as the server saw it
granted bool
expires time.Time
}
@@ -516,8 +518,9 @@ func (a *BuiltinAuth) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /auth/signup", a.pageSignup)
mux.HandleFunc("GET /auth/logout", a.pageLogout)
mux.HandleFunc("GET /auth/cli", a.pageCLI)
mux.HandleFunc("GET /auth/device", a.pageDevice)
mux.HandleFunc("POST /auth/device", a.pageDevice)
mux.HandleFunc("GET /auth/device/{token}", a.pageDevice)
mux.HandleFunc("POST /auth/device/{token}", a.pageDevice)
mux.HandleFunc("GET /auth/device", a.pageDeviceLegacy)
mux.HandleFunc("GET /auth/verify", a.pageVerify)
mux.HandleFunc("GET /auth/reset", a.pageReset)
mux.HandleFunc("POST /auth/reset", a.pageReset)
@@ -607,31 +610,51 @@ func authPage(w http.ResponseWriter, title, body string) {
fmt.Fprintf(w, `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>%s — BearDrive</title>
<style>
/* Shares the app's token values so sign-in and the app read as one product. */
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",sans-serif;
background:#0a0b0d;color:#eef0f3;display:flex;justify-content:center;padding-top:13vh;margin:0;
/* The app's tokens, name for name, so sign-in and the app read as one
product. Source of truth: frontend/src/tw.css @theme — keep the values
here identical to the token of the same name there. */
:root{--bg:#0a0b0d;--raise:#15171b;--surface:rgba(255,255,255,.03);--hovered:rgba(255,255,255,.06);
--line:rgba(255,255,255,.07);--line-2:rgba(255,255,255,.11);--text:#eef0f3;--dim:#9aa0a9;--faint:#868b93;
--honey:#f5a623;--honey-bright:#ffcf85;--on-honey:#1a1204;--add:#4cc38a;--del:#f26d6d;
--radius-ctl:7px;--radius-over:14px;
--mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace}
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
background:var(--bg);color:var(--text);display:flex;justify-content:center;padding:13vh 16px;margin:0;
letter-spacing:-.006em;-webkit-font-smoothing:antialiased}
.card{background:#0c0e10;border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:28px 30px;width:344px;
box-shadow:0 24px 70px -24px rgba(0,0,0,.7)}
.logo{width:30px;height:30px;display:grid;place-items:center;color:#f5a623;margin-bottom:16px}
.card{background:var(--raise);border:1px solid var(--line);border-radius:var(--radius-over);padding:28px 30px;
width:344px;max-width:100%%;box-sizing:border-box;box-shadow:0 24px 70px -24px rgba(0,0,0,.7)}
.logo{width:30px;height:30px;display:grid;place-items:center;color:var(--honey);margin-bottom:16px}
.logo svg{width:30px;height:30px;fill:currentColor}
h1{font-size:18px;font-weight:640;letter-spacing:-.02em;margin:0 0 18px}
label{display:block;font-size:12px;color:#9aa0a9;margin:14px 0 5px;font-weight:500}
input{width:100%%;box-sizing:border-box;height:38px;padding:0 12px;border-radius:8px;
border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.03);color:#eef0f3;font:inherit;font-size:14px;outline:none}
input:focus-visible{outline:2px solid #f5a623;outline-offset:1px;border-color:#f5a623}
button{margin-top:20px;width:100%%;height:40px;border:none;border-radius:8px;background:#f5a623;
color:#241704;font:inherit;font-size:14px;font-weight:600;cursor:pointer}
button:hover{background:#ffcf85}
button:focus-visible{outline:2px solid #ffcf85;outline-offset:2px}
.err{color:#ff9b91;font-size:13px;margin:12px 0 0}
.msg{color:#6fd699;font-size:13px;margin:12px 0 0}
.alt{margin-top:16px;font-size:12.5px;color:#868b93}
.alt a{color:#ffcf85;text-decoration:none}
label{display:block;font-size:12px;color:var(--dim);margin:14px 0 5px;font-weight:500}
input{width:100%%;box-sizing:border-box;height:38px;padding:0 12px;border-radius:var(--radius-ctl);
border:1px solid var(--line-2);background:var(--surface);color:var(--text);font:inherit;font-size:14px;outline:none}
input:focus-visible{outline:2px solid var(--honey);outline-offset:1px;border-color:var(--honey)}
button{margin-top:20px;width:100%%;height:40px;border:none;border-radius:var(--radius-ctl);background:var(--honey);
color:var(--on-honey);font:inherit;font-size:14px;font-weight:600;cursor:pointer}
button:hover{background:var(--honey-bright)}
button:focus-visible{outline:2px solid var(--honey-bright);outline-offset:2px}
.err{color:var(--del);font-size:13px;margin:12px 0 0}
.msg{color:var(--add);font-size:13px;margin:12px 0 0}
.lede{margin:0;color:var(--dim);font-size:13px}
.alt{margin-top:16px;font-size:12.5px;color:var(--faint)}
.alt a{color:var(--honey-bright);text-decoration:none}
.alt a:hover{text-decoration:underline}
/* Device approval: who you'd be granting as, then what is asking. */
.who{display:flex;align-items:center;gap:12px;justify-content:space-between;margin:16px 0 4px;
padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-ctl);background:var(--surface)}
.who-id{min-width:0}
.who-l{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--faint)}
.who-id b{display:block;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.who-sub{display:block;font-size:12px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.who-swap{flex:none;font-size:12.5px;color:var(--honey-bright);text-decoration:none}
.who-swap:hover{text-decoration:underline}
.rows{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;margin:14px 0 0;font-size:13px}
.rows dt{color:var(--faint)}
.rows dd{margin:0;font-family:var(--mono);font-size:12.5px;overflow-wrap:anywhere}
@media (max-width:900px){input{height:44px}button{height:44px}}
code{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);padding:2px 6px;border-radius:5px;
font-family:ui-monospace,Menlo,monospace}
code{background:var(--hovered);border:1px solid var(--line);padding:2px 6px;border-radius:5px;
font-family:var(--mono)}
</style></head><body><div class="card"><div class="logo"><svg viewBox="0 0 32 32" role="img" aria-label="BearDrive">` +
`<rect x="4" y="4" width="5.6" height="24"/><rect x="11.2" y="4" width="14.4" height="11.2"/>` +
`<rect x="11.2" y="16.8" width="16.8" height="11.2"/></svg></div><h1>%s</h1>%s</div></body></html>`,
@@ -699,7 +722,7 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
if a.AllowSignup || invited {
note := ""
if a.AllowSignup && len(a.AllowedDomains) > 0 {
note = ` <span style="color:#868b93">(` + html.EscapeString(a.domainList()) + ` only)</span>`
note = ` <span style="color:var(--faint)">(` + html.EscapeString(a.domainList()) + ` only)</span>`
}
label := "No account?"
if invited && !a.AllowSignup {
@@ -709,7 +732,7 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
}
brand := ""
if a.Brand != "" {
brand = `<p class="alt" style="margin:0 0 14px;color:#9aa0a9">` + html.EscapeString(a.Brand) + `</p>`
brand = `<p class="alt" style="margin:0 0 14px;color:var(--dim)">` + html.EscapeString(a.Brand) + `</p>`
}
authPage(w, "Sign in", brand+inviteBanner(next)+fmt.Sprintf(`<form method="post" action="/auth/login?next=%s">%s%s%s<button>Sign in</button></form>
%s<p class="alt"><a href="/auth/reset">Forgot password?</a></p>`,
@@ -775,7 +798,7 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
}
brand := ""
if a.Brand != "" {
brand = `<p class="alt" style="margin:0 0 14px;color:#9aa0a9">` + html.EscapeString(a.Brand) + `</p>`
brand = `<p class="alt" style="margin:0 0 14px;color:var(--dim)">` + html.EscapeString(a.Brand) + `</p>`
}
authPage(w, "Create account", brand+inviteBanner(next)+fmt.Sprintf(`<form method="post" action="/auth/signup?next=%s">%s%s%s%s%s<button>Sign up</button></form>
<p class="alt">Have an account? <a href="/auth/login?next=%s">Sign in</a></p>`,
@@ -787,12 +810,19 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
errMsg, url.QueryEscape(next)))
}
// pageLogout ends the browser session. It honors ?next= so "switch account"
// on a page that needed one (device approval, an invite) lands back there as
// the new account instead of dumping the visitor at the hub root.
func (a *BuiltinAuth) pageLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil {
a.revokeToken(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
dest := "/auth/login"
if next := safeNext(r.FormValue("next")); next != "/" {
dest += "?next=" + url.QueryEscape(next)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
// pageCLI completes `bdrive login`: once the browser has a session, mint a
@@ -819,27 +849,74 @@ func (a *BuiltinAuth) pageCLI(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, u.String(), http.StatusSeeOther)
}
// pageDevice is the headless-login approval page: the user types the code
// `bdrive login` printed.
// pageDevice is the headless-login approval page, reached by opening the link
// `bdrive login` printed: the token lives in the path, so there is no code to
// read off one screen and type into another. It names the account the device
// will act as (with a way to switch), and what is asking, because approving
// hands that machine a token that acts as you.
func (a *BuiltinAuth) pageDevice(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
user, ok := a.sessionUser(r)
if !ok {
http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
var msg string
g, ok := a.peekGrant("device", token)
if !ok {
authPage(w, "Link expired", `<p class="err">This sign-in link is invalid, already used, or older than 10 minutes.</p>
<p class="alt">Run <code>bdrive login --device</code> again for a fresh one.</p>`)
return
}
if r.Method == http.MethodPost {
code := strings.ToLower(strings.TrimSpace(r.FormValue("code")))
if a.grantDevice(code, user.ID) {
authPage(w, "Device connected", `<p class="msg">Done — you can close this tab. The terminal will finish logging in.</p>`)
if !a.grantDevice(token, user.ID) {
authPage(w, "Link expired", `<p class="err">This sign-in link expired while the page was open.</p>
<p class="alt">Run <code>bdrive login --device</code> again for a fresh one.</p>`)
return
}
msg = `<p class="err">Unknown or expired code.</p>`
authPage(w, "Device connected", fmt.Sprintf(`<p class="msg">%s can now sync as %s.</p>
<p class="alt">You can close this tab — the terminal finishes on its own.</p>`,
html.EscapeString(orDash(g.device)), html.EscapeString(user.Email)))
return
}
code := r.URL.Query().Get("code")
authPage(w, "Connect a device", fmt.Sprintf(`<p>Enter the code shown by <code>bdrive login</code>:</p>
<form method="post">%s%s<button>Approve</button></form>`,
field("Code", "code", "text", code), msg))
authPage(w, "Connect a device", fmt.Sprintf(`<p class="lede">A device is asking to sign in to BearDrive.</p>
%s
<form method="post"><button>Approve</button></form>
<p class="alt">Approve this only if you just started a sign-in on that machine.</p>`,
whoBlock(user, g, r.URL.RequestURI())))
}
// whoBlock renders the two things the approver needs: who they'd be granting
// as (with an escape hatch back to this same page), and what is asking.
func whoBlock(user User, g pendingGrant, back string) string {
name := user.Name
if name == "" {
name = user.Email
}
return fmt.Sprintf(`<div class="who">
<div class="who-id"><span class="who-l">Signing in as</span><b>%s</b><span class="who-sub">%s</span></div>
<a class="who-swap" href="/auth/logout?next=%s">Switch account</a>
</div>
<dl class="rows"><dt>Device</dt><dd>%s</dd><dt>System</dt><dd>%s</dd><dt>Address</dt><dd>%s</dd></dl>`,
html.EscapeString(name), html.EscapeString(user.Email), url.QueryEscape(safeNext(back)),
html.EscapeString(orDash(g.device)), html.EscapeString(orDash(g.os)), html.EscapeString(orDash(g.ip)))
}
func orDash(s string) string {
if strings.TrimSpace(s) == "" {
return "—"
}
return s
}
// pageDeviceLegacy forwards the pre-0.13 link shape (/auth/device?code=…),
// which older CLIs still print, to the path form.
func (a *BuiltinAuth) pageDeviceLegacy(w http.ResponseWriter, r *http.Request) {
code := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("code")))
if code == "" {
authPage(w, "Connect a device", `<p>Run <code>bdrive login --device</code> on the machine you want to connect; it prints a link to open here.</p>`)
return
}
http.Redirect(w, r, "/auth/device/"+url.PathEscape(code), http.StatusSeeOther)
}
// pageVerify activates an account from an email link, then either starts a
@@ -963,23 +1040,37 @@ func (a *BuiltinAuth) apiExchange(w http.ResponseWriter, r *http.Request) {
a.finishLogin(w, g.user, req.Device)
}
// apiDeviceStart begins the headless flow: the CLI shows the code, the user
// approves it at /auth/device, the CLI polls.
// apiDeviceStart begins the headless flow: the CLI prints the approval link,
// the user opens it in any signed-in browser, the CLI polls. The link itself
// is the secret (RFC 8628 calls this verification_uri_complete), so it is a
// full-length token, not something short enough to retype — nobody has to
// read a code off one screen and type it into another.
//
// The requesting device's name, OS, and address are recorded here so the
// approval page can show WHAT is being approved: this flow's weakness is that
// a stranger can send you their own pending link, and a page that just says
// "Approve" gives you nothing to notice with.
func (a *BuiltinAuth) apiDeviceStart(w http.ResponseWriter, r *http.Request) {
var req struct {
Device string `json:"device"`
OS string `json:"os"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
code := randHex(4) // short enough to type
code := randHex(16)
a.mu.Lock()
a.pending[code] = pendingGrant{kind: "device", device: req.Device, expires: time.Now().Add(10 * time.Minute)}
a.pending[code] = pendingGrant{
kind: "device", device: req.Device, os: req.OS, ip: requestIP(r),
expires: time.Now().Add(10 * time.Minute),
}
a.mu.Unlock()
writeJSON(w, map[string]any{
// "code" keeps its wire name: it is what the CLI polls with, and
// older clients still print it.
"code": code,
"verify_url": requestBaseURL(r) + "/auth/device?code=" + code,
"verify_url": requestBaseURL(r) + "/auth/device/" + code,
"interval": 2,
})
}