From a0489c85ff0df190f577405903f8c5668f8bd249 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Sun, 8 Feb 2026 10:01:22 +0300 Subject: [PATCH 01/16] feat: Add models for JWT claims, organization roles, and Zitadel API client --- internal/models/claims.go | 10 +++ internal/models/config.go | 8 +++ internal/models/invitation.go | 21 ++++++ internal/models/jwks.go | 122 ++++++++++++++++++++++++++++++++ internal/models/organization.go | 19 +++++ internal/models/zitadel.go | 95 +++++++++++++++++++++++++ 6 files changed, 275 insertions(+) create mode 100644 internal/models/claims.go create mode 100644 internal/models/config.go create mode 100644 internal/models/invitation.go create mode 100644 internal/models/jwks.go create mode 100644 internal/models/organization.go create mode 100644 internal/models/zitadel.go diff --git a/internal/models/claims.go b/internal/models/claims.go new file mode 100644 index 0000000..6596276 --- /dev/null +++ b/internal/models/claims.go @@ -0,0 +1,10 @@ +package models + +// Claims represents the validated JWT claims from Zitadel. +type Claims struct { + Sub string `json:"sub"` + Email string `json:"email"` + OrgID string `json:"urn:zitadel:iam:org:id"` + OrgDomain string `json:"urn:zitadel:iam:user:resourceowner:primary_domain"` + Roles map[string]interface{} `json:"urn:zitadel:iam:org:project:roles"` +} diff --git a/internal/models/config.go b/internal/models/config.go new file mode 100644 index 0000000..37143d9 --- /dev/null +++ b/internal/models/config.go @@ -0,0 +1,8 @@ +package models + +// Config holds the configuration for the auth middleware. +type Config struct { + IssuerURL string + Audience string + SkipPaths []string +} diff --git a/internal/models/invitation.go b/internal/models/invitation.go new file mode 100644 index 0000000..916d413 --- /dev/null +++ b/internal/models/invitation.go @@ -0,0 +1,21 @@ +package models + +import "time" + +// InvitationClaims represents the claims embedded in an invitation token. +type InvitationClaims struct { + InvitationID string `json:"invitation_id"` + OrgID string `json:"org_id"` + Email string `json:"email"` + Role string `json:"role"` + ExpiresAt time.Time `json:"expires_at"` + CreatedBy string `json:"created_by"` +} + +// AccessCode represents a secure one-time access code for invitation acceptance. +type AccessCode struct { + Code string `json:"code"` + InvitationID string `json:"invitation_id"` + ExpiresAt time.Time `json:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty"` +} diff --git a/internal/models/jwks.go b/internal/models/jwks.go new file mode 100644 index 0000000..ac20790 --- /dev/null +++ b/internal/models/jwks.go @@ -0,0 +1,122 @@ +package models + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "sync" + "time" +) + +// JWKSKey represents a single key from the JWKS endpoint. +type JWKSKey struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` +} + +// JWKSResponse represents the JWKS endpoint response. +type JWKSResponse struct { + Keys []JWKSKey `json:"keys"` +} + +// JWKSCache fetches and caches JWKS keys from the identity provider. +type JWKSCache struct { + JWKSURL string + Keys map[string]*rsa.PublicKey + Mu sync.RWMutex + LastFetch time.Time + CacheTTL time.Duration + HTTPClient *http.Client +} + +// GetKey returns the RSA public key for the given key ID. +func (j *JWKSCache) GetKey(kid string) (*rsa.PublicKey, error) { + // Try cached key first + j.Mu.RLock() + if key, ok := j.Keys[kid]; ok && time.Since(j.LastFetch) < j.CacheTTL { + j.Mu.RUnlock() + return key, nil + } + j.Mu.RUnlock() + + // Fetch fresh keys + if err := j.refresh(); err != nil { + return nil, fmt.Errorf("failed to refresh JWKS: %w", err) + } + + j.Mu.RLock() + defer j.Mu.RUnlock() + key, ok := j.Keys[kid] + if !ok { + return nil, fmt.Errorf("key %q not found in JWKS", kid) + } + return key, nil +} + +func (j *JWKSCache) refresh() error { + j.Mu.Lock() + defer j.Mu.Unlock() + + // Double-check after acquiring write lock + if time.Since(j.LastFetch) < 30*time.Second { + return nil + } + + resp, err := j.HTTPClient.Get(j.JWKSURL) + if err != nil { + return fmt.Errorf("JWKS fetch failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) + } + + var jwks JWKSResponse + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return fmt.Errorf("failed to decode JWKS: %w", err) + } + + newKeys := make(map[string]*rsa.PublicKey, len(jwks.Keys)) + for _, k := range jwks.Keys { + if k.Kty != "RSA" || k.Use != "sig" { + continue + } + pubKey, err := parseRSAPublicKey(k.N, k.E) + if err != nil { + continue + } + newKeys[k.Kid] = pubKey + } + + j.Keys = newKeys + j.LastFetch = time.Now() + return nil +} + +func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(nStr) + if err != nil { + return nil, fmt.Errorf("invalid modulus: %w", err) + } + + eBytes, err := base64.RawURLEncoding.DecodeString(eStr) + if err != nil { + return nil, fmt.Errorf("invalid exponent: %w", err) + } + + n := new(big.Int).SetBytes(nBytes) + e := 0 + for _, b := range eBytes { + e = e<<8 + int(b) + } + + return &rsa.PublicKey{N: n, E: e}, nil +} diff --git a/internal/models/organization.go b/internal/models/organization.go new file mode 100644 index 0000000..84dcf2d --- /dev/null +++ b/internal/models/organization.go @@ -0,0 +1,19 @@ +package models + +// OrgRole represents organization-level role types. +type OrgRole string + +// Organization role constants matching Zitadel role keys. +const ( + // OrgRoleOwner has full control over the organization. + OrgRoleOwner OrgRole = "orgowner" + + // OrgRoleAdmin can manage members and most organization settings. + OrgRoleAdmin OrgRole = "orgadmin" + + // OrgRoleMember has standard access to organization resources. + OrgRoleMember OrgRole = "orgmember" + + // OrgRoleViewer has read-only access to organization resources. + OrgRoleViewer OrgRole = "orgviewer" +) diff --git a/internal/models/zitadel.go b/internal/models/zitadel.go new file mode 100644 index 0000000..cabfecf --- /dev/null +++ b/internal/models/zitadel.go @@ -0,0 +1,95 @@ +package models + +import ( + "net/http" + "time" +) + +// ZitadelClient provides a wrapper around the Zitadel Management API. +type ZitadelClient struct { + BaseURL string + ServiceToken string + ProjectID string + HTTPClient *http.Client +} + +// ZitadelConfig holds the configuration for the Zitadel API client. +type ZitadelConfig struct { + IssuerURL string + ServiceToken string + ProjectID string + Timeout time.Duration +} + +// CreateUserRequest contains the parameters for creating a new user. +type CreateUserRequest struct { + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Password string `json:"password,omitempty"` + OrgID string `json:"org_id"` +} + +// CreateUserResponse contains the response from creating a user. +type CreateUserResponse struct { + UserID string `json:"user_id"` + Email string `json:"email"` +} + +// UserResponse contains the details of a user retrieved from Zitadel. +type UserResponse struct { + UserID string `json:"user_id"` + UserName string `json:"user_name"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + State string `json:"state"` +} + +// ZitadelCreateUserRequestBody is the internal API request format. +type ZitadelCreateUserRequestBody struct { + UserName string `json:"username"` + Profile struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + } `json:"profile"` + Email struct { + Email string `json:"email"` + IsVerified bool `json:"is_email_verified"` + } `json:"email"` + Password string `json:"password,omitempty"` +} + +// ZitadelCreateUserResponseBody is the internal API response format. +type ZitadelCreateUserResponseBody struct { + UserID string `json:"user_id"` + Details struct { + Sequence string `json:"sequence"` + CreationDate time.Time `json:"creation_date"` + ChangeDate time.Time `json:"change_date"` + ResourceOwner string `json:"resource_owner"` + } `json:"details"` +} + +// ZitadelRoleAssignment is the internal API format for role assignments. +type ZitadelRoleAssignment struct { + RoleKeys []string `json:"role_keys"` +} + +// ZitadelGetUserResponseBody is the internal API response format for GetUser. +type ZitadelGetUserResponseBody struct { + User struct { + UserID string `json:"user_id"` + UserName string `json:"user_name"` + State string `json:"state"` + Human struct { + Profile struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + } `json:"profile"` + Email struct { + Email string `json:"email"` + } `json:"email"` + } `json:"human"` + } `json:"user"` +} From af294344a23724dbbdbe6277ffac23edeaac5dcc Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Sun, 8 Feb 2026 10:01:50 +0300 Subject: [PATCH 02/16] refactor: Replace Claims struct with alias to models.Claims for backward compatibility --- claims.go | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/claims.go b/claims.go index c5673d3..94e7785 100644 --- a/claims.go +++ b/claims.go @@ -1,29 +1,14 @@ package authkit import ( + "github.com/Prescott-Data/dromos-authkit/internal/models" "github.com/gin-gonic/gin" ) const claimsKey = "dromos_auth_claims" -// Claims represents the validated JWT claims from Zitadel. -type Claims struct { - // Sub is the Zitadel user ID. - Sub string `json:"sub"` - - // Email is the user's email address. - Email string `json:"email"` - - // OrgID is the Zitadel organization ID the user belongs to. - OrgID string `json:"urn:zitadel:iam:org:id"` - - // OrgDomain is the primary domain of the user's resource owner organization. - OrgDomain string `json:"urn:zitadel:iam:user:resourceowner:primary_domain"` - - // Roles maps role names to their grant details. - // The keys are role names (e.g. "admin", "editor"). - Roles map[string]interface{} `json:"urn:zitadel:iam:org:project:roles"` -} +// Claims is an alias to models.Claims for backward compatibility. +type Claims = models.Claims // SetClaims stores validated claims in the Gin context. func SetClaims(c *gin.Context, claims *Claims) { From 77dc75073ba7b9f3e438acb971e8e18b1b5f7371 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Sun, 8 Feb 2026 10:01:58 +0300 Subject: [PATCH 03/16] refactor: Simplify Config definition by aliasing models.Config for backward compatibility --- config.go | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/config.go b/config.go index f619281..76c062d 100644 --- a/config.go +++ b/config.go @@ -1,14 +1,6 @@ package authkit -// Config holds the configuration for the auth middleware. -type Config struct { - // IssuerURL is the Zitadel issuer URL (e.g. "http://172.191.51.250:8080"). - IssuerURL string +import "github.com/Prescott-Data/dromos-authkit/internal/models" - // Audience is the expected audience claim (Zitadel project ID). - Audience string - - // SkipPaths lists route paths that bypass authentication (e.g. health checks). - // These should match Gin's FullPath() patterns (e.g. "/api/v1/health"). - SkipPaths []string -} +// Config is an alias to models.Config for backward compatibility. +type Config = models.Config From e9b2aff53254fa570758b3b65a9c281af5cbb543 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Sun, 8 Feb 2026 10:02:10 +0300 Subject: [PATCH 04/16] feat: Add error definitions for invitation and organization actions --- errors.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 errors.go diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..34c57f6 --- /dev/null +++ b/errors.go @@ -0,0 +1,32 @@ +package authkit + +import "errors" + +// Invitation and access code errors. +var ( + // ErrInvalidAccessCode is returned when an access code format is invalid. + ErrInvalidAccessCode = errors.New("invalid access code format") + + // ErrAccessCodeExpired is returned when an access code has expired. + ErrAccessCodeExpired = errors.New("access code has expired") + + // ErrAccessCodeUsed is returned when an access code has already been used. + ErrAccessCodeUsed = errors.New("access code has already been used") + + // ErrInvitationNotFound is returned when an invitation cannot be found. + ErrInvitationNotFound = errors.New("invitation not found") + + // ErrInvitationExpired is returned when an invitation has expired. + ErrInvitationExpired = errors.New("invitation has expired") +) + +// Organization errors. +var ( + // ErrUnauthorizedOrgAction is returned when a user attempts an action + // they don't have permission for within an organization. + ErrUnauthorizedOrgAction = errors.New("unauthorized organization action") + + // ErrUserAlreadyExists is returned when attempting to create a user + // that already exists in the identity provider. + ErrUserAlreadyExists = errors.New("user already exists") +) From 567bfadd4473b7b1b10c7b9051dfc3727c5f5c24 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Sun, 8 Feb 2026 10:02:22 +0300 Subject: [PATCH 05/16] feat: Implement access code generation, validation, and hashing functions --- invitation.go | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 invitation.go diff --git a/invitation.go b/invitation.go new file mode 100644 index 0000000..ee7fa25 --- /dev/null +++ b/invitation.go @@ -0,0 +1,86 @@ +package authkit + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "github.com/Prescott-Data/dromos-authkit/internal/models" +) + +// InvitationClaims is an alias to models.InvitationClaims for backward compatibility. +type InvitationClaims = models.InvitationClaims + +// AccessCode is an alias to models.AccessCode for backward compatibility. +type AccessCode = models.AccessCode + +// GenerateAccessCode creates a cryptographically secure access code +func GenerateAccessCode() (string, error) { + // Character set excluding ambiguous characters: 0, O, 1, I, L + const charset = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + const codeLength = 12 + + // Generate random bytes + randomBytes := make([]byte, codeLength) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Map random bytes to charset + code := make([]byte, codeLength) + for i, b := range randomBytes { + code[i] = charset[int(b)%len(charset)] + } + + // Format as XXXX-XXXX-XXXX + formatted := fmt.Sprintf("%s-%s-%s", + string(code[0:4]), + string(code[4:8]), + string(code[8:12]), + ) + + return formatted, nil +} + +// ValidateAccessCodeFormat checks if the access code matches the expected format +func ValidateAccessCodeFormat(code string) bool { + // Check length (14 chars: 12 alphanumeric + 2 hyphens) + if len(code) != 14 { + return false + } + + // Check format: XXXX-XXXX-XXXX + parts := strings.Split(code, "-") + if len(parts) != 3 { + return false + } + + // Each part must be exactly 4 characters + for _, part := range parts { + if len(part) != 4 { + return false + } + // Validate characters (uppercase letters and digits, excluding ambiguous ones) + for _, ch := range part { + if !isValidAccessCodeChar(ch) { + return false + } + } + } + + return true +} + +// isValidAccessCodeChar checks if a character is valid for access codes. +func isValidAccessCodeChar(ch rune) bool { + const validChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + return strings.ContainsRune(validChars, ch) +} + +// HashAccessCode creates a SHA256 hash of the access code for secure storage. +func HashAccessCode(code string) string { + hash := sha256.Sum256([]byte(code)) + return hex.EncodeToString(hash[:]) +} From d66796226dbfc43a788bba4edf5a2b2afb217651 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Sun, 8 Feb 2026 10:02:36 +0300 Subject: [PATCH 06/16] refactor: Remove unused JWKSCache methods and simplify structure --- jwks.go | 127 ++++---------------------------------------------------- 1 file changed, 8 insertions(+), 119 deletions(-) diff --git a/jwks.go b/jwks.go index f098f58..7f185e4 100644 --- a/jwks.go +++ b/jwks.go @@ -2,134 +2,23 @@ package authkit import ( "crypto/rsa" - "encoding/base64" - "encoding/json" - "fmt" - "math/big" "net/http" - "sync" "time" + + "github.com/Prescott-Data/dromos-authkit/internal/models" ) -// jwksKey represents a single key from the JWKS endpoint. -type jwksKey struct { - Kty string `json:"kty"` - Kid string `json:"kid"` - Use string `json:"use"` - Alg string `json:"alg"` - N string `json:"n"` - E string `json:"e"` -} - -// jwksResponse represents the JWKS endpoint response. -type jwksResponse struct { - Keys []jwksKey `json:"keys"` -} - -// JWKSCache fetches and caches JWKS keys from the identity provider. -type JWKSCache struct { - jwksURL string - keys map[string]*rsa.PublicKey - mu sync.RWMutex - lastFetch time.Time - cacheTTL time.Duration - httpClient *http.Client -} +// JWKSCache is an alias to models.JWKSCache for backward compatibility. +type JWKSCache = models.JWKSCache // NewJWKSCache creates a new JWKS cache for the given URL. func NewJWKSCache(jwksURL string) *JWKSCache { return &JWKSCache{ - jwksURL: jwksURL, - keys: make(map[string]*rsa.PublicKey), - cacheTTL: 1 * time.Hour, - httpClient: &http.Client{ + JWKSURL: jwksURL, + Keys: make(map[string]*rsa.PublicKey), + CacheTTL: 1 * time.Hour, + HTTPClient: &http.Client{ Timeout: 10 * time.Second, }, } } - -// GetKey returns the RSA public key for the given key ID. -// It fetches fresh keys if the cache is stale or the key ID is unknown. -func (j *JWKSCache) GetKey(kid string) (*rsa.PublicKey, error) { - // Try cached key first - j.mu.RLock() - if key, ok := j.keys[kid]; ok && time.Since(j.lastFetch) < j.cacheTTL { - j.mu.RUnlock() - return key, nil - } - j.mu.RUnlock() - - // Fetch fresh keys - if err := j.refresh(); err != nil { - return nil, fmt.Errorf("failed to refresh JWKS: %w", err) - } - - j.mu.RLock() - defer j.mu.RUnlock() - key, ok := j.keys[kid] - if !ok { - return nil, fmt.Errorf("key %q not found in JWKS", kid) - } - return key, nil -} - -func (j *JWKSCache) refresh() error { - j.mu.Lock() - defer j.mu.Unlock() - - // Double-check after acquiring write lock - if time.Since(j.lastFetch) < 30*time.Second { - return nil - } - - resp, err := j.httpClient.Get(j.jwksURL) - if err != nil { - return fmt.Errorf("JWKS fetch failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) - } - - var jwks jwksResponse - if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { - return fmt.Errorf("failed to decode JWKS: %w", err) - } - - newKeys := make(map[string]*rsa.PublicKey, len(jwks.Keys)) - for _, k := range jwks.Keys { - if k.Kty != "RSA" || k.Use != "sig" { - continue - } - pubKey, err := parseRSAPublicKey(k.N, k.E) - if err != nil { - continue - } - newKeys[k.Kid] = pubKey - } - - j.keys = newKeys - j.lastFetch = time.Now() - return nil -} - -func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { - nBytes, err := base64.RawURLEncoding.DecodeString(nStr) - if err != nil { - return nil, fmt.Errorf("invalid modulus: %w", err) - } - - eBytes, err := base64.RawURLEncoding.DecodeString(eStr) - if err != nil { - return nil, fmt.Errorf("invalid exponent: %w", err) - } - - n := new(big.Int).SetBytes(nBytes) - e := 0 - for _, b := range eBytes { - e = e<<8 + int(b) - } - - return &rsa.PublicKey{N: n, E: e}, nil -} From dcbbcc4d6a57571cd07cde7034d0c52380efd29c Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Sun, 8 Feb 2026 10:03:10 +0300 Subject: [PATCH 07/16] feat: Add organization role management and middleware for role checks --- organization.go | 87 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 organization.go diff --git a/organization.go b/organization.go new file mode 100644 index 0000000..86bb2ee --- /dev/null +++ b/organization.go @@ -0,0 +1,87 @@ +package authkit + +import ( + "fmt" + "net/http" + + "github.com/Prescott-Data/dromos-authkit/internal/models" + "github.com/gin-gonic/gin" +) + +// OrgRole is an alias to models.OrgRole for backward compatibility. +type OrgRole = models.OrgRole + +// Organization role constants matching Zitadel role keys. +const ( + // OrgRoleOwner has full control over the organization. + OrgRoleOwner = models.OrgRoleOwner + + // OrgRoleAdmin can manage members and most organization settings. + OrgRoleAdmin = models.OrgRoleAdmin + + // OrgRoleMember has standard access to organization resources. + OrgRoleMember = models.OrgRoleMember + + // OrgRoleViewer has read-only access to organization resources. + OrgRoleViewer = models.OrgRoleViewer +) + +// RequireOrgRole returns a Gin middleware that checks if the authenticated user +// has at least one of the specified organization roles. Must be applied AFTER AuthN. +func RequireOrgRole(roles ...OrgRole) gin.HandlerFunc { + roleStrs := make([]string, len(roles)) + for i, r := range roles { + roleStrs[i] = string(r) + } + + return func(c *gin.Context) { + if HasAnyRole(c, roleStrs...) { + c.Next() + return + } + + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": fmt.Sprintf("insufficient permissions — requires one of: %s", formatRoles(roles)), + }) + } +} + +// IsOrgAdmin checks if the authenticated user has admin or owner privileges. +func IsOrgAdmin(c *gin.Context) bool { + return HasAnyRole(c, string(OrgRoleOwner), string(OrgRoleAdmin)) +} + +// IsOrgOwner checks if the authenticated user is an organization owner. +func IsOrgOwner(c *gin.Context) bool { + return HasRole(c, string(OrgRoleOwner)) +} + +// CanManageMembers checks if the authenticated user can invite and manage members. +func CanManageMembers(c *gin.Context) bool { + return IsOrgAdmin(c) +} + +// HasAnyOrgRole checks if the authenticated user has any of the specified organization roles. +func HasAnyOrgRole(c *gin.Context, roles ...OrgRole) bool { + roleStrs := make([]string, len(roles)) + for i, r := range roles { + roleStrs[i] = string(r) + } + return HasAnyRole(c, roleStrs...) +} + +// formatRoles formats a slice of OrgRoles into a comma-separated string. +func formatRoles(roles []OrgRole) string { + if len(roles) == 0 { + return "" + } + if len(roles) == 1 { + return string(roles[0]) + } + + result := string(roles[0]) + for i := 1; i < len(roles); i++ { + result += ", " + string(roles[i]) + } + return result +} From 582a38368f10996e45fa393b73233735fd674b14 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Sun, 8 Feb 2026 10:03:35 +0300 Subject: [PATCH 08/16] feat: Implement Zitadel client with user management functions --- zitadel_client.go | 271 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 zitadel_client.go diff --git a/zitadel_client.go b/zitadel_client.go new file mode 100644 index 0000000..013f06b --- /dev/null +++ b/zitadel_client.go @@ -0,0 +1,271 @@ +package authkit + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/Prescott-Data/dromos-authkit/internal/models" +) + +// ZitadelClient wraps models.ZitadelClient for convenience. +type ZitadelClient struct { + *models.ZitadelClient +} + +// ZitadelConfig is an alias to models.ZitadelConfig for backward compatibility. +type ZitadelConfig = models.ZitadelConfig + +// CreateUserRequest is an alias to models.CreateUserRequest for backward compatibility. +type CreateUserRequest = models.CreateUserRequest + +// CreateUserResponse is an alias to models.CreateUserResponse for backward compatibility. +type CreateUserResponse = models.CreateUserResponse + +// UserResponse is an alias to models.UserResponse for backward compatibility. +type UserResponse = models.UserResponse + +// NewZitadelClient creates a new Zitadel API client with the provided configuration. +func NewZitadelClient(cfg ZitadelConfig) *ZitadelClient { + timeout := cfg.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + + return &ZitadelClient{ + ZitadelClient: &models.ZitadelClient{ + BaseURL: cfg.IssuerURL, + ServiceToken: cfg.ServiceToken, + ProjectID: cfg.ProjectID, + HTTPClient: &http.Client{ + Timeout: timeout, + }, + }, + } +} + +// CreateUser creates a new user in Zitadel within the specified organization. +func (z *ZitadelClient) CreateUser(ctx context.Context, req CreateUserRequest) (*CreateUserResponse, error) { + // Build the API request + apiReq := models.ZitadelCreateUserRequestBody{ + UserName: req.Email, + Password: req.Password, + } + apiReq.Profile.FirstName = req.FirstName + apiReq.Profile.LastName = req.LastName + apiReq.Email.Email = req.Email + apiReq.Email.IsVerified = false + + body, err := json.Marshal(apiReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/management/v1/users/human/_import", z.ZitadelClient.BaseURL) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + // Add org context if provided + if req.OrgID != "" { + httpReq.Header.Set("x-zitadel-orgid", req.OrgID) + } + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + // Check for user already exists error + if resp.StatusCode == http.StatusConflict { + return nil, ErrUserAlreadyExists + } + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var apiResp models.ZitadelCreateUserResponseBody + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &CreateUserResponse{ + UserID: apiResp.UserID, + Email: req.Email, + }, nil +} + +// AssignUserRole assigns one or more roles to a user in the configured project. +func (z *ZitadelClient) AssignUserRole(ctx context.Context, userID string, roleKeys []string) error { + apiReq := models.ZitadelRoleAssignment{ + RoleKeys: roleKeys, + } + + body, err := json.Marshal(apiReq) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/management/v1/users/%s/grants", z.ZitadelClient.BaseURL, userID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// AddUserToOrganization adds a user to an organization in Zitadel. +func (z *ZitadelClient) AddUserToOrganization(ctx context.Context, userID, orgID string) error { + url := fmt.Sprintf("%s/management/v1/orgs/%s/members", z.ZitadelClient.BaseURL, orgID) + + reqBody := map[string]interface{}{ + "userId": userID, + "roles": []string{"ORG_OWNER"}, // Default role, can be customized + } + + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// DeactivateUser deactivates a user in Zitadel, preventing them from logging in. +func (z *ZitadelClient) DeactivateUser(ctx context.Context, userID string) error { + url := fmt.Sprintf("%s/management/v1/users/%s/_deactivate", z.ZitadelClient.BaseURL, userID) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// ActivateUser activates a previously deactivated user in Zitadel. +func (z *ZitadelClient) ActivateUser(ctx context.Context, userID string) error { + url := fmt.Sprintf("%s/management/v1/users/%s/_reactivate", z.ZitadelClient.BaseURL, userID) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// GetUser retrieves user details from Zitadel by user ID. +func (z *ZitadelClient) GetUser(ctx context.Context, userID string) (*UserResponse, error) { + url := fmt.Sprintf("%s/management/v1/users/%s", z.ZitadelClient.BaseURL, userID) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var apiResp models.ZitadelGetUserResponseBody + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &UserResponse{ + UserID: apiResp.User.UserID, + UserName: apiResp.User.UserName, + FirstName: apiResp.User.Human.Profile.FirstName, + LastName: apiResp.User.Human.Profile.LastName, + Email: apiResp.User.Human.Email.Email, + State: apiResp.User.State, + }, nil +} From 323927a0adf85c9008a0c32582f18085c303dd45 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Tue, 10 Feb 2026 10:16:38 +0300 Subject: [PATCH 09/16] feat: improve Zitadel API models --- internal/models/zitadel.go | 161 ++++++++++++++++++++++++++++++++----- 1 file changed, 143 insertions(+), 18 deletions(-) diff --git a/internal/models/zitadel.go b/internal/models/zitadel.go index cabfecf..7c98d2b 100644 --- a/internal/models/zitadel.go +++ b/internal/models/zitadel.go @@ -24,6 +24,7 @@ type ZitadelConfig struct { // CreateUserRequest contains the parameters for creating a new user. type CreateUserRequest struct { Email string `json:"email"` + UserName string `json:"username"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Password string `json:"password,omitempty"` @@ -38,54 +39,58 @@ type CreateUserResponse struct { // UserResponse contains the details of a user retrieved from Zitadel. type UserResponse struct { - UserID string `json:"user_id"` - UserName string `json:"user_name"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Email string `json:"email"` - State string `json:"state"` + LastName string `json:"last_name"` + Email string `json:"email"` + State string `json:"state"` + AvatarURL string `json:"avatar_url,omitempty"` } // ZitadelCreateUserRequestBody is the internal API request format. type ZitadelCreateUserRequestBody struct { - UserName string `json:"username"` + UserName string `json:"userName"` Profile struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` } `json:"profile"` Email struct { Email string `json:"email"` - IsVerified bool `json:"is_email_verified"` + IsVerified bool `json:"isEmailVerified"` } `json:"email"` Password string `json:"password,omitempty"` } // ZitadelCreateUserResponseBody is the internal API response format. type ZitadelCreateUserResponseBody struct { - UserID string `json:"user_id"` + UserID string `json:"userId"` Details struct { Sequence string `json:"sequence"` - CreationDate time.Time `json:"creation_date"` - ChangeDate time.Time `json:"change_date"` - ResourceOwner string `json:"resource_owner"` + CreationDate time.Time `json:"creationDate"` + ChangeDate time.Time `json:"changeDate"` + ResourceOwner string `json:"resourceOwner"` } `json:"details"` } // ZitadelRoleAssignment is the internal API format for role assignments. type ZitadelRoleAssignment struct { - RoleKeys []string `json:"role_keys"` + ProjectID string `json:"projectId"` + RoleKeys []string `json:"roleKeys"` } // ZitadelGetUserResponseBody is the internal API response format for GetUser. type ZitadelGetUserResponseBody struct { User struct { - UserID string `json:"user_id"` - UserName string `json:"user_name"` + UserID string `json:"userId"` + UserName string `json:"userName"` State string `json:"state"` Human struct { Profile struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + DisplayName string `json:"displayName"` + AvatarURL string `json:"avatarUrl"` } `json:"profile"` Email struct { Email string `json:"email"` @@ -93,3 +98,123 @@ type ZitadelGetUserResponseBody struct { } `json:"human"` } `json:"user"` } + +// OrganizationResponse contains the details of an organization retrieved from Zitadel. +type OrganizationResponse struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Domain string `json:"domain,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + LogoDarkURL string `json:"logo_dark_url,omitempty"` + IconURL string `json:"icon_url,omitempty"` + IconDarkURL string `json:"icon_dark_url,omitempty"` +} + +// ZitadelLabelPolicyResponseBody is the response format for getting org label/branding policy +type ZitadelLabelPolicyResponseBody struct { + Policy struct { + LogoURL string `json:"logoUrl"` + LogoDarkURL string `json:"logoUrlDark"` + IconURL string `json:"iconUrl"` + IconDarkURL string `json:"iconUrlDark"` + PrimaryColor string `json:"primaryColor"` + BackgroundColor string `json:"backgroundColor"` + FontURL string `json:"fontUrl"` + } `json:"policy"` +} + +// ZitadelGetOrgResponseBody is the internal API response format for GetOrganization. +type ZitadelGetOrgResponseBody struct { + Org struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + PrimaryDomain string `json:"primaryDomain"` + } `json:"org"` +} + +// UserGrant represents a user's grant (role assignment) in a project +type UserGrant struct { + ID string `json:"id"` + UserID string `json:"user_id"` + ProjectID string `json:"project_id"` + RoleKeys []string `json:"role_keys"` + State string `json:"state"` + // User details (populated from separate call or included in response) + UserName string `json:"user_name,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Email string `json:"email,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` +} + +// ZitadelListUserGrantsResponseBody is the response format for listing user grants +type ZitadelListUserGrantsResponseBody struct { + Result []struct { + ID string `json:"id"` + UserID string `json:"userId"` + ProjectID string `json:"projectId"` + RoleKeys []string `json:"roleKeys"` + State string `json:"state"` + UserName string `json:"userName"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email"` + AvatarURL string `json:"avatarUrl"` + DisplayName string `json:"displayName"` + OrgID string `json:"orgId"` + ProjectName string `json:"projectName"` + GrantedOrgID string `json:"grantedOrgId"` + } `json:"result"` + Details struct { + TotalResult string `json:"totalResult"` + } `json:"details"` +} + +// OrgMetadata represents organization metadata (for logo, location, etc.) +type OrgMetadata struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// ZitadelListOrgMetadataResponseBody is the response format for listing org metadata +type ZitadelListOrgMetadataResponseBody struct { + Result []struct { + Key string `json:"key"` + Value string `json:"value"` // Base64 encoded + } `json:"result"` +} + +// ZitadelSearchUsersRequestBody is the request format for searching users +type ZitadelSearchUsersRequestBody struct { + Query struct { + Offset uint64 `json:"offset,omitempty"` + Limit uint32 `json:"limit,omitempty"` + Asc bool `json:"asc,omitempty"` + } `json:"query,omitempty"` + Queries []interface{} `json:"queries,omitempty"` +} + +// ZitadelSearchUsersResponseBody is the response format for searching users +type ZitadelSearchUsersResponseBody struct { + Result []struct { + UserID string `json:"userId"` + UserName string `json:"userName"` + State string `json:"state"` + Human *struct { + Profile struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + DisplayName string `json:"displayName"` + } `json:"profile"` + Email struct { + Email string `json:"email"` + IsVerified bool `json:"isEmailVerified"` + } `json:"email"` + } `json:"human,omitempty"` + } `json:"result"` + Details struct { + TotalResult string `json:"totalResult"` + } `json:"details"` +} From d4c9dfc13ce8e164049dd3e710234b2e582f00f0 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Tue, 10 Feb 2026 10:18:05 +0300 Subject: [PATCH 10/16] feat: enhance Zitadel client with organization management and user role assignments --- zitadel_client.go | 472 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 470 insertions(+), 2 deletions(-) diff --git a/zitadel_client.go b/zitadel_client.go index 013f06b..4ba28a7 100644 --- a/zitadel_client.go +++ b/zitadel_client.go @@ -3,6 +3,7 @@ package authkit import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -29,6 +30,9 @@ type CreateUserResponse = models.CreateUserResponse // UserResponse is an alias to models.UserResponse for backward compatibility. type UserResponse = models.UserResponse +// OrganizationResponse is an alias to models.OrganizationResponse for backward compatibility. +type OrganizationResponse = models.OrganizationResponse + // NewZitadelClient creates a new Zitadel API client with the provided configuration. func NewZitadelClient(cfg ZitadelConfig) *ZitadelClient { timeout := cfg.Timeout @@ -51,8 +55,14 @@ func NewZitadelClient(cfg ZitadelConfig) *ZitadelClient { // CreateUser creates a new user in Zitadel within the specified organization. func (z *ZitadelClient) CreateUser(ctx context.Context, req CreateUserRequest) (*CreateUserResponse, error) { // Build the API request + // Use provided username, fallback to email if username is empty + username := req.UserName + if username == "" { + username = req.Email + } + apiReq := models.ZitadelCreateUserRequestBody{ - UserName: req.Email, + UserName: username, Password: req.Password, } apiReq.Profile.FirstName = req.FirstName @@ -112,7 +122,8 @@ func (z *ZitadelClient) CreateUser(ctx context.Context, req CreateUserRequest) ( // AssignUserRole assigns one or more roles to a user in the configured project. func (z *ZitadelClient) AssignUserRole(ctx context.Context, userID string, roleKeys []string) error { apiReq := models.ZitadelRoleAssignment{ - RoleKeys: roleKeys, + ProjectID: z.ZitadelClient.ProjectID, + RoleKeys: roleKeys, } body, err := json.Marshal(apiReq) @@ -267,5 +278,462 @@ func (z *ZitadelClient) GetUser(ctx context.Context, userID string) (*UserRespon LastName: apiResp.User.Human.Profile.LastName, Email: apiResp.User.Human.Email.Email, State: apiResp.User.State, + AvatarURL: apiResp.User.Human.Profile.AvatarURL, + }, nil +} + +// GetOrganization retrieves organization details from Zitadel by organization ID. +func (z *ZitadelClient) GetOrganization(ctx context.Context, orgID string) (*OrganizationResponse, error) { + url := fmt.Sprintf("%s/management/v1/orgs/me", z.ZitadelClient.BaseURL) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + httpReq.Header.Set("x-zitadel-orgid", orgID) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var apiResp models.ZitadelGetOrgResponseBody + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + org := &OrganizationResponse{ + ID: apiResp.Org.ID, + Name: apiResp.Org.Name, + State: apiResp.Org.State, + Domain: apiResp.Org.PrimaryDomain, + } + + // Fetch label policy for org branding (logo, icon) + labelPolicy, err := z.GetOrgLabelPolicy(ctx, orgID) + if err == nil && labelPolicy != nil { + org.LogoURL = labelPolicy.LogoURL + org.LogoDarkURL = labelPolicy.LogoDarkURL + org.IconURL = labelPolicy.IconURL + org.IconDarkURL = labelPolicy.IconDarkURL + } + + return org, nil +} + +// LabelPolicy contains the branding/label policy for an organization +type LabelPolicy struct { + LogoURL string + LogoDarkURL string + IconURL string + IconDarkURL string + PrimaryColor string + BackgroundColor string +} + +// GetOrgLabelPolicy retrieves the label/branding policy for an organization. +func (z *ZitadelClient) GetOrgLabelPolicy(ctx context.Context, orgID string) (*LabelPolicy, error) { + url := fmt.Sprintf("%s/management/v1/policies/label", z.ZitadelClient.BaseURL) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + httpReq.Header.Set("x-zitadel-orgid", orgID) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var apiResp models.ZitadelLabelPolicyResponseBody + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &LabelPolicy{ + LogoURL: apiResp.Policy.LogoURL, + LogoDarkURL: apiResp.Policy.LogoDarkURL, + IconURL: apiResp.Policy.IconURL, + IconDarkURL: apiResp.Policy.IconDarkURL, + PrimaryColor: apiResp.Policy.PrimaryColor, + BackgroundColor: apiResp.Policy.BackgroundColor, + }, nil +} + +// ListProjectUserGrants lists all user grants for the configured project in an organization. +func (z *ZitadelClient) ListProjectUserGrants(ctx context.Context, orgID string) ([]models.UserGrant, error) { + url := fmt.Sprintf("%s/management/v1/projects/%s/grants/_search", z.ZitadelClient.BaseURL, z.ZitadelClient.ProjectID) + + // Empty search body to get all grants + reqBody := map[string]any{} + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + httpReq.Header.Set("x-zitadel-orgid", orgID) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var apiResp models.ZitadelListUserGrantsResponseBody + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + grants := make([]models.UserGrant, 0, len(apiResp.Result)) + for _, g := range apiResp.Result { + grants = append(grants, models.UserGrant{ + ID: g.ID, + UserID: g.UserID, + ProjectID: g.ProjectID, + RoleKeys: g.RoleKeys, + State: g.State, + UserName: g.UserName, + FirstName: g.FirstName, + LastName: g.LastName, + Email: g.Email, + AvatarURL: g.AvatarURL, + }) + } + + return grants, nil +} + +// ListUserGrantsInOrg lists all user grants in an organization for the configured project. +func (z *ZitadelClient) ListUserGrantsInOrg(ctx context.Context, orgID string) ([]models.UserGrant, error) { + url := fmt.Sprintf("%s/management/v1/users/grants/_search", z.ZitadelClient.BaseURL) + + reqBody := map[string]any{ + "queries": []map[string]any{ + { + "projectIdQuery": map[string]string{ + "projectId": z.ZitadelClient.ProjectID, + }, + }, + }, + } + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + httpReq.Header.Set("x-zitadel-orgid", orgID) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var apiResp models.ZitadelListUserGrantsResponseBody + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + grants := make([]models.UserGrant, 0, len(apiResp.Result)) + for _, g := range apiResp.Result { + grants = append(grants, models.UserGrant{ + ID: g.ID, + UserID: g.UserID, + ProjectID: g.ProjectID, + RoleKeys: g.RoleKeys, + State: g.State, + UserName: g.UserName, + FirstName: g.FirstName, + LastName: g.LastName, + Email: g.Email, + AvatarURL: g.AvatarURL, + }) + } + + return grants, nil +} + +// GetOrgMetadata retrieves all metadata for an organization. +func (z *ZitadelClient) GetOrgMetadata(ctx context.Context, orgID string) (map[string]string, error) { + url := fmt.Sprintf("%s/management/v1/metadata/_search", z.ZitadelClient.BaseURL) + + reqBody := map[string]any{} + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + httpReq.Header.Set("x-zitadel-orgid", orgID) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var apiResp models.ZitadelListOrgMetadataResponseBody + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + metadata := make(map[string]string) + for _, m := range apiResp.Result { + // Zitadel returns base64-encoded values + decoded, err := base64.StdEncoding.DecodeString(m.Value) + if err != nil { + metadata[m.Key] = m.Value // Use raw value if decode fails + } else { + metadata[m.Key] = string(decoded) + } + } + + return metadata, nil +} + +// SetOrgMetadata sets a metadata key-value pair for an organization. +func (z *ZitadelClient) SetOrgMetadata(ctx context.Context, orgID, key, value string) error { + url := fmt.Sprintf("%s/management/v1/metadata/%s", z.ZitadelClient.BaseURL, key) + + // Zitadel expects base64-encoded value + encodedValue := base64.StdEncoding.EncodeToString([]byte(value)) + reqBody := map[string]string{ + "value": encodedValue, + } + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + httpReq.Header.Set("x-zitadel-orgid", orgID) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// SearchUserByEmail searches for a user by email address. +func (z *ZitadelClient) SearchUserByEmail(ctx context.Context, email string) (*UserResponse, error) { + url := fmt.Sprintf("%s/v2/users", z.ZitadelClient.BaseURL) + + reqBody := map[string]any{ + "queries": []map[string]any{ + { + "emailQuery": map[string]any{ + "emailAddress": email, + "method": "TEXT_QUERY_METHOD_EQUALS", + }, + }, + }, + } + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var apiResp models.ZitadelSearchUsersResponseBody + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + if len(apiResp.Result) == 0 { + return nil, nil // User not found + } + + user := apiResp.Result[0] + userResp := &UserResponse{ + UserID: user.UserID, + UserName: user.UserName, + State: user.State, + } + + if user.Human != nil { + userResp.FirstName = user.Human.Profile.FirstName + userResp.LastName = user.Human.Profile.LastName + userResp.Email = user.Human.Email.Email + } + + return userResp, nil +} + +// GetUserGrantForProject checks if a user has a grant for the configured project. +func (z *ZitadelClient) GetUserGrantForProject(ctx context.Context, userID string) (*models.UserGrant, error) { + // Use the user grants search endpoint with project filter + url := fmt.Sprintf("%s/management/v1/users/grants/_search", z.ZitadelClient.BaseURL) + + reqBody := map[string]any{ + "queries": []map[string]any{ + { + "userIdQuery": map[string]string{ + "userId": userID, + }, + }, + { + "projectIdQuery": map[string]string{ + "projectId": z.ZitadelClient.ProjectID, + }, + }, + }, + } + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var apiResp models.ZitadelListUserGrantsResponseBody + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + if len(apiResp.Result) == 0 { + return nil, nil // No grant found for this project + } + + g := apiResp.Result[0] + return &models.UserGrant{ + ID: g.ID, + UserID: g.UserID, + ProjectID: g.ProjectID, + RoleKeys: g.RoleKeys, + State: g.State, + UserName: g.UserName, + FirstName: g.FirstName, + LastName: g.LastName, + Email: g.Email, + AvatarURL: g.AvatarURL, }, nil } From a52f8d47211961fd4e2942f8c44181babdbe8eb3 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Fri, 13 Feb 2026 11:01:04 +0300 Subject: [PATCH 11/16] feat: add IDPLink model and response format for listing user IDP links --- internal/models/zitadel.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/models/zitadel.go b/internal/models/zitadel.go index 7c98d2b..86f6fee 100644 --- a/internal/models/zitadel.go +++ b/internal/models/zitadel.go @@ -218,3 +218,29 @@ type ZitadelSearchUsersResponseBody struct { TotalResult string `json:"totalResult"` } `json:"details"` } + +// IDPLink represents a user's linked external identity provider +type IDPLink struct { + IDPID string `json:"idp_id"` + IDPName string `json:"idp_name"` + UserID string `json:"user_id"` + ExternalUserID string `json:"external_user_id"` + DisplayName string `json:"display_name"` + ProvidedUserID string `json:"provided_user_id,omitempty"` + ProvidedEmail string `json:"provided_email,omitempty"` +} + +// ZitadelListIDPLinksResponseBody is the response format for listing user IDP links +type ZitadelListIDPLinksResponseBody struct { + Result []struct { + IDPID string `json:"idpId"` + UserID string `json:"userId"` + IDPName string `json:"idpName"` + ProvidedUserID string `json:"providedUserId"` + ProvidedEmail string `json:"providedUserName"` // Zitadel uses providedUserName for email + IDPType int `json:"idpType"` + } `json:"result"` + Details struct { + TotalResult string `json:"totalResult"` + } `json:"details"` +} From 1729fd58763c8981a0d1302a5387266e09cf57c3 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Fri, 13 Feb 2026 11:01:14 +0300 Subject: [PATCH 12/16] feat: add GetUserIDPLinks and RemoveUserIDPLink methods to ZitadelClient --- zitadel_client.go | 82 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/zitadel_client.go b/zitadel_client.go index 4ba28a7..9c4a936 100644 --- a/zitadel_client.go +++ b/zitadel_client.go @@ -33,6 +33,9 @@ type UserResponse = models.UserResponse // OrganizationResponse is an alias to models.OrganizationResponse for backward compatibility. type OrganizationResponse = models.OrganizationResponse +// IDPLink is an alias to models.IDPLink for backward compatibility. +type IDPLink = models.IDPLink + // NewZitadelClient creates a new Zitadel API client with the provided configuration. func NewZitadelClient(cfg ZitadelConfig) *ZitadelClient { timeout := cfg.Timeout @@ -737,3 +740,82 @@ func (z *ZitadelClient) GetUserGrantForProject(ctx context.Context, userID strin AvatarURL: g.AvatarURL, }, nil } + +// GetUserIDPLinks retrieves all external identity provider links for a user. +func (z *ZitadelClient) GetUserIDPLinks(ctx context.Context, userID string) ([]IDPLink, error) { + url := fmt.Sprintf("%s/management/v1/users/%s/idps/_search", z.ZitadelClient.BaseURL, userID) + + // Empty search body to get all links + reqBody := map[string]any{} + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var apiResp models.ZitadelListIDPLinksResponseBody + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + links := make([]IDPLink, 0, len(apiResp.Result)) + for _, link := range apiResp.Result { + links = append(links, IDPLink{ + IDPID: link.IDPID, + IDPName: link.IDPName, + UserID: link.UserID, + ExternalUserID: link.ProvidedUserID, + ProvidedUserID: link.ProvidedUserID, + ProvidedEmail: link.ProvidedEmail, + }) + } + + return links, nil +} + +// RemoveUserIDPLink removes an external identity provider link from a user. +func (z *ZitadelClient) RemoveUserIDPLink(ctx context.Context, userID, idpID, externalUserID string) error { + url := fmt.Sprintf("%s/management/v1/users/%s/idps/%s/%s", z.ZitadelClient.BaseURL, userID, idpID, externalUserID) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} From b925adcb9f2f85182e6491f03e22ca2204108573 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Tue, 17 Feb 2026 07:00:32 +0300 Subject: [PATCH 13/16] feat: update user ID field in GetUser response and add session-related models --- internal/models/zitadel.go | 363 ++++++++++++++++++++++++++++++++++++- 1 file changed, 362 insertions(+), 1 deletion(-) diff --git a/internal/models/zitadel.go b/internal/models/zitadel.go index 86f6fee..3246172 100644 --- a/internal/models/zitadel.go +++ b/internal/models/zitadel.go @@ -82,7 +82,7 @@ type ZitadelRoleAssignment struct { // ZitadelGetUserResponseBody is the internal API response format for GetUser. type ZitadelGetUserResponseBody struct { User struct { - UserID string `json:"userId"` + UserID string `json:"id"` UserName string `json:"userName"` State string `json:"state"` Human struct { @@ -244,3 +244,364 @@ type ZitadelListIDPLinksResponseBody struct { TotalResult string `json:"totalResult"` } `json:"details"` } + +// ============================================================================= +// Session API v2 Models +// ============================================================================= + +// SessionDetails contains metadata about a session operation +type SessionDetails struct { + Sequence string `json:"sequence,omitempty"` + ChangeDate string `json:"changeDate,omitempty"` + ResourceOwner string `json:"resourceOwner,omitempty"` + CreationDate string `json:"creationDate,omitempty"` +} + +// SessionUserCheck verifies user identity by login name +type SessionUserCheck struct { + LoginName string `json:"loginName,omitempty"` + UserID string `json:"userId,omitempty"` +} + +// SessionPasswordCheck verifies user password +type SessionPasswordCheck struct { + Password string `json:"password"` +} + +// SessionWebAuthNCheck contains WebAuthN credential assertion data +type SessionWebAuthNCheck struct { + CredentialAssertionData map[string]interface{} `json:"credentialAssertionData"` +} + +// SessionIDPIntentCheck verifies external IDP authentication +type SessionIDPIntentCheck struct { + IDPIntentID string `json:"idpIntentId"` + IDPIntentToken string `json:"idpIntentToken"` +} + +// SessionTOTPCheck verifies TOTP code +type SessionTOTPCheck struct { + Code string `json:"code"` +} + +// SessionOTPCheck verifies OTP code (SMS or Email) +type SessionOTPCheck struct { + Code string `json:"code"` +} + +// SessionChecks contains all possible verification checks for a session +type SessionChecks struct { + User *SessionUserCheck `json:"user,omitempty"` + Password *SessionPasswordCheck `json:"password,omitempty"` + WebAuthN *SessionWebAuthNCheck `json:"webAuthN,omitempty"` + IDPIntent *SessionIDPIntentCheck `json:"idpIntent,omitempty"` + TOTP *SessionTOTPCheck `json:"totp,omitempty"` + OTPSMS *SessionOTPCheck `json:"otpSms,omitempty"` + OTPEmail *SessionOTPCheck `json:"otpEmail,omitempty"` +} + +// WebAuthNChallenge contains WebAuthN challenge configuration +type WebAuthNChallenge struct { + Domain string `json:"domain"` + UserVerificationRequirement string `json:"userVerificationRequirement,omitempty"` +} + +// OTPChallenge contains OTP challenge configuration +type OTPChallenge struct { + ReturnCode bool `json:"returnCode,omitempty"` +} + +// SessionChallenges contains challenge requests for MFA +type SessionChallenges struct { + WebAuthN *WebAuthNChallenge `json:"webAuthN,omitempty"` + OTPSMS *OTPChallenge `json:"otpSms,omitempty"` + OTPEmail *OTPChallenge `json:"otpEmail,omitempty"` +} + +// SessionUserAgent contains client user agent information +type SessionUserAgent struct { + FingerprintID *string `json:"fingerprintId,omitempty"` + IP *string `json:"ip,omitempty"` + Description *string `json:"description,omitempty"` + Header map[string]string `json:"header,omitempty"` +} + +// CreateSessionRequest is the request body for creating a session +type CreateSessionRequest struct { + Checks *SessionChecks `json:"checks,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Challenges *SessionChallenges `json:"challenges,omitempty"` + UserAgent *SessionUserAgent `json:"userAgent,omitempty"` + Lifetime string `json:"lifetime,omitempty"` +} + +// ChallengeResponse contains the response challenges +type ChallengeResponse struct { + WebAuthN *WebAuthNPublicKeyCredentialRequestOptions `json:"webAuthN,omitempty"` + OTPSMS string `json:"otpSms,omitempty"` + OTPEmail string `json:"otpEmail,omitempty"` +} + +// WebAuthNPublicKeyCredentialRequestOptions contains WebAuthN challenge options +type WebAuthNPublicKeyCredentialRequestOptions struct { + PublicKeyCredentialRequestOptions map[string]interface{} `json:"publicKeyCredentialRequestOptions"` +} + +// CreateSessionResponse is the response from creating a session +type CreateSessionResponse struct { + Details *SessionDetails `json:"details,omitempty"` + SessionID string `json:"sessionId"` + SessionToken string `json:"sessionToken"` + Challenges *ChallengeResponse `json:"challenges,omitempty"` +} + +// SetSessionRequest is the request body for updating a session +type SetSessionRequest struct { + Checks *SessionChecks `json:"checks,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Challenges *SessionChallenges `json:"challenges,omitempty"` + Lifetime string `json:"lifetime,omitempty"` +} + +// SetSessionResponse is the response from updating a session +type SetSessionResponse struct { + Details *SessionDetails `json:"details,omitempty"` + SessionToken string `json:"sessionToken"` + Challenges *ChallengeResponse `json:"challenges,omitempty"` +} + +// SessionFactor represents a completed authentication factor +type SessionFactor struct { + VerifiedAt string `json:"verifiedAt,omitempty"` +} + +// SessionUserFactor contains user verification details +type SessionUserFactor struct { + VerifiedAt string `json:"verifiedAt,omitempty"` + ID string `json:"id,omitempty"` + LoginName string `json:"loginName,omitempty"` + DisplayName string `json:"displayName,omitempty"` + OrganizationID string `json:"organizationId,omitempty"` +} + +// SessionFactors contains all completed authentication factors +type SessionFactors struct { + User *SessionUserFactor `json:"user,omitempty"` + Password *SessionFactor `json:"password,omitempty"` + WebAuthN *SessionFactor `json:"webAuthN,omitempty"` + Intent *SessionFactor `json:"intent,omitempty"` + TOTP *SessionFactor `json:"totp,omitempty"` + OTPSMS *SessionFactor `json:"otpSms,omitempty"` + OTPEmail *SessionFactor `json:"otpEmail,omitempty"` +} + +// Session represents a Zitadel session +type Session struct { + ID string `json:"id"` + CreationDate string `json:"creationDate,omitempty"` + ChangeDate string `json:"changeDate,omitempty"` + Sequence string `json:"sequence,omitempty"` + Factors *SessionFactors `json:"factors,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + ExpirationDate string `json:"expirationDate,omitempty"` +} + +// GetSessionResponse is the response from getting a session +type GetSessionResponse struct { + Session *Session `json:"session"` +} + +// DeleteSessionRequest is the request body for deleting a session +type DeleteSessionRequest struct { + SessionToken string `json:"sessionToken,omitempty"` +} + +// ============================================================================= +// IDP Intent API v2 Models +// ============================================================================= + +// IDPIntentURLs contains success and failure URLs for IDP flow +type IDPIntentURLs struct { + SuccessURL string `json:"successUrl"` + FailureURL string `json:"failureUrl"` +} + +// StartIDPIntentRequest is the request body for starting an IDP intent +type StartIDPIntentRequest struct { + IDPID string `json:"idpId"` + URLs *IDPIntentURLs `json:"urls,omitempty"` + Content interface{} `json:"content,omitempty"` +} + +// StartIDPIntentResponse is the response from starting an IDP intent +type StartIDPIntentResponse struct { + Details *SessionDetails `json:"details,omitempty"` + AuthURL string `json:"authUrl"` +} + +// IDPInformation contains the information returned by the IDP +type IDPInformation struct { + IDPID string `json:"idpId"` + UserID string `json:"userId,omitempty"` + UserName string `json:"userName,omitempty"` + RawInformation map[string]interface{} `json:"rawInformation,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"isEmailVerified,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + DisplayName string `json:"displayName,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + AccessToken string `json:"accessToken,omitempty"` + IDToken string `json:"idToken,omitempty"` +} + +// RetrieveIDPIntentRequest is the request body for retrieving an IDP intent +type RetrieveIDPIntentRequest struct { + IDPIntentToken string `json:"idpIntentToken"` +} + +// RetrieveIDPIntentResponse is the response from retrieving an IDP intent +type RetrieveIDPIntentResponse struct { + Details *SessionDetails `json:"details,omitempty"` + IDPInformation *IDPInformation `json:"idpInformation,omitempty"` + UserID string `json:"userId,omitempty"` +} + +// ============================================================================= +// OIDC Auth Request API v2 Models +// ============================================================================= + +// GetAuthRequestResponse is the response from getting an auth request +type GetAuthRequestResponse struct { + AuthRequest *AuthRequest `json:"authRequest"` +} + +// AuthRequest represents an OIDC authorization request +type AuthRequest struct { + ID string `json:"id"` + CreationDate string `json:"creationDate,omitempty"` + ClientID string `json:"clientId,omitempty"` + Scope []string `json:"scope,omitempty"` + RedirectURI string `json:"redirectUri,omitempty"` + Prompt []string `json:"prompt,omitempty"` + UILocales []string `json:"uiLocales,omitempty"` + LoginHint string `json:"loginHint,omitempty"` + HintUserID string `json:"hintUserId,omitempty"` +} + +// FinalizeAuthRequestSession contains the session info for finalization +type FinalizeAuthRequestSession struct { + SessionID string `json:"sessionId"` + SessionToken string `json:"sessionToken"` +} + +// FinalizeAuthRequestRequest is the request body for finalizing an auth request +type FinalizeAuthRequestRequest struct { + Session *FinalizeAuthRequestSession `json:"session"` +} + +// FinalizeAuthRequestResponse is the response from finalizing an auth request +type FinalizeAuthRequestResponse struct { + Details *SessionDetails `json:"details,omitempty"` + CallbackURL string `json:"callbackUrl"` +} + +// ============================================================================= +// User Creation with IDP Link Models +// ============================================================================= + +// AddHumanUserIDPLink contains IDP link info for user creation +type AddHumanUserIDPLink struct { + IDPID string `json:"idpId"` + UserID string `json:"userId,omitempty"` + UserName string `json:"userName,omitempty"` +} + +// CreateUserWithIDPRequest is the request body for creating a user with IDP link +type CreateUserWithIDPRequest struct { + UserName string `json:"userName"` + Profile *UserProfile `json:"profile"` + Email *UserEmail `json:"email"` + IDPLinks []*AddHumanUserIDPLink `json:"idpLinks,omitempty"` + Metadata []*UserMetadata `json:"metadata,omitempty"` +} + +// UserProfile contains user profile information +type UserProfile struct { + FirstName string `json:"givenName"` + LastName string `json:"familyName"` + DisplayName string `json:"displayName,omitempty"` +} + +// UserEmail contains user email information +type UserEmail struct { + Email string `json:"email"` + IsVerified bool `json:"isEmailVerified,omitempty"` +} + +// UserMetadata contains user metadata +type UserMetadata struct { + Key string `json:"key"` + Value []byte `json:"value"` +} + +// CreateHumanUserResponse is the response from creating a human user +type CreateHumanUserResponse struct { + UserID string `json:"userId"` + Details *SessionDetails `json:"details,omitempty"` +} + +// ============================================================================= +// Add IDP Link to Existing User Models +// ============================================================================= + +// AddIDPLinkRequest is the request body for adding an IDP link to an existing user +type AddIDPLinkRequest struct { + IDPLink *IDPLinkInfo `json:"idpLink"` +} + +// IDPLinkInfo contains IDP link information +type IDPLinkInfo struct { + IDPID string `json:"idpId"` + UserID string `json:"userId"` + UserName string `json:"userName"` +} + +// AddIDPLinkResponse is the response from adding an IDP link +type AddIDPLinkResponse struct { + Details *SessionDetails `json:"details,omitempty"` +} + +// ============================================================================= +// Login Settings Models +// ============================================================================= + +// LoginSettings contains the login settings for an organization +type LoginSettings struct { + AllowUsernamePassword bool `json:"allowUsernamePassword"` + AllowRegister bool `json:"allowRegister"` + AllowExternalIDP bool `json:"allowExternalIdp"` + ForceMFA bool `json:"forceMfa"` + ForceMFALocalOnly bool `json:"forceMfaLocalOnly"` + PasskeysType string `json:"passkeysType,omitempty"` + HidePasswordReset bool `json:"hidePasswordReset"` + IgnoreUnknownUsernames bool `json:"ignoreUnknownUsernames"` + AllowDomainDiscovery bool `json:"allowDomainDiscovery"` + DisableLoginWithEmail bool `json:"disableLoginWithEmail"` + DisableLoginWithPhone bool `json:"disableLoginWithPhone"` + SecondFactors []string `json:"secondFactors,omitempty"` + MultiFactors []string `json:"multiFactors,omitempty"` + IDPs []*IDP `json:"idps,omitempty"` +} + +// IDP represents an identity provider in login settings +type IDP struct { + ID string `json:"idpId"` + Name string `json:"idpName"` + Type string `json:"idpType,omitempty"` +} + +// GetLoginSettingsResponse is the response from getting login settings +type GetLoginSettingsResponse struct { + Settings *LoginSettings `json:"settings"` +} From ad7d333fa0fc6fc0e54cfb2cea43b7c96fc0a0f4 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Tue, 17 Feb 2026 07:00:45 +0300 Subject: [PATCH 14/16] feat: add ServiceToken field to Config for session token validation --- internal/models/config.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/models/config.go b/internal/models/config.go index 37143d9..10a4594 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -2,7 +2,8 @@ package models // Config holds the configuration for the auth middleware. type Config struct { - IssuerURL string - Audience string - SkipPaths []string + IssuerURL string + Audience string + SkipPaths []string + ServiceToken string // enables session token validation as fallback } From 4e14d88b972d93d38a96a04116aef58ecef69907 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Tue, 17 Feb 2026 07:01:00 +0300 Subject: [PATCH 15/16] feat: enhance AuthN middleware to support Zitadel session token validation --- auth.go | 193 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 134 insertions(+), 59 deletions(-) diff --git a/auth.go b/auth.go index d802c1c..11e3358 100644 --- a/auth.go +++ b/auth.go @@ -1,7 +1,9 @@ package authkit import ( + "encoding/json" "fmt" + "io" "log" "net/http" "strings" @@ -14,6 +16,9 @@ import ( // It extracts the Bearer token from the Authorization header (or "token" query // parameter for WebSocket upgrades), validates it against the JWKS endpoint, // and stores the parsed claims in the Gin context. +// +// If ServiceToken is configured and JWT validation fails, it will attempt to +// validate the token as a Zitadel session token. func AuthN(cfg Config) gin.HandlerFunc { jwks := NewJWKSCache(cfg.IssuerURL + "/oauth/v2/keys") @@ -22,8 +27,8 @@ func AuthN(cfg Config) gin.HandlerFunc { skipSet[p] = true } - log.Printf("[authkit] Initialized AuthN middleware (issuer=%s, audience=%s, skip=%d paths)", - cfg.IssuerURL, cfg.Audience, len(cfg.SkipPaths)) + log.Printf("[authkit] Initialized AuthN middleware (issuer=%s, audience=%s, skip=%d paths, session_auth=%v)", + cfg.IssuerURL, cfg.Audience, len(cfg.SkipPaths), cfg.ServiceToken != "") return func(c *gin.Context) { // Skip configured paths @@ -41,78 +46,148 @@ func AuthN(cfg Config) gin.HandlerFunc { return } - // Parse and validate the JWT - token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { - // Verify signing method - if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - - // Get the key ID from the token header - kid, ok := token.Header["kid"].(string) - if !ok { - return nil, fmt.Errorf("missing kid in token header") - } - - // Fetch the public key from JWKS cache - key, err := jwks.GetKey(kid) - if err != nil { - return nil, err - } - return key, nil - }, - jwt.WithIssuer(cfg.IssuerURL), - jwt.WithValidMethods([]string{"RS256"}), - ) - - if err != nil || !token.Valid { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "invalid or expired token", - }) + // Try JWT validation first + claims, jwtErr := validateJWT(tokenStr, jwks, cfg) + if jwtErr == nil { + SetClaims(c, claims) + c.Next() return } - // Also validate audience if configured - if cfg.Audience != "" { - if err := validateAudience(token, cfg.Audience); err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "token audience mismatch", - }) + // If JWT validation failed and ServiceToken is configured, try session validation + if cfg.ServiceToken != "" { + claims, sessionErr := validateSessionToken(tokenStr, cfg) + if sessionErr == nil { + SetClaims(c, claims) + c.Next() return } + // Log session validation error for debugging + log.Printf("[authkit] Session validation failed: %v (JWT error: %v)", sessionErr, jwtErr) } - // Extract claims into our struct - mapClaims, ok := token.Claims.(jwt.MapClaims) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid or expired token", + }) + } +} + +// validateJWT validates a JWT token and returns claims +func validateJWT(tokenStr string, jwks *JWKSCache, cfg Config) (*Claims, error) { + token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + kid, ok := token.Header["kid"].(string) if !ok { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "invalid token claims", - }) - return + return nil, fmt.Errorf("missing kid in token header") } - - claims := &Claims{ - Sub: getStringClaim(mapClaims, "sub"), - Email: getStringClaim(mapClaims, "email"), - OrgID: getStringClaim(mapClaims, "urn:zitadel:iam:org:id"), - OrgDomain: getStringClaim(mapClaims, "urn:zitadel:iam:user:resourceowner:primary_domain"), + key, err := jwks.GetKey(kid) + if err != nil { + return nil, err } + return key, nil + }, + jwt.WithIssuer(cfg.IssuerURL), + jwt.WithValidMethods([]string{"RS256"}), + ) - // Extract project roles - if roles, ok := mapClaims["urn:zitadel:iam:org:project:roles"].(map[string]interface{}); ok { - claims.Roles = roles - } + if err != nil || !token.Valid { + return nil, fmt.Errorf("invalid JWT: %w", err) + } - // Fallback: extract org ID from roles claim if not present as a top-level claim. - // Zitadel embeds the org ID as the key inside each role grant, e.g.: - // "urn:zitadel:iam:org:project:roles": { "user": { "": "domain" } } - if claims.OrgID == "" && claims.Roles != nil { - claims.OrgID = extractOrgIDFromRoles(claims.Roles) + if cfg.Audience != "" { + if err := validateAudience(token, cfg.Audience); err != nil { + return nil, err } + } - SetClaims(c, claims) - c.Next() + mapClaims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("invalid claims type") } + + claims := &Claims{ + Sub: getStringClaim(mapClaims, "sub"), + Email: getStringClaim(mapClaims, "email"), + OrgID: getStringClaim(mapClaims, "urn:zitadel:iam:org:id"), + OrgDomain: getStringClaim(mapClaims, "urn:zitadel:iam:user:resourceowner:primary_domain"), + } + + if roles, ok := mapClaims["urn:zitadel:iam:org:project:roles"].(map[string]interface{}); ok { + claims.Roles = roles + } + + if claims.OrgID == "" && claims.Roles != nil { + claims.OrgID = extractOrgIDFromRoles(claims.Roles) + } + + return claims, nil +} + +// validateSessionToken validates a Zitadel session token and returns claims +// Token format: sessionId:sessionToken +func validateSessionToken(tokenStr string, cfg Config) (*Claims, error) { + // Parse sessionId:sessionToken format + parts := strings.SplitN(tokenStr, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid session token format (expected sessionId:sessionToken)") + } + + sessionID := parts[0] + sessionToken := parts[1] + + // Validate session with Zitadel's Session API + url := fmt.Sprintf("%s/v2/sessions/%s", cfg.IssuerURL, sessionID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+cfg.ServiceToken) + req.Header.Set("x-zitadel-session-token", sessionToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to validate session: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("session validation failed (status %d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Session struct { + ID string `json:"id"` + Factors struct { + User struct { + ID string `json:"id"` + LoginName string `json:"loginName"` + DisplayName string `json:"displayName"` + OrganizationID string `json:"organizationId"` + } `json:"user"` + } `json:"factors"` + } `json:"session"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode session response: %w", err) + } + + userFactor := result.Session.Factors.User + + if userFactor.ID == "" { + return nil, fmt.Errorf("session has no user factor") + } + + return &Claims{ + Sub: userFactor.ID, + Email: userFactor.LoginName, + OrgID: userFactor.OrganizationID, + }, nil } // extractToken gets the JWT from the Authorization header or "token" query param. From 734c583cd6ef2a419b3e60976132e1c6c8f3dd21 Mon Sep 17 00:00:00 2001 From: Alan Njogu Date: Tue, 17 Feb 2026 07:01:16 +0300 Subject: [PATCH 16/16] feat: add Session API v2 methods for session management and IDP intent handling --- zitadel_client.go | 518 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 518 insertions(+) diff --git a/zitadel_client.go b/zitadel_client.go index 9c4a936..312c455 100644 --- a/zitadel_client.go +++ b/zitadel_client.go @@ -819,3 +819,521 @@ func (z *ZitadelClient) RemoveUserIDPLink(ctx context.Context, userID, idpID, ex return nil } + +// ============================================================================= +// Session API v2 Methods +// ============================================================================= + +// CreateSessionRequest is an alias for models.CreateSessionRequest +type CreateSessionRequest = models.CreateSessionRequest + +// CreateSessionResponse is an alias for models.CreateSessionResponse +type CreateSessionResponse = models.CreateSessionResponse + +// SetSessionRequest is an alias for models.SetSessionRequest +type SetSessionRequest = models.SetSessionRequest + +// SetSessionResponse is an alias for models.SetSessionResponse +type SetSessionResponse = models.SetSessionResponse + +// SessionChecks is an alias for models.SessionChecks +type SessionChecks = models.SessionChecks + +// SessionUserCheck is an alias for models.SessionUserCheck +type SessionUserCheck = models.SessionUserCheck + +// SessionPasswordCheck is an alias for models.SessionPasswordCheck +type SessionPasswordCheck = models.SessionPasswordCheck + +// SessionIDPIntentCheck is an alias for models.SessionIDPIntentCheck +type SessionIDPIntentCheck = models.SessionIDPIntentCheck + +// SessionChallenges is an alias for models.SessionChallenges +type SessionChallenges = models.SessionChallenges + +// Session is an alias for models.Session +type Session = models.Session + +// CreateSession creates a new Zitadel session with optional checks and challenges. +func (z *ZitadelClient) CreateSession(ctx context.Context, req *CreateSessionRequest) (*CreateSessionResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/v2/sessions", z.ZitadelClient.BaseURL) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result CreateSessionResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// SetSession updates an existing session with new checks or challenges. +func (z *ZitadelClient) SetSession(ctx context.Context, sessionID string, req *SetSessionRequest) (*SetSessionResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/v2/sessions/%s", z.ZitadelClient.BaseURL, sessionID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result SetSessionResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// GetSession retrieves an existing session by ID. +func (z *ZitadelClient) GetSession(ctx context.Context, sessionID, sessionToken string) (*Session, error) { + url := fmt.Sprintf("%s/v2/sessions/%s", z.ZitadelClient.BaseURL, sessionID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + if sessionToken != "" { + httpReq.Header.Set("x-zitadel-session-token", sessionToken) + } + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result models.GetSessionResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return result.Session, nil +} + +// DeleteSession terminates a session. +func (z *ZitadelClient) DeleteSession(ctx context.Context, sessionID, sessionToken string) error { + reqBody := &models.DeleteSessionRequest{} + if sessionToken != "" { + reqBody.SessionToken = sessionToken + } + + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/v2/sessions/%s", z.ZitadelClient.BaseURL, sessionID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// ============================================================================= +// IDP Intent API v2 Methods +// ============================================================================= + +// StartIDPIntentRequest is an alias for models.StartIDPIntentRequest +type StartIDPIntentRequest = models.StartIDPIntentRequest + +// StartIDPIntentResponse is an alias for models.StartIDPIntentResponse +type StartIDPIntentResponse = models.StartIDPIntentResponse + +// IDPIntentURLs is an alias for models.IDPIntentURLs +type IDPIntentURLs = models.IDPIntentURLs + +// RetrieveIDPIntentResponse is an alias for models.RetrieveIDPIntentResponse +type RetrieveIDPIntentResponse = models.RetrieveIDPIntentResponse + +// IDPInformation is an alias for models.IDPInformation +type IDPInformation = models.IDPInformation + +// StartIDPIntent initiates an external identity provider login flow. +func (z *ZitadelClient) StartIDPIntent(ctx context.Context, req *StartIDPIntentRequest) (*StartIDPIntentResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/v2/idp_intents", z.ZitadelClient.BaseURL) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result StartIDPIntentResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// RetrieveIDPIntent retrieves the information from a completed IDP intent. +func (z *ZitadelClient) RetrieveIDPIntent(ctx context.Context, intentID, intentToken string) (*RetrieveIDPIntentResponse, error) { + reqBody := &models.RetrieveIDPIntentRequest{ + IDPIntentToken: intentToken, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/v2/idp_intents/%s", z.ZitadelClient.BaseURL, intentID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result RetrieveIDPIntentResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// ============================================================================= +// OIDC Auth Request API v2 Methods +// ============================================================================= + +// AuthRequest is an alias for models.AuthRequest +type AuthRequest = models.AuthRequest + +// FinalizeAuthRequestResponse is an alias for models.FinalizeAuthRequestResponse +type FinalizeAuthRequestResponse = models.FinalizeAuthRequestResponse + +// GetAuthRequest retrieves an OIDC authorization request. +func (z *ZitadelClient) GetAuthRequest(ctx context.Context, authRequestID string) (*AuthRequest, error) { + url := fmt.Sprintf("%s/v2/oidc/auth_requests/%s", z.ZitadelClient.BaseURL, authRequestID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result models.GetAuthRequestResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return result.AuthRequest, nil +} + +// FinalizeAuthRequest finalizes an OIDC auth request with a session. +func (z *ZitadelClient) FinalizeAuthRequest(ctx context.Context, authRequestID, sessionID, sessionToken string) (*FinalizeAuthRequestResponse, error) { + reqBody := &models.FinalizeAuthRequestRequest{ + Session: &models.FinalizeAuthRequestSession{ + SessionID: sessionID, + SessionToken: sessionToken, + }, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/v2/oidc/auth_requests/%s", z.ZitadelClient.BaseURL, authRequestID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result FinalizeAuthRequestResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// ============================================================================= +// User Creation with IDP Link Methods +// ============================================================================= + +// CreateUserWithIDPRequest is an alias for models.CreateUserWithIDPRequest +type CreateUserWithIDPRequest = models.CreateUserWithIDPRequest + +// CreateHumanUserResponse is an alias for models.CreateHumanUserResponse +type CreateHumanUserResponse = models.CreateHumanUserResponse + +// AddHumanUserIDPLink is an alias for models.AddHumanUserIDPLink +type AddHumanUserIDPLink = models.AddHumanUserIDPLink + +// UserProfile is an alias for models.UserProfile +type UserProfile = models.UserProfile + +// UserEmail is an alias for models.UserEmail +type UserEmail = models.UserEmail + +// CreateUserWithIDP creates a new user with an IDP link. +func (z *ZitadelClient) CreateUserWithIDP(ctx context.Context, orgID string, req *CreateUserWithIDPRequest) (*CreateHumanUserResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/v2/users/human", z.ZitadelClient.BaseURL) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + if orgID != "" { + httpReq.Header.Set("x-zitadel-orgid", orgID) + } + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + if resp.StatusCode == http.StatusConflict { + return nil, ErrUserAlreadyExists + } + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result CreateHumanUserResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// AddUserIDPLink adds an IDP link to an existing user. +func (z *ZitadelClient) AddUserIDPLink(ctx context.Context, userID string, idpID, idpUserID, idpUserName string) error { + reqBody := &models.AddIDPLinkRequest{ + IDPLink: &models.IDPLinkInfo{ + IDPID: idpID, + UserID: idpUserID, + UserName: idpUserName, + }, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/v2/users/%s/links/idps", z.ZitadelClient.BaseURL, userID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// ============================================================================= +// Login Settings Methods +// ============================================================================= + +// LoginSettings is an alias for models.LoginSettings +type LoginSettings = models.LoginSettings + +// GetLoginSettings retrieves the login settings for an organization. +func (z *ZitadelClient) GetLoginSettings(ctx context.Context, orgID string) (*LoginSettings, error) { + url := fmt.Sprintf("%s/v2/settings/login", z.ZitadelClient.BaseURL) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+z.ZitadelClient.ServiceToken) + if orgID != "" { + httpReq.Header.Set("x-zitadel-orgid", orgID) + } + + resp, err := z.ZitadelClient.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result models.GetLoginSettingsResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return result.Settings, nil +}