Files
260446f91f Merge commit from fork
* fix: block Login V2 auth for users in deactivated organizations

Enforce organization state on session creation, OIDC token issuance/refresh,
and SAML session creation so deactivated org users cannot authenticate.

Co-authored-by: Cursor <[email protected]>

* fix: reject claim serving for users in deactivated organizations

Require an active resource-owner org in OIDC userinfo and SAML attribute
paths, and use distinct error IDs for inactive user vs inactive org at
token issuance.

Co-authored-by: Cursor <[email protected]>

* fix: drop sessions and tokens when an organization is deactivated

Mirror OrgRemoved cleanup for OrgDeactivated in the V2 session projection
and V1 auth user_session, token, and refresh_token handlers. Also delete
V2 sessions on OrgRemoved, which does not emit per-user removal events.

Co-authored-by: Cursor <[email protected]>

* fix: reject API tokens from deactivated organizations

Check the caller's resource-owner org state in authz middleware via a
cached OrgByID lookup, so already-issued tokens lose ZITADEL API access
when their organization is deactivated. Return unauthenticated (401).
Target org remains unrestricted so instance admins can still manage
deactivated orgs.

Co-authored-by: Cursor <[email protected]>

* fix: invalidate OIDC refresh tokens after org deactivation

Reject refresh exchange if the user's organization was deactivated
after the refresh token was issued, so grants stay dead after reactivate.

Co-authored-by: Cursor <[email protected]>

* fix: invalidate V2 access tokens after org deactivation

Treat OrgDeactivated after the token position as session termination in
ActiveAccessTokenByToken, so issued ATs stay dead after reactivation.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-08-14 06:12:45 +02:00

138 lines
4.2 KiB
Go

package authz
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/zerrors"
)
type tokenVerifierMock struct {
AccessTokenVerifier
SystemTokenVerifier
existsOrg func(ctx context.Context, id, domain string) (string, error)
checkOrgActive func(ctx context.Context, orgID string) error
projectIDOrigin func(ctx context.Context, clientID string) (string, []string, error)
}
func (m *tokenVerifierMock) RegisterServer(string, string, MethodMapping) {}
func (m *tokenVerifierMock) CheckAuthMethod(string) (Option, bool) {
return Option{}, false
}
func (m *tokenVerifierMock) SearchMyMemberships(context.Context, string, bool) ([]*Membership, error) {
return nil, nil
}
func (m *tokenVerifierMock) ExistsOrg(ctx context.Context, id, domain string) (string, error) {
if m.existsOrg != nil {
return m.existsOrg(ctx, id, domain)
}
if id != "" {
return id, nil
}
return domain, nil
}
func (m *tokenVerifierMock) CheckOrgActive(ctx context.Context, orgID string) error {
if m.checkOrgActive != nil {
return m.checkOrgActive(ctx, orgID)
}
return nil
}
func (m *tokenVerifierMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) {
if m.projectIDOrigin != nil {
return m.projectIDOrigin(ctx, clientID)
}
return "", nil, nil
}
func TestVerifyTokenAndCreateCtxData_orgActive(t *testing.T) {
activeCaller := AccessTokenVerifierFunc(func(context.Context, string) (string, string, string, string, string, error) {
return "user1", "", "", "", "caller-org", nil
})
systemTokenNOK := SystemTokenVerifierFunc(func(context.Context, string, string) (Memberships, string, error) {
return nil, "", zerrors.ThrowUnauthenticated(nil, "TEST-sys", "unauthenticated")
})
tests := []struct {
name string
orgID string
orgDomain string
accessToken AccessTokenVerifier
checkOrgActive func(context.Context, string) error
wantErr func(error) bool
wantResource string
wantOrgID string
}{
{
name: "active caller org",
accessToken: activeCaller,
wantResource: "caller-org",
wantOrgID: "caller-org",
},
{
name: "deactivated caller org",
accessToken: activeCaller,
checkOrgActive: func(_ context.Context, orgID string) error {
assert.Equal(t, "caller-org", orgID)
return zerrors.ThrowPreconditionFailed(nil, "QUERY-oR9nA", "Errors.Org.NotActive")
},
wantErr: zerrors.IsUnauthenticated,
},
{
name: "deactivated target org with active caller",
orgID: "target-org",
accessToken: activeCaller,
checkOrgActive: func(_ context.Context, orgID string) error {
// Must check the caller's org, not the target.
assert.Equal(t, "caller-org", orgID)
return nil
},
wantResource: "caller-org",
wantOrgID: "target-org",
},
{
name: "empty resource owner for system token",
accessToken: AccessTokenVerifierFunc(func(context.Context, string) (string, string, string, string, string, error) {
return "", "", "", "", "", zerrors.ThrowUnauthenticated(nil, "TEST-tok", "unauthenticated")
}),
orgID: "system-org",
checkOrgActive: func(context.Context, string) error {
t.Fatal("CheckOrgActive must not be called for system tokens without resource owner")
return nil
},
wantOrgID: "system-org",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verifier := &tokenVerifierMock{
AccessTokenVerifier: tt.accessToken,
SystemTokenVerifier: SystemTokenVerifierFunc(func(ctx context.Context, token, orgID string) (Memberships, string, error) {
if tt.name == "empty resource owner for system token" {
return Memberships{{MemberType: MemberTypeSystem, Roles: []string{"SYSTEM_OWNER"}}}, "system-user", nil
}
return systemTokenNOK.VerifySystemToken(ctx, token, orgID)
}),
checkOrgActive: tt.checkOrgActive,
}
got, err := VerifyTokenAndCreateCtxData(context.Background(), BearerPrefix+"token", tt.orgID, tt.orgDomain, verifier, nil)
if tt.wantErr != nil {
require.Error(t, err)
assert.True(t, tt.wantErr(err))
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantResource, got.ResourceOwner)
assert.Equal(t, tt.wantOrgID, got.OrgID)
})
}
}