oidc: the server becomes a relying party, and devices can carry an account
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 35s
server-release / release (push) Successful in 34s

Echolot delegates identity to whatever IdP the operator already runs and stores
no passwords - no hashing, no reset flow, no lockout policy, and no credential
database to lose. For a tool people self-host next to other services, that is
the difference between one more service and one more thing that can leak
someone's password.

Verification is stdlib-only, matching the server's no-dependency rule. Longer
than jwt.Parse, and auditable in one sitting. The part that matters is the
algorithm allow-list: taking `alg` from the token is the classic forgery, so it
is fixed in code. Tests cover the real attacks against a genuine signer - a
self-contained IdP with real keys, because a mock that returns success proves
nothing about a verifier:

  alg=none, HS256/RS256 confusion, a payload swapped under a valid signature,
  a token addressed to another client, a token from another issuer, expired
  and future-dated tokens, and discovery that renames the issuer (which would
  otherwise have us fetch a stranger's keys believing they were the provider's).

With no admin group configured nobody is an admin. An operator who has not said
who may administer the server has not thereby said "anyone who can log in".

Device and account stay separate concepts: enrollment admits a device (operator's
token), signing in attributes it to a person (POST /v1/account/link, device
credential plus ID token - both required, neither substitutes). uploads=account
now means what it says instead of refusing everyone, and signing in does not
override uploads=off.

The profile advertises the sign-in configuration so the app can offer the button
only when there is something behind it, and drive PKCE without anyone typing an
issuer URL. A discovery failure is reported rather than hidden, so "configured
but the provider is not answering" is distinguishable from "not configured".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 16:52:56 +02:00
co-authored by Claude Fable 5
parent 57a5ef8796
commit ce6d0c2f64
8 changed files with 1008 additions and 29 deletions
+14 -10
View File
@@ -38,10 +38,9 @@ const (
// ModeAnonymous accepts uploads from any enrolled device. The default: enrollment already
// required an admin-minted token, so "anyone enrolled" is not "anyone".
ModeAnonymous Mode = "anonymous"
// ModeAccount accepts uploads only from a device tied to a signed-in account. The account
// system (OIDC) is not built yet, so today this refuses everything with a distinct reason —
// it exists so operators can pick the strict setting now and have it mean the right thing
// when accounts land, rather than silently loosening on upgrade.
// ModeAccount accepts uploads only from a device where somebody has signed in (see
// /v1/account/link). Enrollment alone is not enough: the operator's token admits a device,
// an account attributes it to a person.
ModeAccount Mode = "account"
)
@@ -120,22 +119,27 @@ func Open(stateDir string, p Policy) (*Store, error) {
func (s *Store) Policy() Policy { return s.policy }
// Accepts reports whether an upload would be allowed at all, so callers can answer the
// capability question without a body.
func (s *Store) Accepts() error {
// Accepts reports whether an upload from this caller would be allowed at all, so callers can
// answer the capability question without a body.
//
// linked says whether a person has signed in on the uploading device. It is the only thing that
// distinguishes ModeAccount from ModeOff — and the reason the check takes an argument at all.
func (s *Store) Accepts(linked bool) error {
switch s.policy.Mode {
case ModeOff:
return ErrDisabled
case ModeAccount:
return ErrNeedAccount
if !linked {
return ErrNeedAccount
}
}
return nil
}
// Put validates and stores one uploaded document. body is the raw JSON as received: it is stored
// byte-for-byte so what the device signed off on is what sits on disk.
func (s *Store) Put(deviceID string, body []byte) (Meta, error) {
if err := s.Accepts(); err != nil {
func (s *Store) Put(deviceID string, body []byte, linked bool) (Meta, error) {
if err := s.Accepts(linked); err != nil {
return Meta{}, err
}
if s.policy.MaxBytes > 0 && int64(len(body)) > s.policy.MaxBytes {