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
+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
}