Files
beardrive/internal/webapp/authlocal.go
T
b1c0bba415 feat(auth): the browser sign-in says whose account the terminal gets (#96)
* feat(auth): ask before signing a terminal in as whoever the browser is

`bdrive login` opened /auth/cli and the browser bounced straight back with a
code. Whoever the browser happened to be signed in as is who the terminal
became — silently. That is frequently not the account the user meant: a
personal login left open in the default browser, a teammate's session on a
shared machine. The mistake surfaces much later, as a synced folder full of
commits authored by the wrong person, which is far more work to undo than one
click would have been.

The device flow already got this right in #83 — it names the account, offers
to switch, and says what approving grants. The browser flow said nothing at
all, for the same outcome: a token that acts as you.

So /auth/cli now confirms first. GET renders the page (who you would be
signing in as, a Switch account link that comes back to this same pending
sign-in, what is asking, and where it is waiting); POST is what mints the
code and redirects to the loopback listener. A GET therefore grants nothing,
so a link someone else got you to open can no longer mint a code on your
behalf.

whoBlock loses its pendingGrant parameter and renders only the identity half.
What is asking differs per flow — a device has a name and an OS, a CLI on this
computer has a loopback port — so each page now renders its own rows through a
small helper instead of whoBlock pretending to a shape neither quite fits.

The CLI's own wording follows: "waiting for you to approve the sign-in in your
browser", since being signed in already is no longer the whole story.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* docs: the browser sign-in confirms first, and says whose account it grants

README, the CLI reference, and the self-hosting auth page all described the
old behaviour — sign in and the page bounces a code straight to the terminal.
They also read as though only `--device` had an approval step. Both flows now
confirm; say so, and say why it matters (the browser session is often not the
account the user meant).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* feat(auth): one web step for a first sign-in, not two

The confirmation page fixed the wrong-account problem and created a smaller
one: a user with no browser session now saw two pages on their first
`bdrive init` — sign in, then approve — where the sign-in had already settled
the only question the second page asks.

So authenticating *for* a pending CLI sign-in now counts as approving it. The
login and signup pages carry a line saying a terminal is waiting and that the
account used here is the one it will act as, which is where that consent is
made informed; reaching the callback then needs no second click.

The marker is server-side, bound to the exact pending sign-in, single use, and
two minutes long, so it can only ever skip the page it was granted for and only
once. It cannot be forged: setting it requires authenticating as that account,
and anyone who could do that could click Approve anyway.

An existing session still gets the page — that is the case where the browser
may be signed in as someone the user did not intend, which is the whole reason
it exists. Net effect: exactly one web interaction either way.

The device flow keeps its explicit approval. Its page names a machine that
isn't this one, along with the OS and address it came from — information no
login form can convey, about a grant to somewhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(auth): keep `bdrive login` on one line in the approval hint

It wrapped mid-phrase into two separate code boxes, which reads as two
commands rather than one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* refactor(auth): one approval page for both sign-in flows

The two flows ask the same question — shall this thing act as you? — and had
two copies of the page asking it, differing in three strings. They had already
started drifting: a wrapping fix went into the CLI copy only, leaving the
device page able to break `bdrive login --device` across two code boxes. A
page whose whole purpose is consistent disclosure is a bad place to keep two
of everything.

So pageAuth owns the shape (session check, redirect to login, whoBlock, rows,
the Approve form, the note) and each flow supplies an authRequest describing
what differs: how the request is identified, what is asking, and what
approving does.

Two asymmetries are now explicit rather than accidental. freshAuthSkips is
true only for the local flow — signing in and approving are the same act when
the terminal is on this machine, and are not when the token goes to another
one. live() reports whether the request still exists, because the device
flow's link expires while the CLI flow carries its whole request in the URL
and has nothing to expire.

detail is a function, not a slice: the device rows come off the pending grant,
which only exists after live() has found it.

No test changed. The pages render byte-identically — same sha256 for all three
CLI screenshots before and after — and the device flow was driven end to end
against a real hub, approving a real `bdrive login --device`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* feat(auth): both sign-in flows always ask you to approve

Consistency between the two flows is worth more than the click it saves.
Letting a sign-in count as its own approval made the local flow one step and
the device flow two, so the same product asked for consent in two different
shapes depending on which machine you were on — and the shape that skipped it
was the one where the page had something to tell you.

So the fresh-auth marker is gone: sign in, then approve, on both flows. That
drops a map, two methods, a descriptor field, and a branch in pageAuth — the
unified handler now has exactly one path through it.

A first `bdrive init` on a fresh machine is two web pages again. That is the
deliberate trade: the approval page is where a user sees which account a
machine is about to act as, and nothing shortcuts it.

The sign-in page keeps the line saying a terminal is waiting. It no longer
carries the consent — the next page does — so it is there to explain why a
password prompt appeared at all.

TestBothFlowsAlwaysAskToApprove replaces the one-step test and runs the same
assertions over both flows as subtests: no session sends you to sign in
carrying the request, signing in returns to the request without granting, the
approval page is there every time, and only the POST grants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:14:13 +09:00

1235 lines
45 KiB
Go

package webapp
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// BuiltinAuth is the open-source identity provider: email + password + name
// accounts and long-lived device tokens, persisted in one JSON file (loaded
// at open, rewritten atomically on every change — same discipline as the
// project registry). It owns the /auth/* pages the browser sees and the
// /api/auth/* endpoints the CLI uses.
type BuiltinAuth struct {
AllowSignup bool
Mail *Mailer // nil → reset links go to the server log
// Public-URL signup gating (all optional; set after Open). A hub reachable
// from the internet should use at least one of these.
AllowedDomains []string // if non-empty, signup email domain must match one
RequireVerification bool // new accounts must click an email link before activation
RequireApproval bool // new accounts wait for an admin to approve them
Admins map[string]bool // hub admins (lowercase emails): approve users, govern shares
Brand string // optional name shown on the sign-in page
// InviteValid, when set, reports whether a token is a live org invite.
// It lets an invite link bootstrap an account on an invite-only hub
// (AllowSignup false) — the one path in without self-signup. Wired to
// OrgDB.ValidInvite by the server. Nil → no invite-based signup.
InviteValid func(token string) bool
store AccountRepo
mu sync.Mutex
users map[string]*authUser // by id
tokens map[string]authToken // by sha256(token)
// Ephemeral single-use state; a server restart just cancels pending
// logins and resets.
pending map[string]pendingGrant // auth codes, device codes, reset tokens
}
type authUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Pass string `json:"pass"` // bcrypt hash
Status string `json:"status,omitempty"`
Created time.Time `json:"created"`
}
// Account status. Empty is treated as active so accounts created before
// gating existed keep working.
const (
statusActive = "active"
statusUnverified = "unverified" // awaiting email verification
statusPending = "pending" // verified (or verification off) but awaiting admin approval
)
func (u *authUser) active() bool { return u.Status == "" || u.Status == statusActive }
type authToken struct {
Hash string `json:"hash"` // sha256 of the token; plaintext is never stored
User string `json:"user"`
Device string `json:"device"`
Created time.Time `json:"created"`
}
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
}
// NewBuiltinAuth builds the account service over an AccountRepo, loading its
// accounts, tokens, and persisted policy.
func NewBuiltinAuth(store AccountRepo, allowSignup bool, mail *Mailer) (*BuiltinAuth, error) {
a := &BuiltinAuth{
AllowSignup: allowSignup, Mail: mail, store: store,
users: make(map[string]*authUser),
tokens: make(map[string]authToken),
pending: make(map[string]pendingGrant),
}
users, tokens, policy, err := store.Load()
if err != nil {
return nil, err
}
for _, u := range users {
a.users[u.ID] = u
}
for _, t := range tokens {
a.tokens[t.Hash] = t
}
// A UI-saved policy is the persisted operational default; the server
// config can still override it at startup (see web.go), so a sysadmin who
// pins a value in the config file always wins over a browser toggle.
if policy != nil {
a.RequireVerification = policy.RequireVerification
a.RequireApproval = policy.RequireApproval
}
return a, nil
}
// OpenBuiltinAuth loads (or starts) the file-backed account registry at path.
func OpenBuiltinAuth(path string, allowSignup bool, mail *Mailer) (*BuiltinAuth, error) {
return NewBuiltinAuth(newFileAccountRepo(path), allowSignup, mail)
}
// authPolicy is the UI-tunable slice of gating (persisted in auth.json).
// Domain allowlist and the admin list are intentionally NOT here — they are
// security-critical identity config owned by whoever controls the server,
// not something a browser session should be able to widen.
type authPolicy struct {
RequireVerification bool `json:"require_verification"`
RequireApproval bool `json:"require_approval"`
}
// SetPolicy updates the tunable gating toggles and persists them.
func (a *BuiltinAuth) SetPolicy(requireVerification, requireApproval bool) error {
a.mu.Lock()
defer a.mu.Unlock()
a.RequireVerification = requireVerification
a.RequireApproval = requireApproval
return a.store.PutPolicy(authPolicy{RequireVerification: requireVerification, RequireApproval: requireApproval})
}
func randHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
func hashToken(tok string) string {
sum := sha256.Sum256([]byte(tok))
return hex.EncodeToString(sum[:])
}
// ---- account + token operations ----
func (a *BuiltinAuth) findByEmail(email string) *authUser {
for _, u := range a.users {
if strings.EqualFold(u.Email, email) {
return u
}
}
return nil
}
// signup creates a self-service account, subject to the domain allowlist and
// starting in the state the gating policy dictates.
func (a *BuiltinAuth) signup(email, name, password string) (*authUser, error) {
return a.createAccount(email, name, password, false)
}
// signupInvited creates an account from a valid invite link. An invite is an
// explicit grant by an owner, so it is the vetting: the domain allowlist and
// the approval/verification gates are bypassed and the account is active. The
// caller must have already checked the invite token is live.
func (a *BuiltinAuth) signupInvited(email, name, password string) (*authUser, error) {
return a.createAccount(email, name, password, true)
}
func (a *BuiltinAuth) createAccount(email, name, password string, viaInvite bool) (*authUser, error) {
email = strings.TrimSpace(strings.ToLower(email))
name = strings.TrimSpace(name)
if email == "" || !strings.Contains(email, "@") {
return nil, fmt.Errorf("a valid email is required")
}
if !viaInvite && !a.domainAllowed(email) {
return nil, fmt.Errorf("this server only accepts %s email addresses", a.domainList())
}
if name == "" {
return nil, fmt.Errorf("a name is required")
}
if len(password) < 8 {
return nil, fmt.Errorf("password must be at least 8 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
status := a.initialStatus()
// An invite is an owner's explicit grant; an email on the config's admin
// list is the operator's own. Both are stronger vetting than the signup
// gates, so these accounts activate immediately — otherwise a fresh
// approval-gated hub would strand its first admin as pending forever.
if viaInvite || a.isAdmin(email) {
status = statusActive
}
a.mu.Lock()
defer a.mu.Unlock()
if a.findByEmail(email) != nil {
return nil, fmt.Errorf("an account with this email already exists")
}
u := &authUser{
ID: "u-" + randHex(4), Email: email, Name: name,
Pass: string(hash), Status: status, Created: time.Now().UTC(),
}
a.users[u.ID] = u
if err := a.store.PutAccount(u); err != nil {
delete(a.users, u.ID)
return nil, err
}
return u, nil
}
// initialStatus is the account state a new signup starts in, given the
// server's gating config: verify first, else approve first, else active.
func (a *BuiltinAuth) initialStatus() string {
switch {
case a.RequireVerification:
return statusUnverified
case a.RequireApproval:
return statusPending
default:
return statusActive
}
}
// ValidateSignupPolicy rejects incoherent signup configurations at startup so
// a hub is never accidentally left open to fake-email signups. The three
// supported postures are: invite-only (AllowSignup false — the default),
// approval-gated, and domain-restricted with email verification.
//
// - Open self-signup must carry at least one gate (allowed domains, admin
// approval, or email verification). Without one, anyone can register any
// address — the exact hole this guards.
// - Email verification needs a mailer: without SMTP the link only reaches
// the server log, so it can't actually gate real users.
func (a *BuiltinAuth) ValidateSignupPolicy() error {
if a.RequireVerification && a.Mail == nil {
return fmt.Errorf("auth: require_verification needs an smtp mailer — without one the verification link only reaches the server log; configure auth.smtp or turn verification off")
}
if a.AllowSignup && len(a.AllowedDomains) == 0 && !a.RequireApproval && !a.RequireVerification {
return fmt.Errorf("auth: open self-signup has no gate, so anyone could register any email — set allow_signup:false (invite-only, the default), or add allowed_domains, require_approval, or require_verification")
}
return nil
}
// afterVerify is the state a just-verified account moves to.
func (a *BuiltinAuth) afterVerify() string {
if a.RequireApproval {
return statusPending
}
return statusActive
}
func emailDomain(email string) string {
if i := strings.LastIndex(email, "@"); i >= 0 {
return strings.ToLower(email[i+1:])
}
return ""
}
func (a *BuiltinAuth) domainAllowed(email string) bool {
if len(a.AllowedDomains) == 0 {
return true
}
d := emailDomain(email)
for _, allowed := range a.AllowedDomains {
if strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(allowed), "@"), d) {
return true
}
}
return false
}
func (a *BuiltinAuth) domainList() string {
parts := make([]string, len(a.AllowedDomains))
for i, d := range a.AllowedDomains {
parts[i] = "@" + strings.TrimPrefix(strings.TrimSpace(d), "@")
}
return strings.Join(parts, ", ")
}
func (a *BuiltinAuth) isAdmin(email string) bool {
return a.Admins != nil && a.Admins[normEmail(email)]
}
func (a *BuiltinAuth) accountCount() int {
a.mu.Lock()
defer a.mu.Unlock()
return len(a.users)
}
func (a *BuiltinAuth) verifyPassword(email, password string) *authUser {
a.mu.Lock()
u := a.findByEmail(email)
a.mu.Unlock()
if u == nil {
// burn comparable time so missing accounts aren't detectable
bcrypt.CompareHashAndPassword([]byte("$2a$10$0000000000000000000000000000000000000000000000000000"), []byte(password))
return nil
}
if bcrypt.CompareHashAndPassword([]byte(u.Pass), []byte(password)) != nil {
return nil
}
return u
}
// issueToken mints a device token for the user and persists its hash. The
// plaintext is returned exactly once.
func (a *BuiltinAuth) issueToken(userID, device string) (string, error) {
tok := "bdt_" + randHex(20)
a.mu.Lock()
defer a.mu.Unlock()
t := authToken{Hash: hashToken(tok), User: userID, Device: device, Created: time.Now().UTC()}
a.tokens[t.Hash] = t
if err := a.store.PutToken(t); err != nil {
delete(a.tokens, t.Hash)
return "", err
}
return tok, nil
}
func (a *BuiltinAuth) revokeToken(tok string) {
a.mu.Lock()
defer a.mu.Unlock()
if _, ok := a.tokens[hashToken(tok)]; ok {
delete(a.tokens, hashToken(tok))
a.store.DeleteToken(hashToken(tok))
}
}
func (a *BuiltinAuth) userForToken(tok string) (User, bool) {
a.mu.Lock()
defer a.mu.Unlock()
t, ok := a.tokens[hashToken(tok)]
if !ok {
return User{}, false
}
u, ok := a.users[t.User]
if !ok || !u.active() {
return User{}, false
}
return User{ID: u.ID, Email: u.Email, Name: u.Name, Admin: a.isAdmin(u.Email)}, true
}
// sendVerification emails (or logs) a verification link for the account.
func (a *BuiltinAuth) sendVerification(r *http.Request, u *authUser) {
tok := a.newGrant("verify", u.ID, "", true, 24*time.Hour)
link := requestBaseURL(r) + "/auth/verify?token=" + tok
subject := "Verify your BearDrive account"
body := "Confirm your email to activate your BearDrive account:\n\n " + link +
"\n\nThis link is valid for 24 hours. If you didn't sign up, ignore this email."
if a.Mail == nil {
fmt.Printf("verification link for %s:\n %s\n", u.Email, link)
return
}
if err := a.Mail.Send(u.Email, subject, body); err != nil {
fmt.Printf("verification link for %s (email not sent: %v):\n %s\n", u.Email, err, link)
}
}
// grant helpers: single-use codes with expiry.
func (a *BuiltinAuth) newGrant(kind, user, device string, granted bool, ttl time.Duration) string {
id := randHex(16)
a.mu.Lock()
a.pending[id] = pendingGrant{kind: kind, user: user, device: device, granted: granted, expires: time.Now().Add(ttl)}
a.mu.Unlock()
return id
}
func (a *BuiltinAuth) takeGrant(kind, id string) (pendingGrant, bool) {
a.mu.Lock()
defer a.mu.Unlock()
g, ok := a.pending[id]
if !ok || g.kind != kind || time.Now().After(g.expires) {
delete(a.pending, id)
return pendingGrant{}, false
}
delete(a.pending, id)
return g, true
}
// peekGrant reads without consuming (device polling).
func (a *BuiltinAuth) peekGrant(kind, id string) (pendingGrant, bool) {
a.mu.Lock()
defer a.mu.Unlock()
g, ok := a.pending[id]
if !ok || g.kind != kind || time.Now().After(g.expires) {
return pendingGrant{}, false
}
return g, true
}
func (a *BuiltinAuth) grantDevice(id, userID string) bool {
a.mu.Lock()
defer a.mu.Unlock()
g, ok := a.pending[id]
if !ok || g.kind != "device" || time.Now().After(g.expires) {
return false
}
g.user, g.granted = userID, true
a.pending[id] = g
return true
}
// Branding is the hub name this provider renders on its own pages.
func (a *BuiltinAuth) Branding() string { return a.Brand }
// Policy reports this provider's signup gates (webapp.AccountApprover). The
// provider assembles it so the hub never reaches into these fields itself.
func (a *BuiltinAuth) Policy() SignupPolicy {
a.mu.Lock()
defer a.mu.Unlock()
admins := make([]string, 0, len(a.Admins))
for e := range a.Admins {
admins = append(admins, e)
}
sort.Strings(admins)
return SignupPolicy{
RequireVerification: a.RequireVerification,
RequireApproval: a.RequireApproval,
AllowSignup: a.AllowSignup,
AllowedDomains: a.AllowedDomains,
Admins: admins,
Mailer: a.Mail != nil,
}
}
// PendingUsers lists accounts awaiting admin approval, oldest first.
func (a *BuiltinAuth) PendingUsers() []User {
a.mu.Lock()
defer a.mu.Unlock()
var us []*authUser
for _, u := range a.users {
if u.Status == statusPending {
us = append(us, u)
}
}
sort.Slice(us, func(i, j int) bool { return us[i].Created.Before(us[j].Created) })
out := make([]User, len(us))
for i, u := range us {
out[i] = User{ID: u.ID, Email: u.Email, Name: u.Name}
}
return out
}
// Approve activates a pending account.
func (a *BuiltinAuth) Approve(id string) error {
a.mu.Lock()
defer a.mu.Unlock()
u, ok := a.users[id]
if !ok {
return fmt.Errorf("no such account")
}
u.Status = statusActive
return a.store.PutAccount(u)
}
// Deny removes a pending account.
func (a *BuiltinAuth) Deny(id string) error {
a.mu.Lock()
defer a.mu.Unlock()
if _, ok := a.users[id]; !ok {
return fmt.Errorf("no such account")
}
delete(a.users, id)
return a.store.DeleteAccount(id)
}
// Accounts returns every account, oldest first (used by the org migration
// to pick the default org's owner).
func (a *BuiltinAuth) Accounts() []User {
a.mu.Lock()
defer a.mu.Unlock()
users := make([]*authUser, 0, len(a.users))
for _, u := range a.users {
if u.active() {
users = append(users, u)
}
}
sort.Slice(users, func(i, j int) bool { return users[i].Created.Before(users[j].Created) })
out := make([]User, len(users))
for i, u := range users {
out[i] = User{ID: u.ID, Email: u.Email, Name: u.Name}
}
return out
}
// ---- AuthProvider ----
const sessionCookie = "bdrive_session"
func (a *BuiltinAuth) CLILoginPath() string { return "/auth/cli" }
func (a *BuiltinAuth) Authenticate(r *http.Request) (User, bool) {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return a.userForToken(strings.TrimPrefix(h, "Bearer "))
}
if c, err := r.Cookie(sessionCookie); err == nil {
return a.userForToken(c.Value)
}
return User{}, false
}
func (a *BuiltinAuth) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /auth/login", a.pageLogin)
mux.HandleFunc("POST /auth/login", a.pageLogin)
mux.HandleFunc("GET /auth/signup", a.pageSignup)
mux.HandleFunc("POST /auth/signup", a.pageSignup)
mux.HandleFunc("GET /auth/logout", a.pageLogout)
mux.HandleFunc("GET /auth/cli", a.pageCLI)
mux.HandleFunc("POST /auth/cli", a.pageCLI)
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)
mux.HandleFunc("GET /auth/reset/confirm", a.pageResetConfirm)
mux.HandleFunc("POST /auth/reset/confirm", a.pageResetConfirm)
mux.HandleFunc("POST /api/auth/exchange", a.apiExchange)
mux.HandleFunc("POST /api/auth/device/start", a.apiDeviceStart)
mux.HandleFunc("POST /api/auth/device/poll", a.apiDevicePoll)
mux.HandleFunc("GET /api/auth/me", a.apiMe)
}
// sessionUser resolves the browser session (cookie only, not Bearer).
func (a *BuiltinAuth) sessionUser(r *http.Request) (User, bool) {
if c, err := r.Cookie(sessionCookie); err == nil {
return a.userForToken(c.Value)
}
return User{}, false
}
// cliSignIn reports whether a next URL is a pending CLI sign-in.
func cliSignIn(next string) bool { return strings.HasPrefix(next, "/auth/cli?") }
func (a *BuiltinAuth) startSession(w http.ResponseWriter, userID string) error {
tok, err := a.issueToken(userID, "web-session")
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: tok, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
return nil
}
// inviteBanner shows an invitation cue when the post-login destination is a
// join link, so a visitor who clicked an invite knows why they're here.
func inviteBanner(next string) string {
if !strings.Contains(next, "join/") && !strings.Contains(next, "join%2F") {
return ""
}
return `<p class="msg" style="margin:0 0 14px">You've been invited to a team. Sign in (or sign up) to accept.</p>`
}
// cliBanner says what a sign-in reached from `bdrive login` is for, so the form
// is not a bare password prompt appearing for no visible reason. Approving is
// still its own step on the next page — this only explains why signing in is
// being asked for at all.
func cliBanner(next string) string {
if !cliSignIn(next) {
return ""
}
return `<p class="msg" style="margin:0 0 14px">A terminal on this computer is waiting to sign in. ` +
`The account you use here is the one it will act as.</p>`
}
// safeNext keeps post-login redirects on this site.
func safeNext(next string) string {
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
return "/"
}
return next
}
// inviteTokenFromNext pulls an org-invite token out of a post-login target
// like "/join/<token>". Tokens are lowercase hex.
func inviteTokenFromNext(next string) string {
const marker = "/join/"
i := strings.Index(next, marker)
if i < 0 {
return ""
}
tok := next[i+len(marker):]
if j := strings.IndexAny(tok, "/?&#"); j >= 0 {
tok = tok[:j]
}
if tok == "" {
return ""
}
for _, c := range tok {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return ""
}
}
return tok
}
// invitedVia returns the invite token in next when it points at a live invite,
// so the login/signup pages can let an invitee create an account even on an
// invite-only hub. Empty string when there's no valid invite.
func (a *BuiltinAuth) invitedVia(next string) string {
tok := inviteTokenFromNext(next)
if tok != "" && a.InviteValid != nil && a.InviteValid(tok) {
return tok
}
return ""
}
// ---- pages ----
func authPage(w http.ResponseWriter, title, body string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
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>
/* 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: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: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: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>`,
html.EscapeString(title), html.EscapeString(title), body)
}
func field(label, name, typ, value string) string {
return fmt.Sprintf(`<label for="f-%s">%s</label><input id="f-%s" name=%q type=%q value=%q autocomplete=%q required>`,
name, html.EscapeString(label), name, name, typ, html.EscapeString(value), autocompleteFor(name, typ))
}
// autocompleteFor names a field's purpose so browsers and password managers
// fill it (WCAG 1.3.5). A password field's purpose depends on the page, not
// its name: offering a saved credential on a signup form is worse than
// offering nothing, so those callers use newPasswordField.
func autocompleteFor(name, typ string) string {
switch name {
case "email":
return "email"
case "name":
return "name"
case "code":
return "one-time-code"
}
if typ == "password" {
return "current-password"
}
return "on"
}
// newPasswordField is field() for a password being CREATED (signup, reset),
// so a manager generates one instead of filling an existing login.
func newPasswordField(label, name string) string {
return fmt.Sprintf(`<label for="f-%s">%s</label><input id="f-%s" name=%q type="password" autocomplete="new-password" required>`,
name, html.EscapeString(label), name, name)
}
func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.FormValue("next"))
var errMsg string
if r.Method == http.MethodPost {
if u := a.verifyPassword(r.FormValue("email"), r.FormValue("password")); u != nil {
switch u.Status {
case statusUnverified:
a.sendVerification(r, u)
errMsg = `<p class="err">Please verify your email first — we've re-sent the link.</p>`
case statusPending:
errMsg = `<p class="err">Your account is still awaiting administrator approval.</p>`
default:
if err := a.startSession(w, u.ID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
} else {
errMsg = `<p class="err">Wrong email or password.</p>`
}
}
// Offer account creation when public signup is open, or when the visitor
// arrived through a valid invite (the way into an invite-only hub).
signup := ""
invited := a.invitedVia(next) != ""
if a.AllowSignup || invited {
note := ""
if a.AllowSignup && len(a.AllowedDomains) > 0 {
note = ` <span style="color:var(--faint)">(` + html.EscapeString(a.domainList()) + ` only)</span>`
}
label := "No account?"
if invited && !a.AllowSignup {
label = "New here?"
}
signup = fmt.Sprintf(`<p class="alt">%s <a href="/auth/signup?next=%s">Sign up</a>%s</p>`, label, url.QueryEscape(next), note)
}
brand := ""
if a.Brand != "" {
brand = `<p class="alt" style="margin:0 0 14px;color:var(--dim)">` + html.EscapeString(a.Brand) + `</p>`
}
authPage(w, "Sign in", brand+inviteBanner(next)+cliBanner(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>`,
url.QueryEscape(next),
field("Email", "email", "email", r.FormValue("email")),
field("Password", "password", "password", ""),
errMsg, signup))
}
func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.FormValue("next"))
// An invite link authorizes account creation even when public self-signup
// is closed — it's the only way into an invite-only hub. Except once: a
// brand-new hub has no accounts to mint an invite with, so until the
// first account exists, the emails on the config's admin list may sign
// up directly (the operator wrote them there — that's the vetting).
inviteTok := a.invitedVia(next)
bootstrap := !a.AllowSignup && inviteTok == "" && len(a.Admins) > 0 && a.accountCount() == 0
if !a.AllowSignup && inviteTok == "" && !bootstrap {
authPage(w, "Sign up disabled", `<p>This server is invite-only. Ask a team owner for an invite link, or sign in if you already have an account.</p>
<p class="alt"><a href="/auth/login">Back to sign in</a></p>`)
return
}
var errMsg string
if r.Method == http.MethodPost {
signup := a.signup
if inviteTok != "" {
signup = a.signupInvited // invite is the vetting: skip gates, activate
}
if bootstrap && !a.isAdmin(r.FormValue("email")) {
signup = func(string, string, string) (*authUser, error) {
return nil, fmt.Errorf("this server is invite-only; only a hub admin can create the first account")
}
}
u, err := signup(r.FormValue("email"), r.FormValue("name"), r.FormValue("password"))
if err == nil {
switch u.Status {
case statusUnverified:
a.sendVerification(r, u)
authPage(w, "Verify your email", `<p class="msg">Almost there — we sent a verification link to <b>`+
html.EscapeString(u.Email)+`</b>.</p><p class="alt">Click it to activate your account. No email on this server? The link is in the server log.</p>`)
return
case statusPending:
authPage(w, "Awaiting approval", `<p class="msg">Thanks — your account was created and is waiting for an administrator to approve it.</p>
<p class="alt">You'll be able to sign in once it's approved.</p>`)
return
}
if err := a.startSession(w, u.ID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
errMsg = `<p class="err">` + html.EscapeString(err.Error()) + `</p>`
}
// State the domain restriction up front, where the stranger types their
// email — not only after a rejected submit. An invite bypasses the domain
// allowlist, so don't show it when arriving through one.
domainNote := ""
if inviteTok == "" && len(a.AllowedDomains) > 0 {
domainNote = `<p class="alt" style="margin:2px 0 0">Only ` + html.EscapeString(a.domainList()) + ` email addresses can sign up here.</p>`
}
brand := ""
if a.Brand != "" {
brand = `<p class="alt" style="margin:0 0 14px;color:var(--dim)">` + html.EscapeString(a.Brand) + `</p>`
}
authPage(w, "Create account", brand+inviteBanner(next)+cliBanner(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>`,
url.QueryEscape(next),
field("Name", "name", "text", r.FormValue("name")),
field("Email", "email", "email", r.FormValue("email")),
domainNote,
newPasswordField("Password (min 8 chars)", "password"),
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})
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`: confirm who the terminal will act as, then
// mint a one-time code and bounce it to the CLI's loopback listener. Redirects
// are restricted to loopback addresses so the code can't be sent anywhere else.
//
// The confirmation is the point, not ceremony. Whoever the browser happens to
// be signed in as is who the terminal becomes, and that is frequently not the
// account the user meant — a personal login left open, a teammate's session on
// a shared machine. Granting silently means the mistake surfaces later, as a
// synced folder full of commits authored by the wrong person, which is far
// more work to undo than one click now.
//
// It also means a GET no longer grants anything, so a link someone else got
// you to open can't mint a code on your behalf.
// authRequest describes a pending sign-in to pageAuth. Both flows ask the user
// the same question — "shall this thing act as you?" — and differ only in how
// the request is identified, what is asking, and what approving does. Keeping
// that difference in data rather than in two copies of the page is what stops
// the two from drifting apart, which matters here: a flow whose disclosure
// quietly falls behind the other's is the failure mode this page exists to
// prevent.
type authRequest struct {
title string // heading
lede string // one line naming what is asking (plain text)
note string // when approving is the right call — trusted markup
// detail is what is asking, in detail. A function because the device flow
// reads it off the pending grant, which only exists once live() has found
// it — so it must be evaluated at render time, not at call time.
detail func() [][2]string
// live, when set, runs once the session is known and before anything is
// shown or granted, reporting whether the request still exists — having
// already written its own explanation when it doesn't. The device flow's
// link expires; the CLI flow carries its whole request in the URL and has
// nothing to expire.
live func() bool
// approve performs the grant and writes the response.
approve func(user User)
}
// pageAuth is the approval page both sign-in flows share.
func (a *BuiltinAuth) pageAuth(w http.ResponseWriter, r *http.Request, req authRequest) {
user, ok := a.sessionUser(r)
if !ok {
http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
if req.live != nil && !req.live() {
return
}
if r.Method == http.MethodPost {
req.approve(user)
return
}
authPage(w, req.title, fmt.Sprintf(`<p class="lede">%s</p>
%s%s
<form method="post"><button>Approve</button></form>
<p class="alt">%s</p>`,
html.EscapeString(req.lede), whoBlock(user, r.URL.RequestURI()), rows(req.detail()...), req.note))
}
func (a *BuiltinAuth) pageCLI(w http.ResponseWriter, r *http.Request) {
u, err := url.Parse(r.URL.Query().Get("redirect"))
if err != nil || (u.Scheme != "http") || (u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" && u.Hostname() != "::1") {
http.Error(w, "invalid redirect (must be a loopback URL)", http.StatusBadRequest)
return
}
a.pageAuth(w, r, authRequest{
title: "Sign in on this computer",
lede: "A terminal on this computer is asking to sign in to BearDrive.",
detail: func() [][2]string {
return [][2]string{{"Application", "bdrive command line"}, {"Waiting at", u.Host}}
},
note: `Approve this only if you just ran ` +
`<code style="white-space:nowrap">bdrive login</code> yourself.`,
approve: func(user User) {
code := a.newGrant("code", user.ID, "", true, time.Minute)
q := u.Query()
q.Set("code", code)
q.Set("state", r.URL.Query().Get("state"))
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusSeeOther)
},
})
}
// 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.
//
// Unlike the local flow this one never skips the page. The machine being
// granted is not the one reading this, so the account, the device name, its OS
// and its address are the only things standing between an approval and a
// stranger's pending link.
func (a *BuiltinAuth) pageDevice(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
var g pendingGrant
expired := func(when string) {
authPage(w, "Link expired", `<p class="err">This sign-in link `+when+`.</p>
<p class="alt">Run <code style="white-space:nowrap">bdrive login --device</code> again for a fresh one.</p>`)
}
a.pageAuth(w, r, authRequest{
title: "Connect a device",
lede: "A device is asking to sign in to BearDrive.",
note: "Approve this only if you just started a sign-in on that machine.",
detail: func() [][2]string {
return [][2]string{{"Device", g.device}, {"System", g.os}, {"Address", g.ip}}
},
live: func() bool {
var ok bool
if g, ok = a.peekGrant("device", token); !ok {
expired("is invalid, already used, or older than 10 minutes")
return false
}
return true
},
approve: func(user User) {
if !a.grantDevice(token, user.ID) {
expired("expired while the page was open")
return
}
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)))
},
})
}
// whoBlock renders who the approver would be granting as, with an escape hatch
// back to this same page. Approving on either flow hands a machine a token
// that acts as you, so "as whom" is the question worth answering loudest — and
// the browser's session is often not the account the user meant to use.
//
// What is asking differs per flow (a device has a name and an OS; a CLI on
// this computer has a loopback port), so each page renders its own rows rather
// than this pretending to a shape neither quite fits.
func whoBlock(user User, 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>`,
html.EscapeString(name), html.EscapeString(user.Email), url.QueryEscape(safeNext(back)))
}
// rows renders a label/value list, dashing out the blanks.
func rows(pairs ...[2]string) string {
var b strings.Builder
b.WriteString(`<dl class="rows">`)
for _, p := range pairs {
fmt.Fprintf(&b, "<dt>%s</dt><dd>%s</dd>",
html.EscapeString(p[0]), html.EscapeString(orDash(p[1])))
}
b.WriteString(`</dl>`)
return b.String()
}
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
// session (or explains it's now awaiting approval).
func (a *BuiltinAuth) pageVerify(w http.ResponseWriter, r *http.Request) {
g, ok := a.takeGrant("verify", r.URL.Query().Get("token"))
if !ok {
authPage(w, "Link expired", `<p class="err">This verification link is invalid or expired.</p>
<p class="alt"><a href="/auth/login">Back to sign in</a></p>`)
return
}
a.mu.Lock()
u := a.users[g.user]
next := a.afterVerify()
if u != nil && u.Status == statusUnverified {
u.Status = next
a.store.PutAccount(u)
}
a.mu.Unlock()
if u != nil && u.Status == statusPending {
authPage(w, "Email verified", `<p class="msg">Your email is verified. Your account is now waiting for an administrator to approve it.</p>`)
return
}
if u != nil {
if err := a.startSession(w, u.ID); err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
authPage(w, "Email verified", `<p class="msg">Your email is verified.</p><p class="alt"><a href="/auth/login">Sign in</a></p>`)
}
func (a *BuiltinAuth) pageReset(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
a.mu.Lock()
u := a.findByEmail(email)
a.mu.Unlock()
if u != nil {
tok := a.newGrant("reset", u.ID, "", true, time.Hour)
link := requestBaseURL(r) + "/auth/reset/confirm?token=" + tok
subject := "Reset your BearDrive password"
body := "Someone (hopefully you) asked to reset the BearDrive password for " + u.Email +
".\n\nReset it here (valid for 1 hour):\n\n " + link + "\n\nIf this wasn't you, ignore this email."
if err := a.Mail.Send(u.Email, subject, body); err != nil {
// Never break reset: the admin can hand over the logged link.
fmt.Printf("password reset for %s (email not sent: %v):\n %s\n", u.Email, err, link)
}
}
authPage(w, "Check your email", `<p class="msg">If that account exists, a reset link is on its way.</p>
<p class="alt">No email configured on this server? The link is in the server log.</p>`)
return
}
authPage(w, "Reset password", fmt.Sprintf(`<form method="post">%s<button>Send reset link</button></form>
<p class="alt"><a href="/auth/login">Back to sign in</a></p>`,
field("Email", "email", "email", "")))
}
func (a *BuiltinAuth) pageResetConfirm(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
tok, password := r.FormValue("token"), r.FormValue("password")
if len(password) < 8 {
authPage(w, "Set a new password", resetForm(tok, `<p class="err">Password must be at least 8 characters.</p>`))
return
}
g, ok := a.takeGrant("reset", tok)
if !ok {
authPage(w, "Link expired", `<p class="err">This reset link is invalid or expired.</p>
<p class="alt"><a href="/auth/reset">Request a new one</a></p>`)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
a.mu.Lock()
if u := a.users[g.user]; u != nil {
u.Pass = string(hash)
a.store.PutAccount(u)
}
a.mu.Unlock()
authPage(w, "Password updated", `<p class="msg">Your password is updated.</p>
<p class="alt"><a href="/auth/login">Sign in</a></p>`)
return
}
authPage(w, "Set a new password", resetForm(r.URL.Query().Get("token"), ""))
}
func resetForm(token, msg string) string {
return fmt.Sprintf(`<form method="post"><input type="hidden" name="token" value=%q>%s%s<button>Set password</button></form>`,
html.EscapeString(token), newPasswordField("New password (min 8 chars)", "password"), msg)
}
func requestBaseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host
}
// ---- CLI API ----
// apiExchange trades the one-time code from the browser redirect for a
// long-lived device token.
func (a *BuiltinAuth) apiExchange(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
Device string `json:"device"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
g, ok := a.takeGrant("code", req.Code)
if !ok || !g.granted {
http.Error(w, "invalid or expired code", http.StatusUnauthorized)
return
}
a.finishLogin(w, g.user, req.Device)
}
// 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(16)
a.mu.Lock()
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,
"interval": 2,
})
}
func (a *BuiltinAuth) apiDevicePoll(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
Device string `json:"device"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
g, ok := a.peekGrant("device", req.Code)
if !ok {
http.Error(w, "invalid or expired code", http.StatusUnauthorized)
return
}
if !g.granted {
writeJSON(w, map[string]any{"pending": true})
return
}
a.takeGrant("device", req.Code)
device := req.Device
if device == "" {
device = g.device
}
a.finishLogin(w, g.user, device)
}
func (a *BuiltinAuth) finishLogin(w http.ResponseWriter, userID, device string) {
if device == "" {
device = "cli"
}
tok, err := a.issueToken(userID, device)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
a.mu.Lock()
u := a.users[userID]
a.mu.Unlock()
if u == nil {
http.Error(w, "unknown user", http.StatusUnauthorized)
return
}
writeJSON(w, map[string]any{
"token": tok,
"user": User{ID: u.ID, Email: u.Email, Name: u.Name},
})
}
func (a *BuiltinAuth) apiMe(w http.ResponseWriter, r *http.Request) {
u, ok := a.Authenticate(r)
if !ok {
http.Error(w, "not signed in", http.StatusUnauthorized)
return
}
writeJSON(w, u)
}