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

128 lines
3.5 KiB
Go

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{
"password": map[string]any{"password": 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 any, 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")
}