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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user