oidc: the server becomes a relying party, and devices can carry an account
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:
co-authored by
Claude Fable 5
parent
57a5ef8796
commit
ce6d0c2f64
@@ -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 {
|
||||
|
||||
@@ -34,19 +34,33 @@ func TestModeOffRefusesEverything(t *testing.T) {
|
||||
p := DefaultPolicy()
|
||||
p.Mode = ModeOff
|
||||
s, _ := open(t, p)
|
||||
if _, err := s.Put("dev1", doc("run-1", AnonFull)); !errors.Is(err, ErrDisabled) {
|
||||
if _, err := s.Put("dev1", doc("run-1", AnonFull), false); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("want ErrDisabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ModeAccount must refuse today rather than fall back to anonymous: an operator who selects the
|
||||
// strict setting before accounts exist must not be silently running the permissive one.
|
||||
func TestModeAccountRefusesUntilAccountsExist(t *testing.T) {
|
||||
// ModeAccount turns on whether the *caller* has signed in, and nothing else. A device that has
|
||||
// not is refused with a reason it can act on; one that has is treated exactly like anonymous mode.
|
||||
func TestModeAccountTurnsOnWhetherTheCallerSignedIn(t *testing.T) {
|
||||
p := DefaultPolicy()
|
||||
p.Mode = ModeAccount
|
||||
s, _ := open(t, p)
|
||||
if _, err := s.Put("dev1", doc("run-1", AnonFull)); !errors.Is(err, ErrNeedAccount) {
|
||||
t.Fatalf("want ErrNeedAccount, got %v", err)
|
||||
|
||||
if _, err := s.Put("dev1", doc("run-1", AnonFull), false); !errors.Is(err, ErrNeedAccount) {
|
||||
t.Fatalf("an un-signed-in device was not refused: %v", err)
|
||||
}
|
||||
if _, err := s.Put("dev1", doc("run-2", AnonFull), true); err != nil {
|
||||
t.Fatalf("a signed-in device was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Signing in must not open a door that the operator closed outright: mode=off means off.
|
||||
func TestSigningInDoesNotOverrideModeOff(t *testing.T) {
|
||||
p := DefaultPolicy()
|
||||
p.Mode = ModeOff
|
||||
s, _ := open(t, p)
|
||||
if _, err := s.Put("dev1", doc("run-1", AnonFull), true); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("a signed-in device uploaded to a server with uploads off: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,15 +69,15 @@ func TestMinAnonymizationEnforced(t *testing.T) {
|
||||
p.MinAnonymization = AnonBalanced
|
||||
s, _ := open(t, p)
|
||||
|
||||
if _, err := s.Put("dev1", doc("run-full", AnonFull)); !errors.Is(err, ErrNotAnonEnough) {
|
||||
if _, err := s.Put("dev1", doc("run-full", AnonFull), false); !errors.Is(err, ErrNotAnonEnough) {
|
||||
t.Fatalf("full should be refused when balanced is required, got %v", err)
|
||||
}
|
||||
// An undeclared level means nothing was stripped, so it must be treated as "full".
|
||||
if _, err := s.Put("dev1", []byte(`{"run":{"id":"run-bare"},"summary":{}}`)); !errors.Is(err, ErrNotAnonEnough) {
|
||||
if _, err := s.Put("dev1", []byte(`{"run":{"id":"run-bare"},"summary":{}}`), false); !errors.Is(err, ErrNotAnonEnough) {
|
||||
t.Fatalf("undeclared level should be treated as full, got %v", err)
|
||||
}
|
||||
for _, lvl := range []string{AnonBalanced, AnonStrict} {
|
||||
if _, err := s.Put("dev1", doc("run-"+lvl, lvl)); err != nil {
|
||||
if _, err := s.Put("dev1", doc("run-"+lvl, lvl), false); err != nil {
|
||||
t.Fatalf("%s should be accepted: %v", lvl, err)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +88,7 @@ func TestSizeLimit(t *testing.T) {
|
||||
p.MaxBytes = 200
|
||||
s, _ := open(t, p)
|
||||
big := append(doc("run-1", AnonFull), make([]byte, 400)...)
|
||||
if _, err := s.Put("dev1", big); !errors.Is(err, ErrTooLarge) {
|
||||
if _, err := s.Put("dev1", big, false); !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("want ErrTooLarge, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -84,7 +98,7 @@ func TestRetentionByCountKeepsNewest(t *testing.T) {
|
||||
p.MaxRunsPerDevice = 3
|
||||
s, _ := open(t, p)
|
||||
for i := 0; i < 6; i++ {
|
||||
if _, err := s.Put("dev1", doc(fmt.Sprintf("run-%d", i), AnonFull)); err != nil {
|
||||
if _, err := s.Put("dev1", doc(fmt.Sprintf("run-%d", i), AnonFull), false); err != nil {
|
||||
t.Fatalf("put %d: %v", i, err)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond) // distinct UploadedAt so "newest" is well defined
|
||||
@@ -109,7 +123,7 @@ func TestRetentionByAge(t *testing.T) {
|
||||
p.RetentionDays = 7
|
||||
p.MaxRunsPerDevice = 0
|
||||
s, dir := open(t, p)
|
||||
if _, err := s.Put("dev1", doc("run-old", AnonFull)); err != nil {
|
||||
if _, err := s.Put("dev1", doc("run-old", AnonFull), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Backdate the index entry past the retention window.
|
||||
@@ -123,7 +137,7 @@ func TestRetentionByAge(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := s.Put("dev1", doc("run-new", AnonFull)); err != nil {
|
||||
if _, err := s.Put("dev1", doc("run-new", AnonFull), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := s.List("dev1")
|
||||
@@ -136,7 +150,7 @@ func TestRetentionByAge(t *testing.T) {
|
||||
// store directory or overwrite another device's data.
|
||||
func TestIDsCannotEscapeTheStoreDirectory(t *testing.T) {
|
||||
s, dir := open(t, DefaultPolicy())
|
||||
if _, err := s.Put("../../etc", doc("../../../passwd", AnonFull)); err != nil {
|
||||
if _, err := s.Put("../../etc", doc("../../../passwd", AnonFull), false); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
var found []string
|
||||
@@ -159,10 +173,10 @@ func TestIDsCannotEscapeTheStoreDirectory(t *testing.T) {
|
||||
|
||||
func TestListIsPerDevice(t *testing.T) {
|
||||
s, _ := open(t, DefaultPolicy())
|
||||
if _, err := s.Put("devA", doc("run-a", AnonFull)); err != nil {
|
||||
if _, err := s.Put("devA", doc("run-a", AnonFull), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Put("devB", doc("run-b", AnonFull)); err != nil {
|
||||
if _, err := s.Put("devB", doc("run-b", AnonFull), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.List("devA"); len(got) != 1 || got[0].ID != "run-a" {
|
||||
@@ -175,7 +189,7 @@ func TestListIsPerDevice(t *testing.T) {
|
||||
|
||||
func TestMetaSummarisesTheDocument(t *testing.T) {
|
||||
s, _ := open(t, DefaultPolicy())
|
||||
m, err := s.Put("dev1", doc("run-1", AnonBalanced))
|
||||
m, err := s.Put("dev1", doc("run-1", AnonBalanced), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -190,7 +204,7 @@ func TestMetaSummarisesTheDocument(t *testing.T) {
|
||||
func TestMalformedRejected(t *testing.T) {
|
||||
s, _ := open(t, DefaultPolicy())
|
||||
for _, body := range [][]byte{[]byte("not json"), []byte(`{"run":{}}`), []byte(`{}`)} {
|
||||
if _, err := s.Put("dev1", body); !errors.Is(err, ErrMalformed) {
|
||||
if _, err := s.Put("dev1", body, false); !errors.Is(err, ErrMalformed) {
|
||||
t.Fatalf("body %q: want ErrMalformed, got %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user