Files
echolot/server/internal/oidc/oidc_test.go
T
mrambossekandClaude Fable 5 80d2092f1b
server-test / test (push) Successful in 37s
oidc: accept both the app's public client and the server's confidential one
Explaining public vs confidential clients surfaced a gap in my own design: I had
assumed a single client id, but there are two clients here with genuinely
different properties.

  the Android app     public + PKCE, because an APK cannot keep a secret
  the admin UI        confidential, because the server can keep one in
                      /etc/echolot-server.env and weakening it to public buys
                      nothing

So the audience check now accepts either registered client id - and only those
two. "Any client of this issuer" would let every other application registered
with the same IdP authenticate here, which is the entire reason the check
exists. Either id alone is enough to enable sign-in, since an operator may
register only the app or only the admin UI.

The profile advertises the *app's* client id, since that is what a phone should
authorize as.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:31:33 +02:00

326 lines
11 KiB
Go

// 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", AppClientID: "echolot-app", 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)
}
}
// Two clients, because the phone and the admin UI have different properties: an APK cannot keep a
// secret (public + PKCE) while the server can (confidential). Both must be accepted — but only
// those two. "Any client of this issuer" would let every other application registered with the
// same IdP authenticate here, which is the whole reason the audience check exists.
func TestBothRegisteredClientsAreAccepted(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
for _, aud := range []any{"echolot", "echolot-app", []string{"echolot-app", "other"}} {
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": aud}))
if _, err := v.Verify(context.Background(), tok); err != nil {
t.Errorf("aud %v was refused: %v", aud, err)
}
}
// A third application at the same issuer is still not us.
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": "someone-elses-app"}))
if _, err := v.Verify(context.Background(), tok); !errors.Is(err, ErrClaims) {
t.Fatalf("a third client's token was accepted: %v", err)
}
}
// Either client id alone is enough to make sign-in usable: an operator may register only the app
// (no admin UI login) or only the server.
func TestEitherClientIDAloneEnablesSignIn(t *testing.T) {
if !(Config{Issuer: "https://i", ClientID: "a"}).Enabled() {
t.Error("a server-only configuration was reported disabled")
}
if !(Config{Issuer: "https://i", AppClientID: "b"}).Enabled() {
t.Error("an app-only configuration was reported disabled")
}
if (Config{Issuer: "https://i"}).Enabled() {
t.Error("an issuer with no client at all was reported enabled")
}
}