Skip to content

Commit d3925de

Browse files
test(auth): integration test for dynamic issuer hot-apply
Cover the hot-apply verify seam end to end: a token signed by a freshly registered issuer's key is accepted for its bound org, rejected for another org, and rejected after deregistration. Also cover boot seeding: statically configured issuer URLs are skipped, a JWKS fetch failure is non-fatal, and the reserved-org set is the union of the config static orgs and the DB issuers' static orgs. Signed-off-by: Jan Baraniewski <dev@baraniewski.com>
1 parent 679ddc4 commit d3925de

1 file changed

Lines changed: 245 additions & 0 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package config
5+
6+
import (
7+
"context"
8+
"crypto/rand"
9+
"crypto/rsa"
10+
"encoding/json"
11+
"math/big"
12+
"net/http"
13+
"net/http/httptest"
14+
"testing"
15+
"time"
16+
17+
authz "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/authorization"
18+
cauth "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/config"
19+
"github.com/NVIDIA/infra-controller/rest-api/auth/pkg/processors"
20+
testutil "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/testing"
21+
cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model"
22+
cdbu "github.com/NVIDIA/infra-controller/rest-api/db/pkg/util"
23+
"github.com/golang-jwt/jwt/v5"
24+
"github.com/google/uuid"
25+
"github.com/labstack/echo/v4"
26+
"github.com/rs/zerolog"
27+
"github.com/stretchr/testify/assert"
28+
"github.com/stretchr/testify/require"
29+
)
30+
31+
const testIssuerKID = "test-key-id"
32+
33+
// jwksServerForKey serves a single-RSA-key JWKS.
34+
func jwksServerForKey(t *testing.T, key *rsa.PrivateKey) *httptest.Server {
35+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
36+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
37+
"keys": []map[string]interface{}{{
38+
"kty": "RSA",
39+
"kid": testIssuerKID,
40+
"use": "sig",
41+
"alg": "RS256",
42+
"n": testutil.EncodeBase64URLBigInt(key.N),
43+
"e": testutil.EncodeBase64URLBigInt(big.NewInt(int64(key.E))),
44+
}},
45+
})
46+
}))
47+
t.Cleanup(srv.Close)
48+
return srv
49+
}
50+
51+
func emptyJWKSServer(t *testing.T) *httptest.Server {
52+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+
_, _ = w.Write([]byte(`{"keys":[]}`))
54+
}))
55+
t.Cleanup(srv.Close)
56+
return srv
57+
}
58+
59+
func mintToken(t *testing.T, key *rsa.PrivateKey, issuerURL, sub string) string {
60+
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
61+
"iss": issuerURL,
62+
"sub": sub,
63+
"email": "user@acme.example",
64+
"iat": jwt.NewNumericDate(time.Now()),
65+
"exp": jwt.NewNumericDate(time.Now().Add(time.Hour)),
66+
})
67+
tok.Header["kid"] = testIssuerKID
68+
s, err := tok.SignedString(key)
69+
require.NoError(t, err)
70+
return s
71+
}
72+
73+
func echoCtxForOrg(routeOrg string) echo.Context {
74+
e := echo.New()
75+
req := httptest.NewRequest(http.MethodGet, "/", nil)
76+
ec := e.NewContext(req, httptest.NewRecorder())
77+
ec.SetParamNames("orgName")
78+
ec.SetParamValues(routeOrg)
79+
return ec
80+
}
81+
82+
// TestApplyIssuer_VerifySeam verifies a hot-applied issuer's token is accepted for
83+
// its bound org, rejected for another org, and rejected after deregistration.
84+
func TestApplyIssuer_VerifySeam(t *testing.T) {
85+
ctx := context.Background()
86+
dbSession := cdbu.GetTestDBSession(t, false)
87+
defer dbSession.Close()
88+
require.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.User)(nil)))
89+
90+
key, err := rsa.GenerateKey(rand.Reader, 2048)
91+
require.NoError(t, err)
92+
jwksSrv := jwksServerForKey(t, key)
93+
94+
cfg := NewConfig()
95+
cfg.JwtOriginConfig = cauth.NewJWTOriginConfig()
96+
cfg.JwtOriginConfig.SetProcessorForOrigin(cauth.TokenOriginCustom, processors.NewCustomProcessor(dbSession))
97+
98+
const issuerURL = "https://idp.acme.example"
99+
iss := &cdbm.Issuer{
100+
ID: uuid.New(),
101+
Name: "acme-idp",
102+
IssuerURL: issuerURL,
103+
JWKSURL: jwksSrv.URL,
104+
Origin: cauth.TokenOriginCustom,
105+
ClaimMappings: []cdbm.IssuerClaimMapping{{
106+
OrgName: "tenant-acme",
107+
Roles: []string{authz.TenantAdminRole},
108+
}},
109+
Status: cdbm.IssuerStatusPending,
110+
}
111+
112+
// Hot-apply the issuer.
113+
require.NoError(t, cfg.ApplyIssuer(iss))
114+
115+
jo := cfg.JwtOriginConfig
116+
require.NotNil(t, jo.GetConfig(issuerURL), "ApplyIssuer must install a JwksConfig keyed by the issuer URL")
117+
proc := jo.GetProcessorByIssuer(issuerURL)
118+
require.NotNil(t, proc, "issuer must resolve to the custom processor")
119+
120+
logger := zerolog.Nop()
121+
token := mintToken(t, key, issuerURL, "user-1")
122+
123+
// Own-org request -> accepted, org resolved to the registered binding.
124+
ecOwn := echoCtxForOrg("tenant-acme")
125+
user, apiErr := proc.ProcessToken(ecOwn, token, jo.GetConfig(issuerURL), logger)
126+
require.Nil(t, apiErr, "token from a registered issuer must be accepted for its bound org")
127+
require.NotNil(t, user)
128+
_, oerr := user.OrgData.GetOrgByName("tenant-acme")
129+
assert.NoError(t, oerr, "verified user must carry the issuer's pinned org")
130+
131+
// Cross-org request (same valid token, different route org) -> rejected.
132+
ecCross := echoCtxForOrg("tenant-globex")
133+
_, apiErr = proc.ProcessToken(ecCross, token, jo.GetConfig(issuerURL), logger)
134+
require.NotNil(t, apiErr, "token bound to tenant-acme must not authorize tenant-globex")
135+
assert.Equal(t, http.StatusUnauthorized, apiErr.Code)
136+
137+
// Deregister -> issuer no longer resolves (middleware would return 401).
138+
cfg.RemoveIssuer(issuerURL)
139+
assert.Nil(t, jo.GetConfig(issuerURL))
140+
assert.Nil(t, jo.GetProcessorByIssuer(issuerURL))
141+
}
142+
143+
func TestApplyIssuer_FailedFetchReplacesLiveConfig(t *testing.T) {
144+
key, err := rsa.GenerateKey(rand.Reader, 2048)
145+
require.NoError(t, err)
146+
reachable := jwksServerForKey(t, key)
147+
unreachable := emptyJWKSServer(t)
148+
149+
cfg := &Config{
150+
v: newViper(),
151+
JwtOriginConfig: cauth.NewJWTOriginConfig(),
152+
}
153+
154+
const issuerURL = "https://idp.acme.example"
155+
oldCfg := cauth.NewJwksConfig("old-idp", reachable.URL, issuerURL, cauth.TokenOriginCustom, false, nil, nil)
156+
oldCfg.ClaimMappings = []cauth.ClaimMapping{{OrgName: "tenant-old", Roles: []string{authz.TenantAdminRole}}}
157+
require.NoError(t, oldCfg.UpdateJWKS())
158+
require.Positive(t, oldCfg.KeyCount())
159+
cfg.JwtOriginConfig.AddJwksConfig(oldCfg)
160+
161+
err = cfg.ApplyIssuer(&cdbm.Issuer{
162+
ID: uuid.New(),
163+
Name: "new-idp",
164+
IssuerURL: issuerURL,
165+
JWKSURL: unreachable.URL,
166+
Origin: cauth.TokenOriginCustom,
167+
ClaimMappings: []cdbm.IssuerClaimMapping{{
168+
OrgName: "tenant-new",
169+
Roles: []string{authz.TenantAdminRole},
170+
}},
171+
Status: cdbm.IssuerStatusPending,
172+
})
173+
174+
require.Error(t, err)
175+
got := cfg.JwtOriginConfig.GetConfig(issuerURL)
176+
require.NotNil(t, got)
177+
assert.Equal(t, "new-idp", got.Name)
178+
assert.Equal(t, unreachable.URL, got.URL)
179+
assert.Zero(t, got.KeyCount(), "failed hot-apply must not keep old JWKS keys live")
180+
require.Len(t, got.ClaimMappings, 1)
181+
assert.Equal(t, "tenant-new", got.ClaimMappings[0].OrgName)
182+
}
183+
184+
// TestSeedIssuersFromDB verifies boot seeding: static-config issuers are skipped,
185+
// an unreachable JWKS is non-fatal, and the reserved org set is the union of
186+
// config-static orgs and the DB issuers' static orgs.
187+
func TestSeedIssuersFromDB(t *testing.T) {
188+
ctx := context.Background()
189+
dbSession := cdbu.GetTestDBSession(t, false)
190+
defer dbSession.Close()
191+
require.NoError(t, dbSession.DB.ResetModel(ctx, (*cdbm.Issuer)(nil)))
192+
193+
key, err := rsa.GenerateKey(rand.Reader, 2048)
194+
require.NoError(t, err)
195+
reachable := jwksServerForKey(t, key)
196+
unreachable := emptyJWKSServer(t)
197+
198+
dao := cdbm.NewIssuerDAO(dbSession)
199+
// reachable issuer -> applied with keys
200+
_, err = dao.Create(ctx, nil, cdbm.IssuerCreateInput{
201+
Name: "reachable", IssuerURL: "https://idp.reachable.example", JWKSURL: reachable.URL, Origin: cauth.TokenOriginCustom,
202+
ClaimMappings: []cdbm.IssuerClaimMapping{{OrgName: "tenant-reachable", Roles: []string{"TENANT_ADMIN"}}},
203+
Status: cdbm.IssuerStatusPending, CreatedBy: uuid.New(),
204+
})
205+
require.NoError(t, err)
206+
// unreachable JWKS -> non-fatal, still installed for lazy refresh
207+
_, err = dao.Create(ctx, nil, cdbm.IssuerCreateInput{
208+
Name: "pending", IssuerURL: "https://idp.pending.example", JWKSURL: unreachable.URL, Origin: cauth.TokenOriginCustom,
209+
ClaimMappings: []cdbm.IssuerClaimMapping{{OrgName: "tenant-pending", Roles: []string{"TENANT_ADMIN"}}},
210+
Status: cdbm.IssuerStatusReady, CreatedBy: uuid.New(),
211+
})
212+
require.NoError(t, err)
213+
// issuer URL matching a static config issuer (authn.nvidia.com from config.yaml) -> skipped
214+
_, err = dao.Create(ctx, nil, cdbm.IssuerCreateInput{
215+
Name: "shadow-static", IssuerURL: "authn.nvidia.com", JWKSURL: unreachable.URL, Origin: cauth.TokenOriginKasLegacy,
216+
Status: cdbm.IssuerStatusPending, CreatedBy: uuid.New(),
217+
})
218+
require.NoError(t, err)
219+
220+
cfg := NewConfig()
221+
cfg.JwtOriginConfig = cauth.NewJWTOriginConfig()
222+
223+
require.NoError(t, cfg.SeedIssuersFromDB(ctx, dbSession))
224+
225+
jo := cfg.JwtOriginConfig
226+
require.NotNil(t, jo.GetConfig("https://idp.reachable.example"))
227+
assert.Positive(t, jo.GetConfig("https://idp.reachable.example").KeyCount(), "reachable issuer JWKS should be fetched")
228+
require.NotNil(t, jo.GetConfig("https://idp.pending.example"), "unreachable issuer is still installed for lazy refresh")
229+
assert.Nil(t, jo.GetConfig("authn.nvidia.com"), "statically-configured issuer URL must be skipped")
230+
231+
reachableRow, err := dao.GetByIssuerURL(ctx, nil, "https://idp.reachable.example")
232+
require.NoError(t, err)
233+
assert.Equal(t, cdbm.IssuerStatusReady, reachableRow.Status)
234+
pendingRow, err := dao.GetByIssuerURL(ctx, nil, "https://idp.pending.example")
235+
require.NoError(t, err)
236+
assert.Equal(t, cdbm.IssuerStatusPending, pendingRow.Status)
237+
238+
// reserved org set includes the DB issuers' static orgs. Every installed
239+
// config is wired to the shared set, so assert via a wired config's own
240+
// ReservedOrgNames (the same read path the auth hot-path uses).
241+
reachableCfg := jo.GetConfig("https://idp.reachable.example")
242+
require.NotNil(t, reachableCfg.ReservedOrgNames, "installed config must be wired to the reserved-org set")
243+
assert.True(t, reachableCfg.ReservedOrgNames.Has("tenant-reachable"))
244+
assert.True(t, reachableCfg.ReservedOrgNames.Has("tenant-pending"))
245+
}

0 commit comments

Comments
 (0)