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