Documentation
¶
Index ¶
- Constants
- Variables
- func CSRFMiddleware(next http.Handler) http.Handler
- func DeviceIDFromContext(ctx context.Context) (string, bool)
- func ExtractBearerToken(r *http.Request) (token string, present bool, ok bool)
- func GenerateDeviceToken() (string, error)
- func GeneratePairingCode() string
- func HashCode(code string) []byte
- func HashToken(token string) []byte
- func IsLoopback(r *http.Request) bool
- func NewTCPAPIAuthMiddleware(signer *SessionSigner, store *Store) func(http.Handler) http.Handler
- func NewWebAuthMiddleware(signer *SessionSigner, store *Store) func(http.Handler) http.Handler
- func WithAuthMethod(ctx context.Context, method AuthMethod) context.Context
- func WithDeviceID(ctx context.Context, deviceID string) context.Context
- type AuthMethod
- type BearerVerifier
- type ConnectionRegistry
- type Device
- type DeviceInfo
- type PairRequest
- type PairResponse
- type PairingManager
- type RateLimiter
- type SessionSigner
- type Store
- func (s *Store) ConsumePairingCode(ctx context.Context, codeHash []byte) (label string, err error)
- func (s *Store) DeleteRevokedDevices(ctx context.Context, dryRun bool) (int64, error)
- func (s *Store) GetDevice(ctx context.Context, id string) (*Device, error)
- func (s *Store) GetDeviceByTokenHash(ctx context.Context, tokenHash []byte) (*Device, error)
- func (s *Store) HasAnyDevice(ctx context.Context) (bool, error)
- func (s *Store) InsertDevice(ctx context.Context, id, label string, cookieHash []byte) error
- func (s *Store) InsertDeviceToken(ctx context.Context, id, label string, tokenHash []byte) error
- func (s *Store) InsertPairingCode(ctx context.Context, codeHash []byte, label string, expiresAt time.Time) error
- func (s *Store) ListDevices(ctx context.Context) ([]*Device, error)
- func (s *Store) RevokeAllDevices(ctx context.Context) error
- func (s *Store) RevokeDevice(ctx context.Context, id string) error
- func (s *Store) UpdateDeviceLastSeen(ctx context.Context, id string, t time.Time) error
Constants ¶
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 ¶
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") )
var ErrInvalidSession = errors.New("invalid session")
Functions ¶
func CSRFMiddleware ¶
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 ¶
DeviceIDFromContext returns the authenticated device ID stored in ctx by NewWebAuthMiddleware. Returns ("", false) for unauthenticated requests.
func ExtractBearerToken ¶ added in v0.0.13
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
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 HashToken ¶ added in v0.0.13
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 ¶
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
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 ¶
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.
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 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
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
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
func (*Store) ConsumePairingCode ¶
func (*Store) DeleteRevokedDevices ¶
func (*Store) GetDevice ¶
GetDevice returns the device with the given id, or nil if not found or revoked.
func (*Store) GetDeviceByTokenHash ¶ added in v0.0.13
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) InsertDevice ¶
func (*Store) InsertDeviceToken ¶ added in v0.0.13
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).