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]>
This commit is contained in:
Livio Spring
2026-08-14 06:12:45 +02:00
committed by GitHub
co-authored by Cursor
parent a4266fa7fc
commit 260446f91f
52 changed files with 854 additions and 20 deletions
+1
View File
@@ -20,6 +20,7 @@ type authZRepo interface {
VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error)
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error)
ExistsOrg(ctx context.Context, id, domain string) (string, error)
CheckOrgActive(ctx context.Context, orgID string) error
}
var _ AccessTokenVerifier = (*AccessTokenVerifierFromRepo)(nil)
+7
View File
@@ -15,6 +15,7 @@ type APITokenVerifier interface {
CheckAuthMethod(method string) (Option, bool)
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (_ string, _ []string, err error)
ExistsOrg(ctx context.Context, id, domain string) (orgID string, err error)
CheckOrgActive(ctx context.Context, orgID string) error
SearchMyMemberships(ctx context.Context, orgID string, shouldTriggerBulk bool) (_ []*Membership, err error)
}
@@ -63,6 +64,12 @@ func (v *ApiTokenVerifier) ExistsOrg(ctx context.Context, id, domain string) (or
return v.authZRepo.ExistsOrg(ctx, id, domain)
}
func (v *ApiTokenVerifier) CheckOrgActive(ctx context.Context, orgID string) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
return v.authZRepo.CheckOrgActive(ctx, orgID)
}
func (v *ApiTokenVerifier) CheckAuthMethod(method string) (Option, bool) {
authOpt, ok := v.authMethods[method]
return authOpt, ok
+8
View File
@@ -71,6 +71,7 @@ const (
type TokenVerifier interface {
ExistsOrg(ctx context.Context, id, domain string) (string, error)
CheckOrgActive(ctx context.Context, orgID string) error
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error)
AccessTokenVerifier
SystemTokenVerifier
@@ -132,6 +133,13 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain st
return CtxData{}, zerrors.ThrowPermissionDenied(nil, "AUTH-Bs7Ds", "Organisation doesn't exist")
}
}
// Reject tokens whose caller's organization is inactive. Gate resourceOwner, not the
// resolved target orgID, so instance admins can still manage a deactivated org.
if resourceOwner != "" {
if err = t.CheckOrgActive(ctx, resourceOwner); err != nil {
return CtxData{}, zerrors.ThrowUnauthenticated(err, "AUTH-oR9nA", "Errors.Org.NotActive")
}
}
return CtxData{
UserID: userID,
OrgID: orgID,
+137
View File
@@ -0,0 +1,137 @@
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)
})
}
}
@@ -0,0 +1,50 @@
//go:build integration
package org_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/auth"
"github.com/zitadel/zitadel/pkg/grpc/org/v2"
)
func TestServer_DeactivateOrganization_RejectsCallerToken(t *testing.T) {
orgResp := Instance.CreateOrganization(CTX, integration.OrganizationName(), integration.Email())
userID := orgResp.GetCreatedAdmins()[0].GetUserId()
pat := Instance.CreatePersonalAccessToken(CTX, userID)
userCtx := integration.WithAuthorizationToken(CTX, pat.GetToken())
_, err := Instance.Client.Auth.GetMyUser(userCtx, &auth.GetMyUserRequest{})
require.NoError(t, err)
_, err = Client.DeactivateOrganization(CTX, &org.DeactivateOrganizationRequest{
OrganizationId: orgResp.GetOrganizationId(),
})
require.NoError(t, err)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(collect *assert.CollectT) {
_, getErr := Instance.Client.Auth.GetMyUser(userCtx, &auth.GetMyUserRequest{})
assert.Error(collect, getErr)
}, retryDuration, tick, "timeout waiting for deactivated org to reject caller token")
// Instance admin can still target the deactivated org.
_, err = Client.ListOrganizations(CTX, &org.ListOrganizationsRequest{
Queries: []*org.SearchQuery{
{
Query: &org.SearchQuery_IdQuery{
IdQuery: &org.OrganizationIDQuery{
Id: orgResp.GetOrganizationId(),
},
},
},
},
})
require.NoError(t, err)
}
@@ -37,6 +37,10 @@ func (v *authzRepoMock) ExistsOrg(ctx context.Context, orgID, domain string) (st
return orgID, nil
}
func (v *authzRepoMock) CheckOrgActive(ctx context.Context, orgID string) error {
return nil
}
func (v *authzRepoMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) {
return "", "", nil
}
@@ -37,6 +37,10 @@ func (v *authzRepoMock) ExistsOrg(ctx context.Context, orgID, domain string) (st
return orgID, nil
}
func (v *authzRepoMock) CheckOrgActive(ctx context.Context, orgID string) error {
return nil
}
func (v *authzRepoMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) {
return "", "", nil
}
+7
View File
@@ -126,6 +126,13 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
if !user.State.IsEnabled() {
return nil, zerrors.ThrowPreconditionFailed(nil, "SESSION-Gj4ko", "Errors.User.NotActive")
}
org, err := s.query.OrgByID(ctx, user.ResourceOwner)
if err != nil {
return nil, err
}
if !org.State.IsActive() {
return nil, zerrors.ThrowPreconditionFailed(nil, "SESSION-oL9nT", "Errors.User.NotActive")
}
var preferredLanguage *language.Tag
if user.Human != nil && !user.Human.PreferredLanguage.IsRoot() {
@@ -347,6 +347,13 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
if !user.State.IsEnabled() {
return nil, zerrors.ThrowPreconditionFailed(nil, "SESSION-Gj4ko", "Errors.User.NotActive")
}
org, err := s.query.OrgByID(ctx, user.ResourceOwner)
if err != nil {
return nil, err
}
if !org.State.IsActive() {
return nil, zerrors.ThrowPreconditionFailed(nil, "SESSION-oL9nT", "Errors.User.NotActive")
}
var preferredLanguage *language.Tag
if user.Human != nil && !user.Human.PreferredLanguage.IsRoot() {
+14
View File
@@ -210,6 +210,13 @@ func (p *Storage) SetUserinfoWithUserID(ctx context.Context, applicationID strin
if user.State != domain.UserStateActive {
return zerrors.ThrowPreconditionFailed(nil, "SAML-S3gFd", "Errors.User.NotActive")
}
org, err := p.query.OrgByID(ctx, user.ResourceOwner)
if err != nil {
return err
}
if !org.State.IsActive() {
return zerrors.ThrowPreconditionFailed(nil, "SAML-oR9nA", "Errors.User.NotActive")
}
userGrants, err := p.getGrants(ctx, userID, applicationID)
if err != nil {
@@ -239,6 +246,13 @@ func (p *Storage) SetUserinfoWithLoginName(ctx context.Context, userinfo models.
if user.State != domain.UserStateActive {
return zerrors.ThrowPreconditionFailed(nil, "SAML-FJ262", "Errors.User.NotActive")
}
org, err := p.query.OrgByID(ctx, user.ResourceOwner)
if err != nil {
return err
}
if !org.State.IsActive() {
return zerrors.ThrowPreconditionFailed(nil, "SAML-pQ2mB", "Errors.User.NotActive")
}
setUserinfo(user, userinfo, attributes, map[string]*customAttribute{})
return nil