Refuse an auth provider Base cannot serve

Auth is Hanzo IAM and nothing else, so the registry a provider name was
once checked against is gone, and with it every check. A collection
could declare a google login and save clean, while auth-methods answers
iam whatever that collection stores. One name validates now, and it
lives in core beside the check rather than as a literal in apis.

The tests describing the removed registry are rewritten around the
provider that exists, fixtures included. TestAnchorStatusAfterClose
demanded an unchanged anchor height across a Close that joins the
anchor goroutine, so a submit in flight still landed and it failed 4
runs in 60; it now asserts the height cannot rewind.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-10 21:32:48 -07:00
co-authored by Hanzo Dev
parent d4d6f486ea
commit 9397feac1c
8 changed files with 73 additions and 55 deletions
+11 -11
View File
@@ -795,17 +795,17 @@ The `@hanzo/iam/browser` SDK does a PKCE redirect against whatever that
endpoint is, with no client-side branching.
`/auth-methods` is the one survivor that still speaks OAuth2, and it only
ADVERTISES. It builds a discovery response from a collection's configured
providers — display name, auth URL, a fresh `state`, a PKCE verifier and
challenge — while the endpoint that would spend them, `auth-with-oauth2`, is
gone with the rest of the rip and 404s (guarded in `apis/middlewares_test.go`
alongside `auth-with-password`, `auth-with-otp` and `oauth2-redirect`). A client
that followed one of those URLs would come back to nothing. It is inert rather
than broken: the response is empty unless a collection sets `OAuth2.Enabled`,
which nothing does under IAM, and the code says it is kept so a standalone Base's
admin panel can still list what was registered. `tools/auth` carries ~30 provider
implementations for the same reason, of which only `DisplayName`/`PKCE` and the
URL builder are ever reached — no `FetchAuthUser` in that package has a caller.
ADVERTISES: one provider, `iam` (`core.OAuth2ProviderIAM`), carrying a fresh
`state` and a PKCE verifier and challenge, pointing at IAM's own authorize
endpoint, which it builds from the JWKS URL the platform plugin stored at boot.
Every auth collection gets that same answer, `_superusers` included — the
provider belongs to the deployment, not to a collection. A collection may still
STORE an oauth2 config for a standalone admin panel to list, and `iam` is the
only name that validates, so it cannot advertise a login Base will never serve.
`auth-with-oauth2`, the endpoint that would spend a `state`, is gone with the
rest of the rip and 404s (guarded in `apis/middlewares_test.go` alongside
`auth-with-password`, `auth-with-otp` and `oauth2-redirect`); the flow completes
at IAM and comes back as a token Base verifies against IAM's JWKS.
`resolveJWKSToken` (`apis/middlewares.go`) mirrors the verified token into an
ephemeral auth record — nothing is written, IAM stays the user store. The
+5 -5
View File
@@ -9,10 +9,10 @@ import (
)
// externalIAMAuthMethods returns the auth-methods response a client
// sees when Base is configured with an external IAM. It exposes a
// single OAuth2 provider named "iam" — generic, no brand string in
// Base code. The IAM endpoint is recovered from the JWKS URL the
// platform plugin stored at boot.
// sees when Base is configured with an external IAM. It exposes the
// single OAuth2 provider [core.OAuth2ProviderIAM] — generic, no brand
// string in Base code. The IAM endpoint is recovered from the JWKS URL
// the platform plugin stored at boot.
//
// IAM_DISPLAY_NAME (optional env) feeds the UI button label; if
// unset, DisplayName is empty and the UI renders a neutral fallback.
@@ -33,7 +33,7 @@ func externalIAMAuthMethods(e *core.RequestEvent) authMethodsResponse {
}
info := providerInfo{
Name: "iam",
Name: core.OAuth2ProviderIAM,
DisplayName: os.Getenv("IAM_DISPLAY_NAME"),
State: state,
AuthURL: authURL,
+18 -11
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"testing"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tests"
)
@@ -29,31 +30,37 @@ func TestRecordAuthMethodsList(t *testing.T) {
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth collection with no OAuth2 providers",
// the collection stores a disabled oauth2 config, and it makes no
// difference: auth is IAM, so every auth collection advertises IAM.
Name: "auth collection, IAM unconfigured",
Method: http.MethodGet,
URL: "/v1/collections/nologin/auth-methods",
ExpectedStatus: 200,
ExpectedContent: []string{
`"oauth2":{"providers":[],"enabled":false}`,
`"oauth2":{`,
`"providers":[{`,
`"name":"iam"`,
`"enabled":true`,
`"authURL":""`, // nowhere to send anyone until IAM is named
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth collection with OAuth2 providers",
Method: http.MethodGet,
URL: "/v1/collections/users/auth-methods",
Name: "auth collection, IAM configured",
Method: http.MethodGet,
URL: "/v1/collections/users/auth-methods",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(apis.StoreKeyJWKSURL, "https://iam.example.com/v1/iam/.well-known/jwks")
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"oauth2":{`,
`"providers":[{`,
`"name":"google"`,
`"name":"gitlab"`,
`"name":"iam"`,
`"state":`,
`"displayName":`,
`"codeVerifier":`,
`"codeChallenge":`,
`"codeChallengeMethod":`,
`"authURL":`,
`"codeChallengeMethod":"S256"`,
`"authURL":"https://iam.example.com/v1/iam/oauth/authorize?response_type=code`,
`redirect_uri="`, // ensures that the redirect_uri is the last url param
},
ExpectedEvents: map[string]int{"*": 0},
+18 -1
View File
@@ -223,6 +223,11 @@ type OAuth2KnownFields struct {
AvatarURL string `form:"avatarURL" json:"avatarURL"`
}
// OAuth2ProviderIAM names the one provider Base has. Auth is Hanzo IAM
// (HIP-0111), so a provider config naming anything else describes a login
// route that does not exist and that no collection can ever offer.
const OAuth2ProviderIAM = "iam"
type OAuth2Config struct {
Providers []OAuth2ProviderConfig `form:"providers" json:"providers"`
@@ -299,7 +304,7 @@ type OAuth2ProviderConfig struct {
// Validate makes OAuth2ProviderConfig validatable by implementing [validation.Validatable] interface.
func (c OAuth2ProviderConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.Name, validation.Required),
validation.Field(&c.Name, validation.Required, validation.By(checkProviderName)),
validation.Field(&c.ClientId, validation.Required),
validation.Field(&c.ClientSecret, validation.Required),
validation.Field(&c.AuthURL, is.URL),
@@ -308,3 +313,15 @@ func (c OAuth2ProviderConfig) Validate() error {
)
}
// checkProviderName refuses a name no provider answers to. Required runs
// first, so an empty name is reported once as missing rather than twice.
func checkProviderName(value any) error {
name, _ := value.(string)
if name == OAuth2ProviderIAM {
return nil
}
return validation.NewError("validation_unknown_provider", "The only auth provider is {{.iam}}.").
SetParams(map[string]any{"iam": OAuth2ProviderIAM})
}
+12 -19
View File
@@ -434,26 +434,20 @@ func TestOAuth2ConfigGetProviderConfig(t *testing.T) {
}{
{
"zero value",
"gitlab",
core.OAuth2Config{},
false,
},
{
"empty config with valid provider",
"gitlab",
core.OAuth2ProviderIAM,
core.OAuth2Config{},
false,
},
{
"non-empty config with missing provider",
"gitlab",
core.OAuth2Config{Providers: []core.OAuth2ProviderConfig{{Name: "google"}, {Name: "github"}}},
"missing",
core.OAuth2Config{Providers: []core.OAuth2ProviderConfig{{Name: core.OAuth2ProviderIAM}}},
false,
},
{
"config with existing provider",
"github",
core.OAuth2Config{Providers: []core.OAuth2ProviderConfig{{Name: "google"}, {Name: "github"}}},
core.OAuth2ProviderIAM,
core.OAuth2Config{Providers: []core.OAuth2ProviderConfig{{Name: core.OAuth2ProviderIAM}}},
true,
},
}
@@ -505,23 +499,22 @@ func TestOAuth2ConfigValidate(t *testing.T) {
{
"known provider with invalid data",
core.OAuth2Config{Enabled: true, Providers: []core.OAuth2ProviderConfig{
{Name: "gitlab", ClientId: "abc", TokenURL: "!invalid!"},
{Name: core.OAuth2ProviderIAM, ClientId: "abc", TokenURL: "!invalid!"},
}},
[]string{"providers"},
},
{
"known provider with valid data",
core.OAuth2Config{Enabled: true, Providers: []core.OAuth2ProviderConfig{
{Name: "gitlab", ClientId: "abc", ClientSecret: "456", TokenURL: "https://example.com"},
{Name: core.OAuth2ProviderIAM, ClientId: "abc", ClientSecret: "456", TokenURL: "https://example.com"},
}},
[]string{},
},
{
"known provider with valid data (duplicated)",
core.OAuth2Config{Enabled: true, Providers: []core.OAuth2ProviderConfig{
{Name: "gitlab", ClientId: "abc1", ClientSecret: "1", TokenURL: "https://example1.com"},
{Name: "google", ClientId: "abc2", ClientSecret: "2", TokenURL: "https://example2.com"},
{Name: "gitlab", ClientId: "abc3", ClientSecret: "3", TokenURL: "https://example3.com"},
{Name: core.OAuth2ProviderIAM, ClientId: "abc1", ClientSecret: "1", TokenURL: "https://example1.com"},
{Name: core.OAuth2ProviderIAM, ClientId: "abc2", ClientSecret: "2", TokenURL: "https://example2.com"},
}},
[]string{"providers"},
},
@@ -549,7 +542,7 @@ func TestOAuth2ProviderConfigValidate(t *testing.T) {
},
{
"minimum valid data",
core.OAuth2ProviderConfig{Name: "gitlab", ClientId: "abc", ClientSecret: "456"},
core.OAuth2ProviderConfig{Name: core.OAuth2ProviderIAM, ClientId: "abc", ClientSecret: "456"},
[]string{},
},
{
@@ -560,7 +553,7 @@ func TestOAuth2ProviderConfigValidate(t *testing.T) {
{
"invalid urls",
core.OAuth2ProviderConfig{
Name: "gitlab",
Name: core.OAuth2ProviderIAM,
ClientId: "abc",
ClientSecret: "456",
AuthURL: "!invalid!",
@@ -572,7 +565,7 @@ func TestOAuth2ProviderConfigValidate(t *testing.T) {
{
"valid urls",
core.OAuth2ProviderConfig{
Name: "gitlab",
Name: core.OAuth2ProviderIAM,
ClientId: "abc",
ClientSecret: "456",
AuthURL: "https://example.com/a",
+6 -5
View File
@@ -568,11 +568,12 @@ func TestAnchorStatusAfterClose(t *testing.T) {
// AnchorStatus after Close must not panic and must return last snapshot.
postSt := doc.AnchorStatus()
if postSt.LastHeight == 0 && preSt.LastHeight > 0 {
t.Fatal("AnchorStatus after Close lost data")
}
if postSt.LastHeight != preSt.LastHeight {
t.Fatalf("AnchorStatus changed after Close: pre=%d post=%d", preSt.LastHeight, postSt.LastHeight)
// Close joins the anchor goroutine rather than abandoning it, so a submit
// already in flight when Close is called still lands and the height may
// climb across it. The height is a count of anchors written: it may stand
// still or rise, and losing it is the only wrong answer.
if postSt.LastHeight < preSt.LastHeight {
t.Fatalf("AnchorStatus rewound after Close: pre=%d post=%d", preSt.LastHeight, postSt.LastHeight)
}
}
+3 -3
View File
@@ -287,7 +287,7 @@ func init() {
collection.Indexes = types.JSONArray[string]{"create index test on new_name (id)"}
collection.ManageRule = types.Pointer("1 != 2")
// should be ignored
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}}
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: core.OAuth2ProviderIAM, ClientId: "abc", ClientSecret: "123"}}
testSecret := strings.Repeat("a", 30)
collection.AuthToken.Secret = testSecret
collection.FileToken.Secret = testSecret
@@ -995,7 +995,7 @@ func init() {
collection.OAuth2.Enabled = true
collection.FileToken.Duration = 10
// should be ignored
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}}
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: core.OAuth2ProviderIAM, ClientId: "abc", ClientSecret: "123"}}
testSecret := strings.Repeat("b", 30)
collection.AuthToken.Secret = testSecret
collection.FileToken.Secret = testSecret
@@ -1085,7 +1085,7 @@ func TestAutomigrateCollectionNoChanges(t *testing.T) {
app.Bootstrap()
// should be ignored
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}}
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: core.OAuth2ProviderIAM, ClientId: "abc", ClientSecret: "123"}}
testSecret := strings.Repeat("b", 30)
collection.AuthToken.Secret = testSecret
collection.FileToken.Secret = testSecret
Binary file not shown.