mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
2f68bbe92e
commit
dfb9da3260
+133
-42
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user