Files
zeekay bae63692f0 the corporate name is Lux Industries Inc
Lux Partners Limited is the Isle of Man company and does not appear on a
public surface. Lux Industries Inc, a Delaware C corporation, is the one
these copyright lines, citations and terms name.

A bare "Lux Partners" is left alone unless the line says who owns
something — elsewhere it is the name of a site, not a party.
2026-08-28 16:43:59 -07:00

751 lines
30 KiB
Go

// Copyright 2024-2026 Lux Industries Inc. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/router"
"github.com/luxfi/aml/pkg/cases"
"github.com/luxfi/aml/pkg/types"
)
// One RSA key per test binary: generating one is the slowest thing in this file
// and every test wants the same thing from it.
var key = sync.OnceValue(func() *rsa.PrivateKey {
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
return k
})
// aud is the clientId of the AML application these tests are the resource server
// for — the value IAM stamps as `aud` on a token minted for it.
const aud = "aml-lux"
// mint signs a token with kid "iam" and the given claims. What a real IAM token
// always carries is defaulted — an hour's expiry, this application's audience and
// tokenType "access-token" — so a row states only the claim it is about. A claim
// set to nil is omitted, which is how a row says "without this claim".
func mint(t *testing.T, signer any, method jwt.SigningMethod, c jwt.MapClaims) string {
t.Helper()
for k, v := range map[string]any{
"exp": time.Now().Add(time.Hour).Unix(),
"aud": aud,
"tokenType": accessToken,
} {
if _, ok := c[k]; !ok {
c[k] = v
}
}
for k, v := range c {
if v == nil {
delete(c, k)
}
}
tok := jwt.NewWithClaims(method, c)
tok.Header["kid"] = "iam"
raw, err := tok.SignedString(signer)
if err != nil {
t.Fatalf("sign: %v", err)
}
return raw
}
// rs256 signs with the shared RSA key, which is what every brand's issuer publishes
// in these tests.
func rs256(t *testing.T, c jwt.MapClaims) string {
t.Helper()
return mint(t, key(), jwt.SigningMethodRS256, c)
}
// orgs is the membership set claim, in the shape IAM emits it (schema.OrgRef).
func orgs(names ...string) []any {
out := make([]any, 0, len(names))
for _, n := range names {
out = append(out, map[string]any{"org": n, "role": "member"})
}
return out
}
// published is the keyset every brand issuer publishes in these tests: ONE key,
// shared. That is what the live IAM does — every brand's issuer serves the same
// set, and zoolabs.id's /v1/iam/.well-known/jwks carries cert-lux, cert-hanzo,
// cert-pars and cert-zoo together — so a Lux token's signature really does verify
// against the Zoo issuer's keys.
//
// It is therefore the sharp version of the white-label claim: with the same key
// behind every issuer, the only thing that can refuse a Lux token on a Zoo host is
// the `iss` pin itself, and not a signature that happens not to verify.
func published(issuer string) (Keyset, error) {
return Keyset{"iam": &key().PublicKey}, nil
}
// identity is the Identity under test: this application's audience over the shared
// keyset.
func identity() Identity { return IAMIdentity(published, aud) }
// request builds an event addressed to a specific Host, optionally bearing a token.
func request(method, target, host, token string) (*core.RequestEvent, *httptest.ResponseRecorder) {
headers := map[string]string{}
if token != "" {
headers["Authorization"] = "Bearer " + token
}
e, rec := event(method, target, headers)
e.Request.Host = host
return e, rec
}
// bearing builds a plain request to a Host carrying a token, for the Identity
// under test on its own.
func bearing(host, token string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/v1/aml/cases", nil)
r.Host = host
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
return r
}
type config struct {
Brand string `json:"brand"`
Display string `json:"display"`
Issuer string `json:"issuer"`
Domain string `json:"domain"`
}
// TestWhiteLabelByHost is the white-label claim itself: ONE handler, hit on four
// hosts, answers as four brands, and a token from one brand's issuer does not
// authenticate on another brand's host. Without the second half the first half is
// decoration — the surface would say "Zoo" while admitting Lux's tenants.
func TestWhiteLabelByHost(t *testing.T) {
h := &Handler{Identity: identity(), Alerts: NewAlertStore()}
for _, tc := range []config{
{"lux", "Lux", "https://lux.id", "lux.network"},
{"hanzo", "Hanzo", "https://hanzo.id", "hanzo.ai"},
{"zoo", "Zoo", "https://zoolabs.id", "zoo.ngo"},
} {
for _, host := range []string{"api." + tc.Domain, tc.Domain} {
e, rec := request(http.MethodGet, "/v1/aml/config", host, "")
if err := h.brandConfig()(e); err != nil {
t.Fatalf("%s: transport error: %v", host, err)
}
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d, want 200", host, rec.Code)
}
var got config
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("%s: %v", host, err)
}
if got != tc {
t.Errorf("%s: config = %+v, want %+v", host, got, tc)
}
}
}
// A console host under a brand's further domain is that brand, not the default.
e, rec := request(http.MethodGet, "/v1/aml/config", "console.zoo.cloud", "")
if err := h.brandConfig()(e); err != nil {
t.Fatalf("transport error: %v", err)
}
var zoo config
if err := json.Unmarshal(rec.Body.Bytes(), &zoo); err != nil {
t.Fatal(err)
}
if zoo.Brand != "zoo" || zoo.Issuer != "https://zoolabs.id" {
t.Errorf("console.zoo.cloud = %+v, want the zoo brand", zoo)
}
// A Host no brand claims gets no brand and no issuer to go and get a token from.
// Naming one here would name an issuer that the auth path on this same host must
// refuse — the route would be telling the caller to do something that cannot
// work — and would publish one brand's identity on a surface that is not its.
for _, host := range []string{"aml.internal", "10.42.0.7:8090", "localhost:8090", ""} {
e, rec := request(http.MethodGet, "/v1/aml/config", host, "")
if err := h.brandConfig()(e); err != nil {
t.Fatalf("%s: transport error: %v", host, err)
}
if rec.Code != http.StatusNotFound {
t.Errorf("config on %q: status %d, want 404; body=%s", host, rec.Code, rec.Body.String())
}
if contains(rec.Body.String(), "hanzo") {
t.Errorf("config on %q named a brand: %s", host, rec.Body.String())
}
}
// A Lux-issued token: its own host authenticates it, every other brand's host
// refuses it, and the signature is valid in both cases.
lux := rs256(t, jwt.MapClaims{"iss": "https://lux.id", "owner": "acme", "sub": "u-1"})
who, err := h.Identity(bearing("api.lux.network", lux))
if err != nil {
t.Fatalf("a Lux token on a Lux host was refused: %v", err)
}
if who.Tenant != "lux/acme" {
t.Errorf("tenant = %q, want lux/acme", who.Tenant)
}
if who.Subject != "u-1" {
t.Errorf("subject = %q, want the token's sub", who.Subject)
}
for _, host := range []string{"api.zoo.ngo", "console.zoo.cloud", "api.hanzo.ai", "aml.pars.network", "aml.internal"} {
if who, err := h.Identity(bearing(host, lux)); err == nil {
t.Errorf("a Lux token authenticated as %q on %s — brand tenancy is not enforced", who.Tenant, host)
}
}
// And the refusal reaches the route, not only the seam: the same token that
// reads its own tenant's alerts on a Lux host gets 401 on a Zoo host.
h.Alerts.Add("tx-1", []types.Alert{{ID: "lux-alert", OrgID: "lux/acme"}})
for _, tc := range []struct {
host string
code int
}{{"api.lux.network", http.StatusOK}, {"api.zoo.ngo", http.StatusUnauthorized}} {
e, rec := request(http.MethodGet, "/v1/aml/transactions/tx-1/alerts", tc.host, lux)
e.Request.SetPathValue("id", "tx-1")
if err := h.getAlerts()(e); err != nil {
t.Fatalf("%s: transport error: %v", tc.host, err)
}
if rec.Code != tc.code {
t.Errorf("%s: status %d, want %d", tc.host, rec.Code, tc.code)
}
if tc.code == http.StatusUnauthorized && contains(rec.Body.String(), "lux-alert") {
t.Errorf("%s: a refused request received the tenant's data", tc.host)
}
}
}
// The tenant is the brand of the Host qualifying the `owner` claim of a verified
// token — the same claim the gateway reads to set X-Org-Id, taken first-hand here
// instead of trusting the header, and qualified so that one org name under two
// brands is two tenants.
func TestIAMIdentityYieldsTheQualifiedTenant(t *testing.T) {
id := identity()
for _, tc := range []struct{ host, issuer, org, want string }{
{"api.hanzo.ai", "https://hanzo.id", "acme", "hanzo/acme"},
{"api.lux.network", "https://lux.id", "acme", "lux/acme"},
{"api.zoo.ngo", "https://zoolabs.id", "acme", "zoo/acme"},
{"console.zoo.cloud", "https://zoolabs.id", "acme", "zoo/acme"},
} {
tok := rs256(t, jwt.MapClaims{"iss": tc.issuer, "owner": tc.org, "sub": "u-1", "isAdmin": true})
who, err := id(bearing(tc.host, tok))
got := who.Tenant
if err != nil {
t.Fatalf("%s: refused a valid token: %v", tc.host, err)
}
if got != tc.want {
t.Errorf("%s: tenant = %q, want %q", tc.host, got, tc.want)
}
}
}
// A Host no brand claims must refuse, whatever the token says.
//
// Every row here is a Host this process really is reached on: the pod IP that any
// in-cluster caller can dial, the Service DNS name, localhost through a
// port-forward, an absent Host, and lookalike domains under somebody else's
// registrable suffix. While the brand resolver defaulted, each of them was
// authenticated by the default brand's issuer — a hanzo.id token opened this
// service on all of them, and the white-label pin was inert everywhere the ingress
// had not put the brand's own name in the Host.
func TestIAMIdentityRefusesAnUnnamedHost(t *testing.T) {
id := identity()
// A token that is beyond reproach everywhere else: current, correctly audienced,
// an access token, signed by the published key, from a first-party issuer.
tok := rs256(t, jwt.MapClaims{"iss": "https://hanzo.id", "owner": "acme", "sub": "u-1"})
for _, host := range []string{
"10.42.0.7:8090",
"aml.aml.svc.cluster.local",
"aml.svc.cluster.local:8090",
"localhost",
"localhost:8090",
"127.0.0.1:8090",
"",
"a.zoo.ngo.attacker.example",
"hanzo.ai.attacker.example",
"aml.internal",
} {
if who, err := id(bearing(host, tok)); err == nil {
t.Errorf("a hanzo.id token authenticated as %q on %q", who.Tenant, host)
}
}
// The same token on the brand's own Host is accepted, so the rows above fail for
// the Host and not because the token was bad.
if _, err := id(bearing("api.hanzo.ai", tok)); err != nil {
t.Fatalf("the same token on api.hanzo.ai was refused: %v", err)
}
}
// The audience is this application, and the token is an access token.
//
// IAM stamps aud = the app's clientId, and every first-party token on an issuer is
// signed by the same keys and carries the same `iss`. The audience is the only
// thing that says which application a token was minted FOR, and RFC 9068 §4 makes
// checking it the resource server's own job — nobody else can. Without it a token
// issued to a marketing site, or to any other tenant's application on the same
// issuer, is a credential for a compliance record plane.
func TestAudienceIsThisApplication(t *testing.T) {
id := identity()
hanzo := "https://hanzo.id"
base := func() jwt.MapClaims {
return jwt.MapClaims{"iss": hanzo, "owner": "acme", "sub": "u-1"}
}
with := func(k string, v any) jwt.MapClaims {
c := base()
c[k] = v
return c
}
for _, tc := range []struct{ name, token string }{
{"a token for a marketing site", rs256(t, with("aud", "marketing-site"))},
{"a token for another tenant's app on this issuer", rs256(t, with("aud", "acme-portal"))},
{"a token with no audience at all", rs256(t, with("aud", nil))},
{"an audience that merely contains ours", rs256(t, with("aud", aud+"-staging"))},
{"an audience ours is a prefix of", rs256(t, with("aud", "x-"+aud))},
{"an empty audience", rs256(t, with("aud", ""))},
// IAM scopes a SHARED application's audience to the org (audienceFor:
// clientId + "-org-" + org), so a shared application's token cannot satisfy a
// pin on a dedicated application's clientId. Here the audience itself refuses
// the deployment shape whose tenant claim would be wrong.
{"a shared application's org-scoped audience", rs256(t, with("aud", aud+"-org-hanzo"))},
// tokenType: an id_token is issued to a browser for the browser's own
// consumption, carries the same iss and the same aud, and is not a credential
// for an API. IAM says which kind it minted, so this is refusable and refused.
{"an id_token", rs256(t, with("tokenType", "id-token"))},
{"no tokenType at all", rs256(t, with("tokenType", nil))},
{"an unrecognised tokenType", rs256(t, with("tokenType", "refresh-token"))},
{"an empty tokenType", rs256(t, with("tokenType", ""))},
} {
if who, err := id(bearing("api.hanzo.ai", tc.token)); err == nil {
t.Errorf("%s authenticated as %q", tc.name, who.Tenant)
}
}
// An audience list containing ours is accepted: `aud` is a set, and the question
// RFC 9068 asks is whether this resource server is in it.
if _, err := id(bearing("api.hanzo.ai", rs256(t, with("aud", []string{"other", aud})))); err != nil {
t.Errorf("an audience list naming this application was refused: %v", err)
}
// With no audience configured nothing authenticates, including a token that is
// otherwise perfect. An unpinned deployment would accept every token its issuer
// ever minted, so it accepts none — and it says so without asking the issuer for
// keys, because a deployment that cannot identify anybody must not spend a
// network round trip per request finding that out. Refusing at the top is what
// makes this independent of how the JWT library happens to treat an empty
// expected audience.
for _, unpinned := range []string{"", " "} {
var asked atomic.Int64
counted := func(issuer string) (Keyset, error) {
asked.Add(1)
return published(issuer)
}
if who, err := IAMIdentity(counted, unpinned)(bearing("api.hanzo.ai", rs256(t, base()))); err == nil {
t.Errorf("with audience %q configured, a token authenticated as %q", unpinned, who.Tenant)
}
if n := asked.Load(); n != 0 {
t.Errorf("with audience %q configured, a request cost %d issuer fetches, want 0", unpinned, n)
}
}
}
// The tenant is an org the caller actually belongs to.
//
// IAM's `owner` claim is the APP's org, not the user's (internal/oidc/jwt.go Sign:
// Owner: app.Organization). For the shape this engine supports — one dedicated
// application per financial institution — IAM refuses a login from outside the
// app's org (login.go: user.Owner != app.Organization && !app.IsShared &&
// app.OrgChoiceMode == "" → refused), so `owner` IS the caller's org and the two
// always agree.
//
// A SHARED or org-choice application breaks that: users of any org may authenticate
// to it, and every one of their tokens carries the APP owner's org as `owner`. Taken
// on faith that makes all of them one tenant, and one institution's analyst reads
// another's records. IAM also emits the caller's own membership set, so the claim is
// checkable rather than trusted: an owner the caller is not a member of refuses.
// It is the same predicate the fleet uses to authorize an org switch (X-Org-Id ∈
// orgs).
func TestTenantIsAnOrgTheCallerBelongsTo(t *testing.T) {
id := identity()
hanzo := "https://hanzo.id"
for _, tc := range []struct {
name string
token string
want string
}{
{
"a member of the org the token acts for",
rs256(t, jwt.MapClaims{"iss": hanzo, "owner": "acme", "orgs": orgs("acme")}),
"hanzo/acme",
},
{
// An analyst whose home org is not the institution but who is a member of
// it. The home org is orgs[0] and is deliberately NOT what scopes the read:
// the token says which institution it acts for, and the membership set says
// whether it may.
"a member whose home org is another",
rs256(t, jwt.MapClaims{"iss": hanzo, "owner": "acme", "orgs": orgs("consultancy", "acme")}),
"hanzo/acme",
},
{
// client_credentials: no user, so no membership set — IAM passes nil orgs
// and the claim is omitted. The org is the org of the application, and
// which application it is has already been pinned by the audience.
"a machine token with no membership set",
rs256(t, jwt.MapClaims{"iss": hanzo, "owner": "acme", "sub": "acme/robot"}),
"hanzo/acme",
},
} {
found, err := id(bearing("api.hanzo.ai", tc.token))
got := found.Tenant
if err != nil {
t.Errorf("%s was refused: %v", tc.name, err)
continue
}
if got != tc.want {
t.Errorf("%s: tenant = %q, want %q", tc.name, got, tc.want)
}
}
for _, tc := range []struct{ name, token string }{
{
// The shared-application case: the app is owned by `hanzo`, the caller is a
// member of `zoo-tenant` alone, and IAM stamped the app's org as the owner.
"a shared application's token, whose owner the caller is not in",
rs256(t, jwt.MapClaims{"iss": hanzo, "owner": "hanzo", "orgs": orgs("zoo-tenant")}),
},
{
"an owner that only looks like a member org",
rs256(t, jwt.MapClaims{"iss": hanzo, "owner": "acme-holdings", "orgs": orgs("acme")}),
},
{
"a membership set that names no org",
rs256(t, jwt.MapClaims{"iss": hanzo, "owner": "acme", "orgs": []any{map[string]any{"role": "member"}}}),
},
} {
if who, err := id(bearing("api.hanzo.ai", tc.token)); err == nil {
t.Errorf("%s authenticated as %q", tc.name, who.Tenant)
}
}
}
// Every way a token can fail to establish a tenant, and each must refuse. These
// are one test because the property is one property: on doubt, no tenant.
func TestIAMIdentityRefusesEveryDoubt(t *testing.T) {
stranger, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
id := identity()
hanzo := "https://hanzo.id"
// A token signed with a key the issuer does not publish. Same claims, valid
// shape, no provenance.
forged := mint(t, stranger, jwt.SigningMethodRS256, jwt.MapClaims{"iss": hanzo, "owner": "acme"})
// Symmetric signing over the public modulus: the algorithm-confusion attack,
// which succeeds wherever the verifier takes the token's word for the family.
confused := mint(t, key().PublicKey.N.Bytes(), jwt.SigningMethodHS256, jwt.MapClaims{"iss": hanzo, "owner": "acme"})
// A token with no `alg` at all: "none" carries no signature to check.
unsigned := mint(t, jwt.UnsafeAllowNoneSignatureType, jwt.SigningMethodNone, jwt.MapClaims{"iss": hanzo, "owner": "acme"})
for _, tc := range []struct {
name string
host string
token string
}{
{"no token at all", "api.hanzo.ai", ""},
{"another brand's issuer", "api.hanzo.ai", rs256(t, jwt.MapClaims{"iss": "https://lux.id", "owner": "acme"})},
{"no issuer", "api.hanzo.ai", rs256(t, jwt.MapClaims{"owner": "acme"})},
{"an issuer no brand claims", "api.hanzo.ai", rs256(t, jwt.MapClaims{"iss": "https://evil.example", "owner": "acme"})},
{"the issuer as a prefix of ours", "api.hanzo.ai", rs256(t, jwt.MapClaims{"iss": "https://hanzo.id.evil.example", "owner": "acme"})},
{"a signature from an unpublished key", "api.hanzo.ai", forged},
{"a symmetric signature over the modulus", "api.hanzo.ai", confused},
{"no signature", "api.hanzo.ai", unsigned},
{"expired", "api.hanzo.ai", rs256(t, jwt.MapClaims{"iss": hanzo, "owner": "acme", "exp": time.Now().Add(-time.Hour).Unix()})},
{"no expiry", "api.hanzo.ai", rs256(t, jwt.MapClaims{"iss": hanzo, "owner": "acme", "exp": nil})},
{"not yet valid", "api.hanzo.ai", rs256(t, jwt.MapClaims{"iss": hanzo, "owner": "acme", "nbf": time.Now().Add(time.Hour).Unix()})},
{"no owner", "api.hanzo.ai", rs256(t, jwt.MapClaims{"iss": hanzo, "sub": "u-1"})},
{"an empty owner", "api.hanzo.ai", rs256(t, jwt.MapClaims{"iss": hanzo, "owner": ""})},
{"a blank owner", "api.hanzo.ai", rs256(t, jwt.MapClaims{"iss": hanzo, "owner": " "})},
{"an owner carrying the tenant separator", "api.hanzo.ai", rs256(t, jwt.MapClaims{"iss": hanzo, "owner": "zoo/acme"})},
{"not a token", "api.hanzo.ai", "not.a.token"},
} {
if who, err := id(bearing(tc.host, tc.token)); err == nil {
t.Errorf("%s authenticated as %q", tc.name, who.Tenant)
}
}
// A credential presented anywhere but the Authorization header is not a
// credential here.
valid := rs256(t, jwt.MapClaims{"iss": hanzo, "owner": "acme"})
for _, r := range []*http.Request{
func() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/v1/aml/cases?access_token="+valid, nil)
r.Host = "api.hanzo.ai"
return r
}(),
func() *http.Request {
r := bearing("api.hanzo.ai", "")
r.Header.Set("Cookie", "token="+valid)
return r
}(),
func() *http.Request {
r := bearing("api.hanzo.ai", "")
r.Header.Set("Authorization", "Basic "+valid)
return r
}(),
// Digest is exactly as long as Bearer, so this row fails only if the scheme
// is actually compared. With "Basic" alone a verifier that skipped the
// compare would still refuse — it would read one character into the token
// and reject a mangled one — and the test would pass for the wrong reason.
func() *http.Request {
r := bearing("api.hanzo.ai", "")
r.Header.Set("Authorization", "Digest "+valid)
return r
}(),
} {
if who, err := id(r); err == nil {
t.Errorf("a token outside the bearer header authenticated as %q", who.Tenant)
}
}
}
// A bearer that is not a token must not cost a request to the issuer.
//
// Resolving a keyset can be a network round trip. An unauthenticated caller who can
// make the auth path take one per request — by choosing a Host and putting any word
// at all in the Authorization header — has a lever on this process that costs them
// nothing. So the shape is checked first, and only something that could be a signed
// token is worth asking an issuer about.
func TestGarbageBearerCostsNoIssuerFetch(t *testing.T) {
var asked atomic.Int64
counted := func(issuer string) (Keyset, error) {
asked.Add(1)
return published(issuer)
}
id := IAMIdentity(counted, aud)
for _, garbage := range []string{
"x",
"not-a-token",
"a.b",
"a.b.c.d",
"..",
"a..c",
"!!!.e30.sig", // a header that is not base64url
"e30.e30.sig", // a header that is {} — no alg
"eyJhIjoxfQ.e30.sig", // JSON without an alg
"YQ.e30.sig", // a header that is not JSON
strings.Repeat("A", maxToken+1) + ".b.c", // longer than any token
} {
if who, err := id(bearing("api.hanzo.ai", garbage)); err == nil {
t.Errorf("%q authenticated as %q", garbage, who.Tenant)
}
}
if n := asked.Load(); n != 0 {
t.Errorf("garbage bearers cost %d issuer fetches, want 0", n)
}
// A well-formed token does reach the keys, so the filter is a filter and not a
// second refusal quietly standing in for the parse.
if _, err := id(bearing("api.hanzo.ai", rs256(t, jwt.MapClaims{"iss": "https://hanzo.id", "owner": "acme"}))); err != nil {
t.Fatalf("a real token was refused: %v", err)
}
if n := asked.Load(); n != 1 {
t.Errorf("a real token cost %d issuer fetches, want 1", n)
}
}
// With no way to reach an issuer's keys there is no way to check a signature, so
// there is no tenant. The same holds for an issuer that publishes an empty set.
func TestIAMIdentityRefusesWithoutKeys(t *testing.T) {
tok := rs256(t, jwt.MapClaims{"iss": "https://hanzo.id", "owner": "acme"})
for _, tc := range []struct {
name string
jwks Keysets
}{
{"no keyset source", nil},
{"unreachable issuer", func(string) (Keyset, error) { return nil, fmt.Errorf("dial: refused") }},
{"an empty keyset", func(string) (Keyset, error) { return Keyset{}, nil }},
} {
if who, err := IAMIdentity(tc.jwks, aud)(bearing("api.hanzo.ai", tok)); err == nil {
t.Errorf("%s authenticated as %q", tc.name, who.Tenant)
}
}
}
// A token minted before a rotation still verifies when the issuer publishes both
// keys, and a `kid` naming a key the issuer never published does not on its own
// admit anything.
func TestRotationKeepsLiveTokensValid(t *testing.T) {
next, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
both := func(string) (Keyset, error) {
return Keyset{"iam": &key().PublicKey, "iam-next": &next.PublicKey}, nil
}
id := IAMIdentity(both, aud)
old := rs256(t, jwt.MapClaims{"iss": "https://hanzo.id", "owner": "acme"})
if _, err := id(bearing("api.hanzo.ai", old)); err != nil {
t.Errorf("a token from the previous key was refused during rotation: %v", err)
}
// A stale or absent label does not lock out a live token: with no matching kid
// the signature is checked against every key the issuer publishes, and this one
// holds under the new key.
unlabelled := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": "https://hanzo.id", "owner": "acme", "aud": aud, "tokenType": accessToken,
"exp": time.Now().Add(time.Hour).Unix(),
})
unlabelled.Header["kid"] = "a-kid-nobody-published"
raw, err := unlabelled.SignedString(next)
if err != nil {
t.Fatal(err)
}
if _, err := id(bearing("api.hanzo.ai", raw)); err != nil {
t.Errorf("a token with an unknown kid but a published key's signature was refused: %v", err)
}
// Naming one published key while being signed by another is a key substitution.
// The kid binds: that key must be the one that verifies it.
substituted := mint(t, next, jwt.SigningMethodRS256, jwt.MapClaims{"iss": "https://hanzo.id", "owner": "acme"}) // kid "iam", signed by next
if who, err := id(bearing("api.hanzo.ai", substituted)); err == nil {
t.Errorf("a token labelled kid=iam but signed by another key authenticated as %q", who.Tenant)
}
}
// Two tenants of the same brand, each with a real token, must not see each other's
// alerts. The tenant is whatever the token says and nothing else says otherwise.
func TestTenantsDoNotBleedUnderIAMIdentity(t *testing.T) {
alerts := NewAlertStore()
alerts.Add("tx-1", []types.Alert{{ID: "first-alert", OrgID: "hanzo/first"}})
alerts.Add("tx-1", []types.Alert{{ID: "second-alert", OrgID: "hanzo/second"}})
h := &Handler{Alerts: alerts, Identity: identity()}
for _, tc := range []struct{ org, mine, theirs string }{
{"first", "first-alert", "second-alert"},
{"second", "second-alert", "first-alert"},
} {
tok := rs256(t, jwt.MapClaims{"iss": "https://hanzo.id", "owner": tc.org})
e, rec := request(http.MethodGet, "/v1/aml/transactions/tx-1/alerts", "api.hanzo.ai", tok)
e.Request.SetPathValue("id", "tx-1")
if err := h.getAlerts()(e); err != nil {
t.Fatalf("%s: transport error: %v", tc.org, err)
}
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d, want 200", tc.org, rec.Code)
}
body := rec.Body.String()
if contains(body, tc.theirs) {
t.Errorf("%s read another tenant's alert: %s", tc.org, body)
}
if !contains(body, tc.mine) {
t.Errorf("%s did not read its own alert: %s", tc.org, body)
}
}
}
// A route nobody registered protects nothing, however well it is written: the
// documentation said GET /v1/aml/config was live while Register did not mention it,
// so the only thing serving it was a test calling the handler directly.
//
// This drives the real router — Register, BuildMux, an HTTP request — so a route is
// only accounted for here if the daemon would actually serve it.
func TestEveryRouteIsServed(t *testing.T) {
h := &Handler{
Identity: identity(),
Alerts: NewAlertStore(),
Cases: cases.NewStore(),
Engine: testEngine(nil),
}
se := &core.ServeEvent{Router: router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*core.RequestEvent, router.EventCleanupFunc) {
e := new(core.RequestEvent)
e.Request, e.Response = r, w
return e, nil
})}
h.Register(se)
mux, err := se.Router.BuildMux()
if err != nil {
t.Fatalf("BuildMux: %v", err)
}
// Unauthenticated, so a tenant-scoped route answers 401 — which is proof it is
// served, since a route nobody registered answers 404.
for _, tc := range []struct {
method, path string
code int
}{
{http.MethodPost, "/v1/aml/transactions", http.StatusUnauthorized},
{http.MethodGet, "/v1/aml/transactions/tx-1/alerts", http.StatusUnauthorized},
{http.MethodGet, "/v1/aml/cases", http.StatusUnauthorized},
{http.MethodPost, "/v1/aml/cases/c-1/events", http.StatusUnauthorized},
{http.MethodPost, "/v1/aml/cases/c-1/resolve", http.StatusUnauthorized},
{http.MethodPost, "/v1/aml/rules/test", http.StatusUnauthorized},
{http.MethodPost, "/v1/aml/relationships", http.StatusUnauthorized},
{http.MethodPost, "/v1/aml/relationships/r-1/close", http.StatusUnauthorized},
{http.MethodPost, "/v1/aml/relationships/search", http.StatusUnauthorized},
{http.MethodGet, "/v1/aml/anomaly", http.StatusUnauthorized},
{http.MethodGet, "/v1/aml/sanctions/sources", http.StatusUnauthorized},
{http.MethodGet, "/v1/aml/catalog", http.StatusUnauthorized},
{http.MethodPost, "/v1/aml/anomaly/test", http.StatusUnauthorized},
{http.MethodPost, "/v1/aml/sanctions/search", http.StatusUnauthorized},
{http.MethodGet, "/v1/aml/rules", http.StatusUnauthorized},
// Two routes answer an unauthenticated caller, and only these two. The brand
// config is what a caller reads BEFORE it has a token — the issuer it names is
// where the token comes from — and health is what a probe reads on a Host that
// names no brand at all. They are also what proves a 401 above is the tenancy
// check and not a route that answers 401 whatever happens.
{http.MethodGet, "/v1/aml/config", http.StatusOK},
{http.MethodGet, "/v1/aml/health", http.StatusServiceUnavailable},
} {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader("{}"))
req.Host = "api.hanzo.ai"
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code == http.StatusNotFound {
t.Errorf("%s %s is not served", tc.method, tc.path)
continue
}
if rec.Code != tc.code {
t.Errorf("%s %s: status %d, want %d; body=%s", tc.method, tc.path, rec.Code, tc.code, rec.Body.String())
}
}
// The config route serves the brand of the Host it was asked on, through the
// router rather than by calling the handler.
req := httptest.NewRequest(http.MethodGet, "/v1/aml/config", nil)
req.Host = "api.lux.network"
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
var got config
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("config body %q: %v", rec.Body.String(), err)
}
if got.Brand != "lux" || got.Issuer != "https://lux.id" {
t.Errorf("config on api.lux.network = %+v, want the lux brand", got)
}
}