auth

package
v0.0.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Index

Constants

View Source
const DeviceTokenPrefix = "boid_pat_"

DeviceTokenPrefix marks every Bearer device token minted by POST /api/auth/device (docs/plans/cli-remote-connection.md Phase 3 PR0 決定事項 8). It carries no security weight on its own — the token's entropy is the random bytes that follow — it exists purely so a token is recognizable at a glance (logs, `boid login` prompts) and distinguishable from a session cookie value or a pairing code.

Variables

View Source
var (
	ErrCodeNotFound   = errors.New("pairing code not found")
	ErrCodeExpired    = errors.New("pairing code expired")
	ErrCodeConsumed   = errors.New("pairing code already consumed")
	ErrDeviceNotFound = errors.New("device not found")
)
View Source
var ErrInvalidSession = errors.New("invalid session")

Functions

func CSRFMiddleware

func CSRFMiddleware(next http.Handler) http.Handler

CSRFMiddleware implements double-submit cookie CSRF protection.

GET/HEAD/OPTIONS/TRACE: issues csrf_token cookie if absent, then passes. POST/PUT/PATCH/DELETE: compares the cookie with the X-CSRF-Token header (for HTMX / fetch callers) or the _csrf form field (for plain HTML forms); 403 on mismatch.

Exempt paths:

  • /auth and /auth/* (protected by one-time pairing token)
  • /api/* (programmatic CLI access via UNIX socket)

func DeviceIDFromContext

func DeviceIDFromContext(ctx context.Context) (string, bool)

DeviceIDFromContext returns the authenticated device ID stored in ctx by NewWebAuthMiddleware. Returns ("", false) for unauthenticated requests.

func ExtractBearerToken added in v0.0.13

func ExtractBearerToken(r *http.Request) (token string, present bool, ok bool)

ExtractBearerToken parses an `Authorization: Bearer <token>` header on r. Returns:

  • token, true, true — a Bearer scheme header carrying a syntactically usable token (non-empty after trimming leading whitespace);
  • "", true, false — a Bearer scheme header that is malformed (missing token part, empty after trimming, etc.);
  • "", false, false — no Authorization header, or a header using a different scheme (Basic, Digest, …).

The three-way return exists so callers (NewTCPAPIAuthMiddleware, WSAttachHandler.authenticateDevice) can implement the plan doc's PR0 rule "an Authorization: Bearer header, when present, is a hard commitment to the Bearer path" — a present-but-malformed Bearer header must fail authentication outright, NOT silently fall back to cookie auth (which could resolve to a different device identity than the caller intended). Scheme matching is case-insensitive.

func GenerateDeviceToken added in v0.0.13

func GenerateDeviceToken() (string, error)

GenerateDeviceToken returns a new raw Bearer device token: DeviceTokenPrefix followed by 32 bytes of crypto/rand, URL-safe base64 (no padding) encoded. The raw token is handed back to the caller exactly once (the POST /api/auth/device response body) — only its HashToken hash is ever persisted (web_devices.token_hash).

func GeneratePairingCode

func GeneratePairingCode() string

func HashCode

func HashCode(code string) []byte

func HashToken added in v0.0.13

func HashToken(token string) []byte

HashToken returns the SHA-256 hash of a raw Bearer token — the value stored in web_devices.token_hash. Mirrors HashCode's role for pairing codes (pairing.go): the raw secret never touches the database, only its hash does.

func IsLoopback

func IsLoopback(r *http.Request) bool

IsLoopback reports whether the request came from a loopback address. UNIX domain socket connections (RemoteAddr == "" or "@") are also considered loopback since they are only reachable from the local machine.

If the request carries upstream-proxy headers (X-Forwarded-For, CF-Connecting-IP, Forwarded), the request is treated as NOT loopback even when RemoteAddr is 127.0.0.1 — because such a request arrived via a reverse proxy / tunnel (e.g. cloudflared forwarding to localhost:8080) and must not benefit from the bootstrap exemption granted to real local sessions. Header values themselves are not trusted (they can be spoofed); only the presence of the header is used as a "I came through a proxy" signal.

func NewTCPAPIAuthMiddleware added in v0.0.5

func NewTCPAPIAuthMiddleware(signer *SessionSigner, store *Store) func(http.Handler) http.Handler

NewTCPAPIAuthMiddleware guards the data/control JSON API when it is reached over the (potentially externally-exposed) TCP listener.

The UNIX socket is trusted: it is filesystem-permission protected and only reachable by the local user (CLI and in-sandbox agents dial it via BOID_SOCKET). It is served by the bare router WITHOUT this middleware, so CLI/agent access is never gated. This middleware is applied ONLY to the TCP listener's handler.

Over TCP, every /api/* path except the public ones (see apiAuthRequired) requires either a valid Bearer device token or a valid session cookie (docs/plans/cli-remote-connection.md Phase 3 PR0). An `Authorization: Bearer <token>` header, when present, is a hard commitment to that path: success or failure is decided on the Bearer token alone, with no fallback to the cookie check below even if a valid session cookie is also attached to the same request. This keeps the two paths' pass/fail semantics independent and makes the priority unambiguous (Bearer over cookie) rather than needing to reconcile which one "wins" when both are present. When no Bearer header is present at all, the logic below is byte-for-byte the pre-PR0 cookie/bootstrap behavior — see 決定事項 「既存 cookie 経路は 100% 温存」.

The loopback-bootstrap exemption (no cookie + genuine loopback + zero registered devices) is preserved so the Web UI is usable before the first device is paired. Requests that arrived via a reverse proxy / tunnel (IsLoopback==false because of proxy headers) get no bootstrap and must authenticate.

Failures return 401 JSON (not a 302 redirect to /login) because callers here are API clients. Non-/api paths (HTML pages, /login, /auth, /static) fall through to the router, which applies its own WebAuthMiddleware.

func NewWebAuthMiddleware

func NewWebAuthMiddleware(signer *SessionSigner, store *Store) func(http.Handler) http.Handler

NewWebAuthMiddleware returns middleware that enforces session cookie auth for web UI routes.

Exempt paths (pass through without any check): /login, /auth*, /static/*.

For non-exempt paths the logic is:

no cookie + loopback + no devices registered → warn + pass (bootstrap mode)
no cookie + anything else                    → 302 /login
invalid cookie                               → clear cookie + 302 /login
valid cookie                                 → pass (Verify updates last_seen, deviceID stored in ctx)

func WithAuthMethod added in v0.0.13

func WithAuthMethod(ctx context.Context, method AuthMethod) context.Context

WithAuthMethod returns ctx tagged with the auth method used to authenticate this request. Called by NewTCPAPIAuthMiddleware (bearer or cookie branch) and NewWebAuthMiddleware (cookie branch); handlers read it via AuthMethodFromContext.

func WithDeviceID

func WithDeviceID(ctx context.Context, deviceID string) context.Context

WithDeviceID returns ctx with deviceID embedded. Used by NewWebAuthMiddleware and in tests to inject a device identity without running the full middleware.

Types

type AuthMethod added in v0.0.13

type AuthMethod string

AuthMethod is the auth path a request came through, recorded in the context by NewTCPAPIAuthMiddleware and NewWebAuthMiddleware so handlers can enforce method-specific policy (e.g. DeviceAuthHandler.DeleteDevice only permits self-revoke for AuthMethodBearer callers).

const (
	AuthMethodBearer AuthMethod = "bearer"
	AuthMethodCookie AuthMethod = "cookie"
)

func AuthMethodFromContext added in v0.0.13

func AuthMethodFromContext(ctx context.Context) (AuthMethod, bool)

AuthMethodFromContext returns the AuthMethod tag stored in ctx by one of the auth middlewares. Returns ("", false) for unauthenticated requests or for authenticated requests that ran through a middleware that predates the tag (bootstrap loopback path — no device identity either).

type BearerVerifier added in v0.0.13

type BearerVerifier struct {
	// contains filtered or unexported fields
}

BearerVerifier validates a raw Bearer device token against web_devices — the Authorization-header counterpart to SessionSigner's cookie verification. Both converge on the same device identity model (a web_devices row, revocable via Store.RevokeDevice); see docs/plans/cli-remote-connection.md 決定事項: 「既存 web_devices テーブル を拡張 (別テーブル案は却下)」.

func NewBearerVerifier added in v0.0.13

func NewBearerVerifier(store *Store) *BearerVerifier

NewBearerVerifier builds a BearerVerifier backed by store. store must not be nil.

func (*BearerVerifier) Verify added in v0.0.13

func (v *BearerVerifier) Verify(r *http.Request) (string, error)

Verify extracts and hashes the Bearer token from r, looks up the owning device (revoked_at IS NULL only — Store.GetDeviceByTokenHash), and on success updates its last_seen_at exactly as SessionSigner.Verify does for cookies. Every failure mode (missing header, malformed header, unknown hash, revoked device) collapses to ErrInvalidSession so callers cannot distinguish "no token" from "bad token" from the error alone — the same posture SessionSigner.Verify takes for cookies.

type ConnectionRegistry

type ConnectionRegistry struct {
	// contains filtered or unexported fields
}

ConnectionRegistry maps device IDs to the set of revoke channels for active long-lived connections (SSE / WebSocket). Calling RevokeDevice closes all channels registered for that device, causing handlers to unblock and return.

func NewConnectionRegistry

func NewConnectionRegistry() *ConnectionRegistry

func (*ConnectionRegistry) Register

func (r *ConnectionRegistry) Register(deviceID string) (<-chan struct{}, func())

Register records a long-lived connection for deviceID and returns a channel that is closed when the device is revoked, plus a release function that must be deferred by the caller to clean up when the connection ends normally.

func (*ConnectionRegistry) RevokeAll

func (r *ConnectionRegistry) RevokeAll()

RevokeAll closes all revoke channels for every registered device.

func (*ConnectionRegistry) RevokeDevice

func (r *ConnectionRegistry) RevokeDevice(deviceID string)

RevokeDevice closes all revoke channels registered for deviceID.

type Device

type Device struct {
	ID         string
	Label      string
	CookieHash []byte
	CreatedAt  time.Time
	LastSeenAt time.Time
	RevokedAt  *time.Time
}

type DeviceInfo

type DeviceInfo struct {
	ID         string    `json:"id"`
	Label      string    `json:"label,omitempty"`
	LastSeenAt time.Time `json:"last_seen_at"`
	CreatedAt  time.Time `json:"created_at"`
}

DeviceInfo is the API response representation of a paired web device.

type PairRequest

type PairRequest struct {
	Label string `json:"label,omitempty"`
}

PairRequest is the body for POST /api/web/pair.

type PairResponse

type PairResponse struct {
	Code      string    `json:"code"`
	URL       string    `json:"url,omitempty"`
	ExpiresAt time.Time `json:"expires_at"`
}

PairResponse is returned by POST /api/web/pair.

type PairingManager

type PairingManager struct {
	// contains filtered or unexported fields
}

func NewPairingManager

func NewPairingManager(store *Store) *PairingManager

func (*PairingManager) Issue

func (m *PairingManager) Issue(ctx context.Context, label string) (string, error)

func (*PairingManager) Redeem

func (m *PairingManager) Redeem(ctx context.Context, code string) (string, error)

type RateLimiter

type RateLimiter struct {
	// contains filtered or unexported fields
}

func NewRateLimiter

func NewRateLimiter(now func() time.Time) *RateLimiter

func (*RateLimiter) Allow

func (rl *RateLimiter) Allow(ip string) bool

func (*RateLimiter) Allowed

func (rl *RateLimiter) Allowed(ip string) bool

Allowed reports whether ip is currently not rate-limited (read-only, no side effects).

func (*RateLimiter) RecordFailure

func (rl *RateLimiter) RecordFailure(ip string)

RecordFailure records a failed attempt for ip and locks it if the threshold is exceeded.

type SessionSigner

type SessionSigner struct {
	// contains filtered or unexported fields
}

func NewSessionSigner

func NewSessionSigner(secret []byte, store *Store) *SessionSigner

func (*SessionSigner) Clear

func (s *SessionSigner) Clear(w http.ResponseWriter)

func (*SessionSigner) Issue

func (s *SessionSigner) Issue(w http.ResponseWriter, deviceID string) error

func (*SessionSigner) Verify

func (s *SessionSigner) Verify(r *http.Request) (string, error)

type Store

type Store struct {
	// contains filtered or unexported fields
}

func NewStore

func NewStore(db *sql.DB) *Store

func (*Store) ConsumePairingCode

func (s *Store) ConsumePairingCode(ctx context.Context, codeHash []byte) (label string, err error)

func (*Store) DeleteRevokedDevices

func (s *Store) DeleteRevokedDevices(ctx context.Context, dryRun bool) (int64, error)

func (*Store) GetDevice

func (s *Store) GetDevice(ctx context.Context, id string) (*Device, error)

GetDevice returns the device with the given id, or nil if not found or revoked.

func (*Store) GetDeviceByTokenHash added in v0.0.13

func (s *Store) GetDeviceByTokenHash(ctx context.Context, tokenHash []byte) (*Device, error)

GetDeviceByTokenHash returns the device whose Bearer token hashes to tokenHash, or nil if not found or revoked. Symmetric with GetDevice's cookie-path lookup (same revoked_at IS NULL filter).

func (*Store) HasAnyDevice

func (s *Store) HasAnyDevice(ctx context.Context) (bool, error)

func (*Store) InsertDevice

func (s *Store) InsertDevice(ctx context.Context, id, label string, cookieHash []byte) error

func (*Store) InsertDeviceToken added in v0.0.13

func (s *Store) InsertDeviceToken(ctx context.Context, id, label string, tokenHash []byte) error

InsertDeviceToken creates a new device row authenticated via Bearer token instead of a session cookie (cookie_hash is left NULL — schema made this nullable in migration 0032). Used by POST /api/auth/device, the CLI pairing-code-redeem endpoint (docs/plans/cli-remote-connection.md Phase 3 PR0). Mirrors InsertDevice's shape exactly except for which hash column it populates; a device row created here has no cookie_hash and one created by InsertDevice has no token_hash — the two auth paths never overlap on the same row (decision: extend the existing table rather than add a second one, so a future PR could still attach both to one row if ever needed).

func (*Store) InsertPairingCode

func (s *Store) InsertPairingCode(ctx context.Context, codeHash []byte, label string, expiresAt time.Time) error

func (*Store) ListDevices

func (s *Store) ListDevices(ctx context.Context) ([]*Device, error)

func (*Store) RevokeAllDevices

func (s *Store) RevokeAllDevices(ctx context.Context) error

func (*Store) RevokeDevice

func (s *Store) RevokeDevice(ctx context.Context, id string) error

func (*Store) UpdateDeviceLastSeen

func (s *Store) UpdateDeviceLastSeen(ctx context.Context, id string, t time.Time) error

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL