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
+116 -1
View File
@@ -7,6 +7,7 @@
package control
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
@@ -27,6 +28,7 @@ import (
"echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/oidc"
"echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/session"
"echo-lot.app/server/internal/store"
@@ -57,6 +59,8 @@ type Server struct {
// Granted server->client sends (spec §5). Both consume an asymmetric grant.
DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error)
BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error)
// OIDC verifies ID tokens when the operator has configured an issuer (may be nil).
OIDC *oidc.Verifier
// Runs stores uploaded measurement documents (may be nil: uploads unsupported).
Runs *runs.Store
// FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil:
@@ -162,6 +166,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /v1/runs", gate(s.listRuns))
mux.HandleFunc("GET /v1/runs/{id}", gate(s.getRun))
mux.HandleFunc("DELETE /v1/runs/{id}", gate(s.deleteRun))
mux.HandleFunc("POST /v1/account/link", gate(s.linkAccount))
mux.HandleFunc("DELETE /v1/account/link", gate(s.unlinkAccount))
mux.HandleFunc("GET /v1/account", gate(s.accountStatus))
// TODO(spec §5): frag_send, throughput (both build on the same grant machinery)
return mux
}
@@ -618,6 +625,10 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
// The app needs the upload rules before it offers the switch: whether uploads are
// accepted at all, and how much identifying detail it must strip first.
"uploads": s.uploadPolicy(),
// What a client needs to start a sign-in, without hard-coding the operator's IdP into
// the app: where to authorize, which client id to use, and whether it is worth offering
// sign-in at all on this server.
"auth": s.authInfo(r.Context()),
// What this build speaks, and which app versions it will serve. A client checks the
// server side of the same question against its own bounds.
"compat": map[string]any{
@@ -714,7 +725,7 @@ func (s *Server) uploadRun(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read failed"})
return
}
meta, err := s.Runs.Put(dev.ID, body)
meta, err := s.Runs.Put(dev.ID, body, dev.LinkedToAccount())
switch {
case err == nil:
slog.Info("run uploaded", "device", dev.ID, "run", meta.ID,
@@ -808,3 +819,107 @@ func upstreamJSON(sess *session.Session) map[string]any {
"span_ms": u.SpanMs(), "kbps": u.Kbps(),
}
}
// authInfo advertises the sign-in configuration, so the app can present a Sign in button only
// when there is something behind it, and can drive the flow without the user typing an issuer URL.
func (s *Server) authInfo(ctx context.Context) map[string]any {
if s.OIDC == nil || !s.OIDC.Config().Enabled() {
return map[string]any{"enabled": false}
}
cfg := s.OIDC.Config()
out := map[string]any{
"enabled": true,
"issuer": cfg.Issuer,
"client_id": cfg.ClientID,
// The app is a public client on a phone: no secret can be kept, so PKCE is what
// protects the code exchange (RFC 7636), and the redirect comes back through the
// scheme the app already registers for enrollment links.
"flow": "authorization_code+pkce",
"redirect_uri": "echolot://auth",
"scopes": "openid profile email",
}
if d, err := s.OIDC.Discover(ctx); err == nil {
out["authorization_endpoint"] = d.AuthorizationEndpoint
out["token_endpoint"] = d.TokenEndpoint
out["end_session_endpoint"] = d.EndSessionEndpoint
} else {
// Reported rather than hidden: an unreachable IdP is the operator's problem to see, and
// a client that knows the difference can say "sign-in is configured but the provider is
// not answering" instead of failing obscurely.
out["discovery_error"] = err.Error()
}
return out
}
// linkAccount ties the calling device to the person whose ID token it presents.
//
// The device credential proves *which device*; the ID token proves *which person*. Both are
// required, and neither substitutes for the other: enrollment admits a device to the server,
// signing in attributes it to someone.
func (s *Server) linkAccount(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
if s.OIDC == nil || !s.OIDC.Config().Enabled() {
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "this server has no identity provider configured, so there is nothing to sign in to",
})
return
}
var body struct {
IDToken string `json:"id_token"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.IDToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected an id_token"})
return
}
claims, err := s.OIDC.Verify(r.Context(), body.IDToken)
if err != nil {
// Deliberately terse to the client and detailed in the log: a caller probing token
// handling should not be told which check it failed.
slog.Info("rejected sign-in", "device", dev.ID, "err", err)
writeJSON(w, http.StatusForbidden, map[string]string{"error": "the identity token was not accepted"})
return
}
if err := s.Store.LinkAccount(dev.ID, claims.AccountID(), claims.Display()); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
slog.Info("device linked to account", "device", dev.ID, "account", claims.AccountID(),
"admin", s.OIDC.IsAdmin(claims))
writeJSON(w, http.StatusOK, map[string]any{
"account_id": claims.AccountID(), "display_name": claims.Display(),
"admin": s.OIDC.IsAdmin(claims),
})
}
// unlinkAccount signs out on this device. The device stays enrolled: signing out should not cost
// someone their enrollment, which an operator had to grant.
func (s *Server) unlinkAccount(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
if err := s.Store.LinkAccount(dev.ID, "", ""); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) accountStatus(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"signed_in": dev.LinkedToAccount(),
"account_id": dev.AccountID,
"display_name": dev.AccountName,
"device_id": dev.ID,
})
}