package zitadel import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" ) // Client calls ZITADEL Management API v2 and OAuth token endpoints. type Client struct { conf Conf http *http.Client apiBase string issuer string jwks *jwksCache } // NewClient constructs a Client. Returns (nil, nil) when Issuer is empty. func NewClient(conf Conf) (*Client, error) { conf = conf.Defaults() if conf.Issuer == "" { return nil, nil } apiBase := strings.TrimRight(conf.APIBase, "/") issuer := strings.TrimRight(conf.Issuer, "/") if apiBase == "" { apiBase = issuer } return &Client{ conf: conf, apiBase: apiBase, issuer: issuer, http: &http.Client{ Timeout: conf.timeout(), }, }, nil } func (c *Client) postToken(ctx context.Context, form url.Values) (*TokenResult, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.issuer+"/oauth/v2/token", strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("zitadel: token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("zitadel: token request: %w", err) } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("zitadel: read token response: %w", err) } if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest { return nil, ErrInvalidCredentials } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("zitadel: token request: status %d: %s", resp.StatusCode, truncateBody(raw)) } var tok struct { AccessToken string `json:"access_token"` IDToken string `json:"id_token"` ExpiresIn int `json:"expires_in"` TokenType string `json:"token_type"` } if err := json.Unmarshal(raw, &tok); err != nil { return nil, fmt.Errorf("zitadel: decode token response: %w", err) } if tok.AccessToken == "" { return nil, fmt.Errorf("zitadel: empty access_token") } return &TokenResult{ AccessToken: tok.AccessToken, IDToken: tok.IDToken, ExpiresIn: tok.ExpiresIn, TokenType: tok.TokenType, }, nil } // CreateHumanUserRequest creates a human user with email/password profile. type CreateHumanUserRequest struct { OrgID string Email string Password string DisplayName string Language string EmailVerified bool } // CreateHumanUserResult is the created ZITADEL user id (sub). type CreateHumanUserResult struct { UserID string } // CreateHumanUser registers a human user via POST /v2/users/human. func (c *Client) CreateHumanUser(ctx context.Context, req CreateHumanUserRequest) (*CreateHumanUserResult, error) { if c == nil { return nil, ErrNotConfigured } if c.conf.ServiceUserToken == "" { return nil, ErrNotConfigured } orgID := req.OrgID if orgID == "" { orgID = c.conf.DefaultOrgID } given, family := splitDisplayName(req.DisplayName, req.Email) profile := map[string]any{ "givenName": given, "familyName": family, } if req.DisplayName != "" { profile["displayName"] = req.DisplayName } if req.Language != "" { profile["preferredLanguage"] = req.Language } body := map[string]any{ "username": req.Email, "profile": profile, "email": map[string]any{ "email": req.Email, "isVerified": req.EmailVerified, }, "password": map[string]any{ "password": req.Password, "changeRequired": false, }, } if orgID != "" { body["organizationId"] = orgID } var out struct { UserID string `json:"userId"` } if err := c.doJSON(ctx, http.MethodPost, c.apiBase+"/v2/users/human", c.serviceAuth(), body, http.StatusOK, &out); err != nil { return nil, err } if out.UserID == "" { return nil, fmt.Errorf("zitadel: create user: empty userId in response") } return &CreateHumanUserResult{UserID: out.UserID}, nil } // DeactivateUser disables login for the user via POST /v2/users/{id}/deactivate. func (c *Client) DeactivateUser(ctx context.Context, userID string) error { if c == nil { return ErrNotConfigured } if userID == "" { return fmt.Errorf("zitadel: user id is required") } return c.doJSON(ctx, http.MethodPost, c.apiBase+"/v2/users/"+url.PathEscape(userID)+"/deactivate", c.serviceAuth(), map[string]any{}, http.StatusOK, nil) } // TokenResult holds OAuth tokens from a successful password grant. type TokenResult struct { AccessToken string IDToken string ExpiresIn int TokenType string } // VerifyPassword checks credentials using the OAuth2 resource-owner password grant. func (c *Client) VerifyPassword(ctx context.Context, username, password string) (*TokenResult, error) { if c == nil { return nil, ErrNotConfigured } if c.conf.OAuthClientID == "" || c.conf.OAuthClientSecret == "" { return nil, fmt.Errorf("zitadel: oauth client credentials are required for password verification") } form := url.Values{} form.Set("grant_type", "password") form.Set("client_id", c.conf.OAuthClientID) form.Set("client_secret", c.conf.OAuthClientSecret) form.Set("username", username) form.Set("password", password) form.Set("scope", "openid profile email") return c.postToken(ctx, form) } func (c *Client) serviceAuth() string { return "Bearer " + c.conf.ServiceUserToken } func (c *Client) doJSON(ctx context.Context, method, endpoint, auth string, body any, wantStatus int, out any) error { var r io.Reader if body != nil { raw, err := json.Marshal(body) if err != nil { return fmt.Errorf("zitadel: marshal request: %w", err) } r = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, method, endpoint, r) if err != nil { return fmt.Errorf("zitadel: new request: %w", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", "application/json") if auth != "" { req.Header.Set("Authorization", auth) } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("zitadel: %s %s: %w", method, endpoint, err) } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("zitadel: read response body: %w", err) } if resp.StatusCode == http.StatusConflict { return ErrUserAlreadyExists } if resp.StatusCode != wantStatus { return fmt.Errorf("zitadel: %s %s: status %d: %s", method, endpoint, resp.StatusCode, truncateBody(raw)) } if out != nil && len(raw) > 0 { if err := json.Unmarshal(raw, out); err != nil { return fmt.Errorf("zitadel: decode response: %w", err) } } return nil } func splitDisplayName(displayName, email string) (given, family string) { displayName = strings.TrimSpace(displayName) if displayName == "" { local := email if i := strings.Index(email, "@"); i > 0 { local = email[:i] } return local, "-" } parts := strings.Fields(displayName) if len(parts) == 1 { return parts[0], "-" } return parts[0], strings.Join(parts[1:], " ") } func truncateBody(b []byte) string { const maxBodyLen = 512 s := strings.TrimSpace(string(b)) if len(s) > maxBodyLen { return s[:maxBodyLen] + "..." } return s }