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