template-monorepo/internal/library/zitadel/client.go

307 lines
8.7 KiB
Go

package zitadel
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
const fieldPassword = "password"
// 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,
},
fieldPassword: map[string]any{
fieldPassword: 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
}
// SetUserPassword sets a human user's password via management API (PAT).
// When currentPassword is non-empty, ZITADEL validates the old password first.
func (c *Client) SetUserPassword(ctx context.Context, userID, newPassword, currentPassword string) error {
if c == nil {
return ErrNotConfigured
}
if userID == "" || newPassword == "" {
return fmt.Errorf("zitadel: user id and new password are required")
}
body := map[string]any{
"newPassword": map[string]any{
fieldPassword: newPassword,
"changeRequired": false,
},
}
if strings.TrimSpace(currentPassword) != "" {
body["currentPassword"] = currentPassword
}
endpoint := c.apiBase + "/v2/users/" + url.PathEscape(userID) + "/password"
return c.doJSON(ctx, http.MethodPost, endpoint, c.serviceAuth(), body, http.StatusOK, 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, or session-verified
// identity fields when OAuth ROPG is not configured (ZITADEL v2 default).
type TokenResult struct {
AccessToken string
IDToken string
ExpiresIn int
TokenType string
// Subject and Email are set by session-based verification (no OAuth tokens).
Subject string
Email string
}
// VerifyPassword checks email/password credentials. ZITADEL v2 disables the
// resource-owner password grant by default, so when a service PAT is configured
// we use the Sessions API first. ROPG remains a fallback for legacy instances.
func (c *Client) VerifyPassword(ctx context.Context, username, password string) (*TokenResult, error) {
if c == nil {
return nil, ErrNotConfigured
}
if c.conf.ServiceUserToken != "" {
return c.verifyPasswordSession(ctx, username, password)
}
if c.conf.OAuthClientID != "" && c.conf.OAuthClientSecret != "" {
return c.verifyPasswordROPG(ctx, username, password)
}
return nil, fmt.Errorf("zitadel: password verification not configured (need service token or oauth client)")
}
func (c *Client) verifyPasswordROPG(ctx context.Context, username, password string) (*TokenResult, error) {
form := url.Values{}
form.Set("grant_type", fieldPassword)
form.Set("client_id", c.conf.OAuthClientID)
form.Set("client_secret", c.conf.OAuthClientSecret)
form.Set("username", username)
form.Set(fieldPassword, 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
}
// Accept any 2xx as success. ZITADEL v2 returns 201 for create endpoints
// (e.g. POST /v2/users/human) and 200 for most others; wantStatus is kept
// for caller intent but we don't reject other 2xx responses.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_ = 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
}