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