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
+23
View File
@@ -41,6 +41,7 @@ import (
"echo-lot.app/server/internal/config"
"echo-lot.app/server/internal/control"
"echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/oidc"
"echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/selftest"
"echo-lot.app/server/internal/selfupdate"
@@ -150,6 +151,27 @@ func serve(cfg *config.Config) error {
slog.Info("client compatibility", "accepts_app", appRange.String(),
"protocol", control.ProtocolVersion, "schema", control.SchemaVersion)
// Identity is optional. Without an issuer the server simply has no sign-in, and
// uploads=account can never be satisfied — which is the honest outcome, not a silent
// downgrade to anonymous.
var idp *oidc.Verifier
if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
idp = oidc.New(oidc.Config{
Issuer: cfg.OIDCIssuer,
ClientID: cfg.OIDCClientID,
AdminGroup: cfg.OIDCAdminGroup,
}, nil)
slog.Info("identity provider configured", "issuer", cfg.OIDCIssuer,
"client_id", cfg.OIDCClientID, "admin_group", cfg.OIDCAdminGroup)
if cfg.OIDCAdminGroup == "" {
slog.Warn("no admin group set: nobody will be an admin via OIDC " +
"(set ECHOLOT_OIDC_ADMIN_GROUP)")
}
} else if cfg.UploadsMode == string(runs.ModeAccount) {
slog.Warn("uploads=account but no identity provider is configured — " +
"every upload will be refused")
}
ctl := &control.Server{
Store: st, Sessions: sessions, Name: cfg.Name,
UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)),
@@ -161,6 +183,7 @@ func serve(cfg *config.Config) error {
Runs: runStore,
AppRange: appRange,
PublicControlURL: publicControlURL(cfg),
OIDC: idp,
}
// Left nil when there is no raw socket, so the handler answers "not implemented" with a
// reason rather than failing somewhere deeper.
+9
View File
@@ -67,6 +67,12 @@ type Config struct {
// first control listen address.
PublicControlURL string // ECHOLOT_PUBLIC_URL / --public-url
// Identity provider. Empty issuer disables sign-in entirely; the server is a relying
// party and never stores passwords.
OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer
OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id
OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group
// Mode
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
}
@@ -115,6 +121,9 @@ func Load(args []string) (*Config, *Actions, error) {
fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables")
fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables")
fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict")
fs.StringVar(&c.OIDCIssuer, "oidc-issuer", envOr("OIDC_ISSUER", ""), "OpenID Connect issuer URL; empty disables sign-in")
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "OpenID Connect client id for this server")
fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC")
fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443")
fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")
+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,
})
}
+475
View File
@@ -0,0 +1,475 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package oidc verifies OpenID Connect ID tokens against a configured issuer.
//
// Echolot is a *relying party*, never an identity provider. It delegates to whatever IdP the
// operator already runs and stores no passwords — no hashing, no reset flow, no lockout policy,
// and no credential database to leak. For a tool people self-host on a box they also use for
// other things, that is the difference between "one more service" and "one more thing that can
// lose your users' passwords".
//
// Verification is written against the stdlib rather than a JWT library, because the server has no
// external dependencies by design. That is a real constraint and it cuts both ways: the code below
// is longer than `jwt.Parse`, but it is also auditable in one sitting and cannot be broken by
// somebody else's release. The algorithm allow-list is the part that matters — accepting `alg`
// from the token itself is the classic JWT forgery, so it is fixed here and `none` can never
// appear.
package oidc
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
)
// Claims are the parts of an ID token Echolot acts on.
type Claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience audience `json:"aud"`
Expiry int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
Nonce string `json:"nonce"`
Email string `json:"email"`
Name string `json:"name"`
Username string `json:"preferred_username"`
Groups []string `json:"groups"`
}
// AccountID is the stable identity of a person: issuer plus subject.
//
// Subject alone is not enough — it is only unique within an issuer — and email is not stable,
// since people change them and IdPs allow reuse. Keying on iss+sub means an operator can switch
// IdPs and know that the accounts did not silently merge.
func (c Claims) AccountID() string { return c.Issuer + "#" + c.Subject }
// Display is the friendliest name available, for the admin UI.
func (c Claims) Display() string {
for _, s := range []string{c.Name, c.Username, c.Email} {
if s != "" {
return s
}
}
return c.Subject
}
// audience tolerates the spec's two shapes: a string or an array of strings.
type audience []string
func (a *audience) UnmarshalJSON(b []byte) error {
var one string
if err := json.Unmarshal(b, &one); err == nil {
*a = audience{one}
return nil
}
var many []string
if err := json.Unmarshal(b, &many); err != nil {
return err
}
*a = many
return nil
}
func (a audience) contains(s string) bool {
for _, v := range a {
if v == s {
return true
}
}
return false
}
// Config is what the operator supplies.
type Config struct {
// Issuer is the IdP's base URL, e.g. https://auth.example.net/application/o/echolot/
Issuer string
// ClientID is this server's registered client. Tokens must be addressed to it.
ClientID string
// AdminGroup, when set, is the group claim a person must hold to reach the admin UI.
// Empty means no one is an admin via OIDC, which is the safe default: an operator who has
// not said who may administer the server has not said "everyone".
AdminGroup string
// Skew tolerated on exp/iat, for ordinary clock drift between the IdP and this server.
Skew time.Duration
}
func (c Config) Enabled() bool { return c.Issuer != "" && c.ClientID != "" }
// Discovery is the subset of the provider metadata document that is used.
type Discovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
EndSessionEndpoint string `json:"end_session_endpoint"`
}
// Verifier fetches provider metadata and keys, and checks tokens against them.
type Verifier struct {
cfg Config
client *http.Client
mu sync.RWMutex
discovery *Discovery
keys map[string]crypto.PublicKey
keysAt time.Time
}
func New(cfg Config, client *http.Client) *Verifier {
if cfg.Skew == 0 {
cfg.Skew = 2 * time.Minute
}
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
return &Verifier{cfg: cfg, client: client, keys: map[string]crypto.PublicKey{}}
}
func (v *Verifier) Config() Config { return v.cfg }
var (
ErrDisabled = errors.New("no OIDC issuer is configured on this server")
ErrMalformed = errors.New("token is not a well-formed JWT")
ErrSignature = errors.New("token signature does not verify")
ErrClaims = errors.New("token claims are not acceptable")
)
// Discover fetches (and caches) the provider metadata.
func (v *Verifier) Discover(ctx context.Context) (*Discovery, error) {
if !v.cfg.Enabled() {
return nil, ErrDisabled
}
v.mu.RLock()
d := v.discovery
v.mu.RUnlock()
if d != nil {
return d, nil
}
url := strings.TrimRight(v.cfg.Issuer, "/") + "/.well-known/openid-configuration"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := v.client.Do(req)
if err != nil {
return nil, fmt.Errorf("discovery: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("discovery: %s returned %d", url, resp.StatusCode)
}
var got Discovery
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
return nil, fmt.Errorf("discovery: %w", err)
}
// The issuer in the document must match the one configured, or a redirect could point us at
// somebody else's keys while we keep believing we are talking to the configured provider.
if strings.TrimRight(got.Issuer, "/") != strings.TrimRight(v.cfg.Issuer, "/") {
return nil, fmt.Errorf("discovery: document says issuer %q, configured %q", got.Issuer, v.cfg.Issuer)
}
v.mu.Lock()
v.discovery = &got
v.mu.Unlock()
return &got, nil
}
// jwksTTL is how long keys are trusted before refetching. Short enough to pick up a rotation
// without an operator restarting anything; long enough that token checks are not IdP round trips.
const jwksTTL = 15 * time.Minute
func (v *Verifier) keyFor(ctx context.Context, kid string) (crypto.PublicKey, error) {
v.mu.RLock()
k, ok := v.keys[kid]
fresh := time.Since(v.keysAt) < jwksTTL
v.mu.RUnlock()
if ok && fresh {
return k, nil
}
if err := v.refreshKeys(ctx); err != nil {
return nil, err
}
v.mu.RLock()
defer v.mu.RUnlock()
if k, ok := v.keys[kid]; ok {
return k, nil
}
// A kid we have never seen, after a refresh, is a token from somewhere else.
return nil, fmt.Errorf("%w: no key %q at the issuer", ErrSignature, kid)
}
func (v *Verifier) refreshKeys(ctx context.Context) error {
d, err := v.Discover(ctx)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.JWKSURI, nil)
if err != nil {
return err
}
resp, err := v.client.Do(req)
if err != nil {
return fmt.Errorf("jwks: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("jwks: %s returned %d", d.JWKSURI, resp.StatusCode)
}
var set struct {
Keys []jwk `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&set); err != nil {
return fmt.Errorf("jwks: %w", err)
}
parsed := make(map[string]crypto.PublicKey, len(set.Keys))
for _, k := range set.Keys {
if pub, err := k.publicKey(); err == nil {
parsed[k.Kid] = pub
}
}
if len(parsed) == 0 {
return errors.New("jwks: no usable keys")
}
v.mu.Lock()
v.keys = parsed
v.keysAt = time.Now()
v.mu.Unlock()
return nil
}
type jwk struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Alg string `json:"alg"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
}
func (k jwk) publicKey() (crypto.PublicKey, error) {
switch k.Kty {
case "RSA":
n, err := b64uint(k.N)
if err != nil {
return nil, err
}
e, err := b64uint(k.E)
if err != nil {
return nil, err
}
if !e.IsInt64() || e.Int64() > 1<<31 {
return nil, errors.New("implausible RSA exponent")
}
return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil
case "EC":
curve, err := curveFor(k.Crv)
if err != nil {
return nil, err
}
x, err := b64uint(k.X)
if err != nil {
return nil, err
}
y, err := b64uint(k.Y)
if err != nil {
return nil, err
}
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
}
return nil, fmt.Errorf("unsupported key type %q", k.Kty)
}
// Verify checks a serialized ID token and returns its claims.
//
// The order is deliberate: structure, then algorithm, then signature, then claims. Nothing about
// the token's contents is believed before its signature has been checked — reading `iss` or `aud`
// out of an unverified token and acting on it is how "verified" tokens turn out not to be.
func (v *Verifier) Verify(ctx context.Context, token string) (*Claims, error) {
if !v.cfg.Enabled() {
return nil, ErrDisabled
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, ErrMalformed
}
headerJSON, err := b64(parts[0])
if err != nil {
return nil, ErrMalformed
}
var hdr struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
if err := json.Unmarshal(headerJSON, &hdr); err != nil {
return nil, ErrMalformed
}
// The allow-list is fixed here rather than taken from the token. Trusting the token's own
// `alg` is the classic JWT forgery: "none" turns any token into a valid one, and swapping RS256
// for HS256 lets an attacker sign with the public key. Neither is reachable from here.
if _, ok := allowedAlgs[hdr.Alg]; !ok {
return nil, fmt.Errorf("%w: algorithm %q is not accepted", ErrSignature, hdr.Alg)
}
pub, err := v.keyFor(ctx, hdr.Kid)
if err != nil {
return nil, err
}
sig, err := b64(parts[2])
if err != nil {
return nil, ErrMalformed
}
signed := parts[0] + "." + parts[1]
if err := verifySignature(hdr.Alg, pub, []byte(signed), sig); err != nil {
return nil, err
}
payload, err := b64(parts[1])
if err != nil {
return nil, ErrMalformed
}
var claims Claims
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, ErrMalformed
}
if err := v.checkClaims(claims); err != nil {
return nil, err
}
return &claims, nil
}
func (v *Verifier) checkClaims(c Claims) error {
if strings.TrimRight(c.Issuer, "/") != strings.TrimRight(v.cfg.Issuer, "/") {
return fmt.Errorf("%w: issued by %q, expected %q", ErrClaims, c.Issuer, v.cfg.Issuer)
}
// A token addressed to a different client is a valid token that was not meant for us —
// accepting it lets any other client of the same IdP authenticate here.
if !c.Audience.contains(v.cfg.ClientID) {
return fmt.Errorf("%w: addressed to %v, not to %q", ErrClaims, []string(c.Audience), v.cfg.ClientID)
}
if c.Subject == "" {
return fmt.Errorf("%w: no subject", ErrClaims)
}
now := time.Now()
if c.Expiry == 0 || now.After(time.Unix(c.Expiry, 0).Add(v.cfg.Skew)) {
return fmt.Errorf("%w: expired", ErrClaims)
}
if c.IssuedAt != 0 && now.Add(v.cfg.Skew).Before(time.Unix(c.IssuedAt, 0)) {
return fmt.Errorf("%w: issued in the future", ErrClaims)
}
return nil
}
// IsAdmin reports whether these claims carry the configured admin group.
//
// With no 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".
func (v *Verifier) IsAdmin(c *Claims) bool {
if c == nil || v.cfg.AdminGroup == "" {
return false
}
for _, g := range c.Groups {
if g == v.cfg.AdminGroup {
return true
}
}
return false
}
var allowedAlgs = map[string]crypto.Hash{
"RS256": crypto.SHA256, "RS384": crypto.SHA384, "RS512": crypto.SHA512,
"ES256": crypto.SHA256, "ES384": crypto.SHA384, "ES512": crypto.SHA512,
}
func verifySignature(alg string, pub crypto.PublicKey, signed, sig []byte) error {
h := allowedAlgs[alg]
digest := hashOf(h, signed)
switch {
case strings.HasPrefix(alg, "RS"):
k, ok := pub.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("%w: %s token against a non-RSA key", ErrSignature, alg)
}
if err := rsa.VerifyPKCS1v15(k, h, digest, sig); err != nil {
return ErrSignature
}
return nil
case strings.HasPrefix(alg, "ES"):
k, ok := pub.(*ecdsa.PublicKey)
if !ok {
return fmt.Errorf("%w: %s token against a non-EC key", ErrSignature, alg)
}
// JWS packs ECDSA signatures as r||s, fixed width — not the ASN.1 form ecdsa.Verify
// would otherwise expect.
if len(sig)%2 != 0 {
return ErrSignature
}
half := len(sig) / 2
r := new(big.Int).SetBytes(sig[:half])
s := new(big.Int).SetBytes(sig[half:])
if !ecdsa.Verify(k, digest, r, s) {
return ErrSignature
}
return nil
}
return ErrSignature
}
func hashOf(h crypto.Hash, b []byte) []byte {
switch h {
case crypto.SHA384:
d := sha512.Sum384(b)
return d[:]
case crypto.SHA512:
d := sha512.Sum512(b)
return d[:]
default:
d := sha256.Sum256(b)
return d[:]
}
}
func curveFor(crv string) (elliptic.Curve, error) {
switch crv {
case "P-256":
return elliptic.P256(), nil
case "P-384":
return elliptic.P384(), nil
case "P-521":
return elliptic.P521(), nil
}
return nil, fmt.Errorf("unsupported curve %q", crv)
}
// b64 decodes JWT base64url, which omits padding.
func b64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
func b64uint(s string) (*big.Int, error) {
b, err := b64(s)
if err != nil {
return nil, err
}
if len(b) == 0 {
return nil, errors.New("empty value")
}
return new(big.Int).SetBytes(b), nil
}
+288
View File
@@ -0,0 +1,288 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package oidc
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// A self-contained IdP: real keys, real signatures, real discovery and JWKS documents. Testing
// token verification against anything less than a genuine signer proves nothing — the failure
// modes that matter here (accepting `none`, accepting another client's token, accepting an
// expired one) all look fine to a mock that just returns success.
type testIdP struct {
*httptest.Server
rsaKey *rsa.PrivateKey
ecKey *ecdsa.PrivateKey
}
func newIdP(t *testing.T) *testIdP {
t.Helper()
rk, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
ek, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
idp := &testIdP{rsaKey: rk, ecKey: ek}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(Discovery{
Issuer: idp.URL,
AuthorizationEndpoint: idp.URL + "/auth",
TokenEndpoint: idp.URL + "/token",
JWKSURI: idp.URL + "/jwks",
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{
{
"kty": "RSA", "kid": "rsa-1", "alg": "RS256", "use": "sig",
"n": raw(rk.N.Bytes()),
"e": raw(big.NewInt(int64(rk.E)).Bytes()),
},
{
"kty": "EC", "kid": "ec-1", "alg": "ES256", "use": "sig", "crv": "P-256",
"x": raw(ek.X.Bytes()), "y": raw(ek.Y.Bytes()),
},
}})
})
idp.Server = httptest.NewServer(mux)
t.Cleanup(idp.Close)
return idp
}
func raw(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func (i *testIdP) sign(t *testing.T, alg, kid string, claims map[string]any) string {
t.Helper()
h, _ := json.Marshal(map[string]string{"alg": alg, "kid": kid, "typ": "JWT"})
p, _ := json.Marshal(claims)
signing := raw(h) + "." + raw(p)
digest := sha256.Sum256([]byte(signing))
var sig []byte
switch alg {
case "RS256":
s, err := rsa.SignPKCS1v15(rand.Reader, i.rsaKey, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
sig = s
case "ES256":
r, s, err := ecdsa.Sign(rand.Reader, i.ecKey, digest[:])
if err != nil {
t.Fatal(err)
}
// JWS wants fixed-width r||s, not ASN.1.
sig = make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
default:
t.Fatalf("unsupported test alg %q", alg)
}
return signing + "." + raw(sig)
}
func (i *testIdP) claims(extra map[string]any) map[string]any {
c := map[string]any{
"iss": i.URL, "sub": "user-1", "aud": "echolot",
"exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
"email": "someone@example.net", "groups": []string{"users"},
}
for k, v := range extra {
c[k] = v
}
return c
}
func verifier(i *testIdP, adminGroup string) *Verifier {
return New(Config{Issuer: i.URL, ClientID: "echolot", AdminGroup: adminGroup}, i.Client())
}
func TestAcceptsAGenuineToken(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
for _, tc := range []struct{ alg, kid string }{{"RS256", "rsa-1"}, {"ES256", "ec-1"}} {
got, err := v.Verify(context.Background(), idp.sign(t, tc.alg, tc.kid, idp.claims(nil)))
if err != nil {
t.Fatalf("%s: %v", tc.alg, err)
}
if got.Subject != "user-1" || got.Email != "someone@example.net" {
t.Fatalf("%s: claims not parsed: %+v", tc.alg, got)
}
if want := idp.URL + "#user-1"; got.AccountID() != want {
t.Errorf("AccountID = %q, want %q", got.AccountID(), want)
}
}
}
// "alg": "none" is the oldest JWT forgery there is: strip the signature, declare no algorithm,
// and a naive verifier accepts anything. It must not even reach the key lookup.
func TestRejectsAlgNone(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
h, _ := json.Marshal(map[string]string{"alg": "none", "kid": "rsa-1", "typ": "JWT"})
p, _ := json.Marshal(idp.claims(nil))
token := raw(h) + "." + raw(p) + "."
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) {
t.Fatalf("alg=none was not refused as a signature failure: %v", err)
}
}
// The other classic: declare HS256 so the verifier treats the RSA *public* key as an HMAC secret,
// which the attacker also has. The allow-list has no symmetric algorithms at all.
func TestRejectsSymmetricAlgorithmConfusion(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
h, _ := json.Marshal(map[string]string{"alg": "HS256", "kid": "rsa-1", "typ": "JWT"})
p, _ := json.Marshal(idp.claims(nil))
token := raw(h) + "." + raw(p) + "." + raw([]byte("whatever"))
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) {
t.Fatalf("HS256 confusion was not refused: %v", err)
}
}
func TestRejectsATamperedPayload(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
good := idp.sign(t, "RS256", "rsa-1", idp.claims(nil))
// Swap the payload for one claiming to be somebody else, keeping the valid signature.
forged, _ := json.Marshal(idp.claims(map[string]any{"sub": "admin"}))
parts := []byte(good)
dot1, dot2 := 0, 0
for i, c := range parts {
if c == '.' {
if dot1 == 0 {
dot1 = i
} else {
dot2 = i
}
}
}
token := string(parts[:dot1+1]) + raw(forged) + string(parts[dot2:])
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) {
t.Fatalf("a swapped payload was not refused: %v", err)
}
}
// A token from the same IdP but issued to a different client is perfectly valid — just not for
// us. Accepting it would let any other client of the same provider authenticate here.
func TestRejectsAnotherClientsToken(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": "some-other-app"}))
if _, err := v.Verify(context.Background(), tok); !errors.Is(err, ErrClaims) {
t.Fatalf("another client's token was accepted: %v", err)
}
}
func TestAcceptsAudienceArrayContainingUs(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": []string{"other", "echolot"}}))
if _, err := v.Verify(context.Background(), tok); err != nil {
t.Fatalf("an audience array including us was refused: %v", err)
}
}
func TestRejectsExpiredAndFutureTokens(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
expired := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{
"exp": time.Now().Add(-time.Hour).Unix(),
}))
if _, err := v.Verify(context.Background(), expired); !errors.Is(err, ErrClaims) {
t.Errorf("expired token accepted: %v", err)
}
future := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{
"iat": time.Now().Add(time.Hour).Unix(),
}))
if _, err := v.Verify(context.Background(), future); !errors.Is(err, ErrClaims) {
t.Errorf("token issued in the future accepted: %v", err)
}
}
// A token signed by a completely different provider, with its own keys and its own kid.
func TestRejectsATokenFromAnotherIssuer(t *testing.T) {
ours, theirs := newIdP(t), newIdP(t)
v := verifier(ours, "")
tok := theirs.sign(t, "RS256", "rsa-1", theirs.claims(nil))
if _, err := v.Verify(context.Background(), tok); err == nil {
t.Fatal("a token from another issuer was accepted")
}
}
func TestRejectsMalformedTokens(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
for _, bad := range []string{"", "not-a-token", "a.b", "a.b.c.d", "...", "!!!.???.***"} {
if _, err := v.Verify(context.Background(), bad); err == nil {
t.Errorf("%q was accepted", bad)
}
}
}
// 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".
func TestNobodyIsAdminUntilAGroupIsConfigured(t *testing.T) {
idp := newIdP(t)
claims := &Claims{Groups: []string{"users", "echolot-admins"}}
if verifier(idp, "").IsAdmin(claims) {
t.Error("someone was an admin with no admin group configured")
}
if !verifier(idp, "echolot-admins").IsAdmin(claims) {
t.Error("a member of the configured group was not an admin")
}
if verifier(idp, "other-group").IsAdmin(claims) {
t.Error("a non-member was an admin")
}
if verifier(idp, "echolot-admins").IsAdmin(nil) {
t.Error("an absent identity was an admin")
}
}
// A discovery document whose issuer disagrees with the configured one means we were redirected
// somewhere — and would otherwise have fetched that somewhere's signing keys while believing
// they belonged to the configured provider.
func TestRefusesDiscoveryThatRenamesTheIssuer(t *testing.T) {
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
defer srv.Close()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(Discovery{Issuer: "https://somewhere.else", JWKSURI: srv.URL + "/jwks"})
})
v := New(Config{Issuer: srv.URL, ClientID: "echolot"}, srv.Client())
if _, err := v.Discover(context.Background()); err == nil {
t.Fatal("discovery accepted a document for a different issuer")
}
}
func TestDisabledWithoutConfiguration(t *testing.T) {
v := New(Config{}, nil)
if v.Config().Enabled() {
t.Fatal("an unconfigured verifier reports itself enabled")
}
if _, err := v.Verify(context.Background(), "x.y.z"); !errors.Is(err, ErrDisabled) {
t.Fatalf("want ErrDisabled, got %v", err)
}
}
+13 -9
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:
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 {
+32 -18
View File
@@ -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)
}
}
+51
View File
@@ -37,8 +37,19 @@ type Device struct {
Credential string `json:"credential"`
Enrolled time.Time `json:"enrolled"`
Name string `json:"name,omitempty"`
// The account this device belongs to, as issuer#subject — empty when nobody has signed in
// on it. Enrollment and sign-in are deliberately separate: a device is admitted by an
// operator's token, and only later (if ever) associated with a person. Servers that accept
// anonymous uploads never need the second step.
AccountID string `json:"account_id,omitempty"`
AccountName string `json:"account_name,omitempty"`
LinkedAt time.Time `json:"linked_at,omitempty"`
}
// LinkedToAccount reports whether a person has signed in on this device.
func (d Device) LinkedToAccount() bool { return d.AccountID != "" }
type Store struct {
mu sync.Mutex
path string
@@ -126,6 +137,46 @@ func (s *Store) Redeem(token, name string) (*Device, error) {
}
// DeviceByCredential authenticates a bearer credential.
// LinkAccount ties a device to a signed-in identity, or clears it when accountID is empty.
func (s *Store) LinkAccount(deviceID, accountID, displayName string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.data.Devices {
if s.data.Devices[i].ID != deviceID {
continue
}
s.data.Devices[i].AccountID = accountID
s.data.Devices[i].AccountName = displayName
if accountID == "" {
s.data.Devices[i].LinkedAt = time.Time{}
} else {
s.data.Devices[i].LinkedAt = time.Now().UTC()
}
return s.save()
}
return errors.New("no such device")
}
// Devices returns a copy of the device list, for the admin UI.
func (s *Store) Devices() []Device {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Device(nil), s.data.Devices...)
}
// DeleteDevice revokes a device: its credential stops working immediately.
func (s *Store) DeleteDevice(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.data.Devices {
if s.data.Devices[i].ID == id {
s.data.Devices = append(s.data.Devices[:i], s.data.Devices[i+1:]...)
return s.save()
}
}
return errors.New("no such device")
}
func (s *Store) DeviceByCredential(cred string) *Device {
s.mu.Lock()
defer s.mu.Unlock()