309 lines
9.0 KiB
Go
309 lines
9.0 KiB
Go
package zitadel
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Config holds everything the controller needs to call ZITADEL.
|
||
|
|
type Config struct {
|
||
|
|
// Issuer is the instance URL, for example https://zitadel.riotpiao.com.
|
||
|
|
Issuer string
|
||
|
|
// ClientID is the API app that this controller authenticates as.
|
||
|
|
ClientID string
|
||
|
|
// UserID is the machine user the assertion acts for. Empty means ClientID.
|
||
|
|
UserID string
|
||
|
|
// KeyID and PrivateKeyPEM identify the controller's own machine key.
|
||
|
|
KeyID string
|
||
|
|
PrivateKeyPEM []byte
|
||
|
|
// HTTPClient is optional; a sane default is used when nil.
|
||
|
|
HTTPClient *http.Client
|
||
|
|
}
|
||
|
|
|
||
|
|
// Client is a thin ZITADEL management client.
|
||
|
|
type Client struct {
|
||
|
|
cfg Config
|
||
|
|
http *http.Client
|
||
|
|
mu sync.Mutex
|
||
|
|
token string
|
||
|
|
until time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// New builds a client.
|
||
|
|
func New(cfg Config) *Client {
|
||
|
|
client := cfg.HTTPClient
|
||
|
|
if client == nil {
|
||
|
|
client = &http.Client{Timeout: 20 * time.Second}
|
||
|
|
}
|
||
|
|
return &Client{cfg: cfg, http: client}
|
||
|
|
}
|
||
|
|
|
||
|
|
// AccessToken returns a cached access token, minting one when needed.
|
||
|
|
func (c *Client) AccessToken() (string, error) {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
|
||
|
|
if c.token != "" && time.Now().Before(c.until) {
|
||
|
|
return c.token, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
assertion, err := SignAssertion(c.cfg.PrivateKeyPEM, c.cfg.KeyID, c.cfg.ClientID, c.cfg.UserID, c.cfg.Issuer, time.Now())
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
form := url.Values{}
|
||
|
|
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||
|
|
form.Set("assertion", assertion)
|
||
|
|
form.Set("scope", "openid")
|
||
|
|
|
||
|
|
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/token", strings.NewReader(form.Encode()))
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
|
||
|
|
resp, err := c.http.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("token request failed: %w", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
return "", fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||
|
|
}
|
||
|
|
|
||
|
|
var parsed struct {
|
||
|
|
AccessToken string `json:"access_token"`
|
||
|
|
ExpiresIn int `json:"expires_in"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||
|
|
return "", fmt.Errorf("token response did not parse: %w", err)
|
||
|
|
}
|
||
|
|
if parsed.AccessToken == "" {
|
||
|
|
return "", fmt.Errorf("token response carried no access_token")
|
||
|
|
}
|
||
|
|
|
||
|
|
lifetime := parsed.ExpiresIn
|
||
|
|
if lifetime <= 0 {
|
||
|
|
lifetime = 300
|
||
|
|
}
|
||
|
|
c.token = parsed.AccessToken
|
||
|
|
// Refresh at 80 % of the lifetime so a slow call never presents an expired token.
|
||
|
|
c.until = time.Now().Add(time.Duration(lifetime) * time.Second * 4 / 5)
|
||
|
|
return c.token, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// projectAppPath builds the management path for one OIDC app under a project.
|
||
|
|
func (c *Client) projectAppPath(projectID, appID string) string {
|
||
|
|
return fmt.Sprintf("/management/v1/projects/%s/apps/%s", projectID, appID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// MachineKey is one registered key for a service user.
|
||
|
|
type MachineKey struct {
|
||
|
|
KeyID string `json:"keyId"`
|
||
|
|
Expiration string `json:"expirationDate,omitempty"`
|
||
|
|
// Details holds the PEM bundle when the key was just created.
|
||
|
|
Details string `json:"keyDetails,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewMachineKeyJSON is the JSON document ZITADEL returns for a new JSON key.
|
||
|
|
type NewMachineKeyJSON struct {
|
||
|
|
Type string `json:"type"`
|
||
|
|
KeyID string `json:"keyId"`
|
||
|
|
Key string `json:"key"`
|
||
|
|
UserID string `json:"userId"`
|
||
|
|
ExpireAt string `json:"expirationDate,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// CreateMachineKey adds a key to a service user and returns its material.
|
||
|
|
//
|
||
|
|
// ZITADEL keeps many keys per user, so this never invalidates the key that is
|
||
|
|
// currently deployed. That is the whole reason the machine-key provider can
|
||
|
|
// rotate with no gap.
|
||
|
|
func (c *Client) CreateMachineKey(userID string) (*NewMachineKeyJSON, error) {
|
||
|
|
payload := map[string]any{"type": "KEY_TYPE_JSON"}
|
||
|
|
var out struct {
|
||
|
|
KeyID string `json:"keyId"`
|
||
|
|
KeyDetails string `json:"keyDetails"`
|
||
|
|
}
|
||
|
|
if err := c.post("/management/v1/users/"+userID+"/keys", payload, &out); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if out.KeyDetails == "" {
|
||
|
|
return nil, fmt.Errorf("create machine key returned no key material")
|
||
|
|
}
|
||
|
|
|
||
|
|
raw, err := base64Decode(out.KeyDetails)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("key material is not base64: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
var key NewMachineKeyJSON
|
||
|
|
if err := json.Unmarshal(raw, &key); err != nil {
|
||
|
|
return nil, fmt.Errorf("key material is not the expected JSON: %w", err)
|
||
|
|
}
|
||
|
|
return &key, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// DeleteMachineKey removes one key. Called only after the new key is proven.
|
||
|
|
func (c *Client) DeleteMachineKey(userID, keyID string) error {
|
||
|
|
return c.delete("/management/v1/users/" + userID + "/keys/" + keyID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GenerateClientSecret replaces the client secret of an OIDC app.
|
||
|
|
//
|
||
|
|
// ZITADEL stores exactly one secret per app, so the previous secret stops working
|
||
|
|
// at this call. The caller must publish the new value immediately.
|
||
|
|
func (c *Client) GenerateClientSecret(projectID, appID string) (string, error) {
|
||
|
|
var out struct {
|
||
|
|
ClientSecret string `json:"clientSecret"`
|
||
|
|
}
|
||
|
|
path := c.projectAppPath(projectID, appID) + "/oidc_config/_generate_client_secret"
|
||
|
|
if err := c.post(path, map[string]any{}, &out); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
if out.ClientSecret == "" {
|
||
|
|
return "", fmt.Errorf("generate client secret returned an empty value")
|
||
|
|
}
|
||
|
|
return out.ClientSecret, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Introspect checks a client credential against the token endpoint.
|
||
|
|
//
|
||
|
|
// A successful call returns an object with `active`. The credentials are valid
|
||
|
|
// when the endpoint answers 200, whatever `active` says, because `active`
|
||
|
|
// describes the token rather than the client.
|
||
|
|
func (c *Client) Introspect(clientID, clientSecret string) error {
|
||
|
|
form := url.Values{}
|
||
|
|
form.Set("token", "probe")
|
||
|
|
|
||
|
|
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/introspect", strings.NewReader(form.Encode()))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
req.SetBasicAuth(clientID, clientSecret)
|
||
|
|
|
||
|
|
resp, err := c.http.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("introspection request failed: %w", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
return fmt.Errorf("introspection returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ExchangeMachineKey obtains a token with a specific key, proving that key works.
|
||
|
|
func (c *Client) ExchangeMachineKey(clientID, userID, keyID string, keyPEM []byte, audienceProject string) error {
|
||
|
|
assertion, err := SignAssertion(keyPEM, keyID, clientID, userID, c.cfg.Issuer, time.Now())
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
scope := "openid"
|
||
|
|
if audienceProject != "" {
|
||
|
|
scope += " urn:zitadel:iam:org:project:id:" + audienceProject + ":aud"
|
||
|
|
}
|
||
|
|
|
||
|
|
form := url.Values{}
|
||
|
|
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||
|
|
form.Set("assertion", assertion)
|
||
|
|
form.Set("scope", scope)
|
||
|
|
|
||
|
|
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/token", strings.NewReader(form.Encode()))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
|
||
|
|
resp, err := c.http.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("key exchange failed: %w", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
return fmt.Errorf("key exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) do(method, path string, payload any, out any) error {
|
||
|
|
token, err := c.AccessToken()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
var body io.Reader
|
||
|
|
if payload != nil {
|
||
|
|
encoded, err := json.Marshal(payload)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
body = bytes.NewReader(encoded)
|
||
|
|
}
|
||
|
|
|
||
|
|
req, err := http.NewRequest(method, strings.TrimRight(c.cfg.Issuer, "/")+path, body)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
|
if payload != nil {
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
}
|
||
|
|
|
||
|
|
resp, err := c.http.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
|
|
return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw)))
|
||
|
|
}
|
||
|
|
if out != nil && len(raw) > 0 {
|
||
|
|
if err := json.Unmarshal(raw, out); err != nil {
|
||
|
|
return fmt.Errorf("%s %s response did not parse: %w", method, path, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) post(path string, payload, out any) error {
|
||
|
|
return c.do(http.MethodPost, path, payload, out)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) delete(path string) error {
|
||
|
|
return c.do(http.MethodDelete, path, nil, nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
func base64Decode(s string) ([]byte, error) {
|
||
|
|
if raw, err := base64StdDecode(s); err == nil {
|
||
|
|
return raw, nil
|
||
|
|
}
|
||
|
|
return base64URLDecode(s)
|
||
|
|
}
|
||
|
|
|
||
|
|
func base64StdDecode(s string) ([]byte, error) {
|
||
|
|
return base64.StdEncoding.DecodeString(s)
|
||
|
|
}
|
||
|
|
|
||
|
|
func base64URLDecode(s string) ([]byte, error) {
|
||
|
|
return base64.RawURLEncoding.DecodeString(s)
|
||
|
|
}
|