package zitadel import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" ) // verifyPasswordSession checks credentials via ZITADEL v2 Sessions API (PAT-backed). // Used when OAuth resource-owner password grant is unavailable (default in ZITADEL v2). func (c *Client) verifyPasswordSession(ctx context.Context, loginName, password string) (*TokenResult, error) { if c.conf.ServiceUserToken == "" { return nil, fmt.Errorf("zitadel: service user token is required for session password verification") } loginName = strings.TrimSpace(loginName) if loginName == "" || password == "" { return nil, ErrInvalidCredentials } var created struct { SessionID string `json:"sessionId"` } if err := c.doSessionJSON(ctx, http.MethodPost, c.apiBase+"/v2/sessions", map[string]any{ "checks": map[string]any{ "user": map[string]any{"loginName": loginName}, }, }, &created); err != nil { if isSessionUserNotFound(err) { return nil, ErrInvalidCredentials } return nil, err } if created.SessionID == "" { return nil, fmt.Errorf("zitadel: create session: empty session id") } if err := c.doSessionJSON(ctx, http.MethodPatch, c.apiBase+"/v2/sessions/"+created.SessionID, map[string]any{ "checks": map[string]any{ fieldPassword: map[string]any{fieldPassword: password}, }, }, nil); err != nil { if isSessionPasswordInvalid(err) { return nil, ErrInvalidCredentials } return nil, err } var got struct { Session struct { Factors struct { User struct { ID string `json:"id"` LoginName string `json:"loginName"` } `json:"user"` Password struct { VerifiedAt string `json:"verifiedAt"` } `json:"password"` } `json:"factors"` } `json:"session"` } if err := c.doSessionJSON(ctx, http.MethodGet, c.apiBase+"/v2/sessions/"+created.SessionID, nil, &got); err != nil { return nil, err } if got.Session.Factors.Password.VerifiedAt == "" || got.Session.Factors.User.ID == "" { return nil, ErrInvalidCredentials } return &TokenResult{ Subject: got.Session.Factors.User.ID, Email: got.Session.Factors.User.LoginName, }, nil } func (c *Client) doSessionJSON(ctx context.Context, method, endpoint string, body, 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") req.Header.Set("Authorization", c.serviceAuth()) 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 < 200 || resp.StatusCode >= 300 { 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 isSessionUserNotFound(err error) bool { return err != nil && strings.Contains(err.Error(), "status 404") } func isSessionPasswordInvalid(err error) bool { if err == nil { return false } s := err.Error() return strings.Contains(s, "status 400") && strings.Contains(s, "Password is invalid") }