279 lines
7.7 KiB
Go
279 lines
7.7 KiB
Go
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, 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. Uses OAuth2 ROPG when OAuthClientID
|
|
// and OAuthClientSecret are configured; otherwise uses ZITADEL v2 Sessions API (PAT).
|
|
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 c.verifyPasswordROPG(ctx, username, password)
|
|
}
|
|
return c.verifyPasswordSession(ctx, username, password)
|
|
}
|
|
|
|
func (c *Client) verifyPasswordROPG(ctx context.Context, username, password string) (*TokenResult, error) {
|
|
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
|
|
}
|
|
// 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
|
|
}
|