Twice the identity provider's admin kept an older password than the one the mesh minted (an adopted, then a moved database), and the provisioner failed every consumer until it was repaired by hand (hq issue 179). The module now checks the admin's login and repairs a refusal itself through the server's bootstrap command, verifies, brakes a failed repair and announces it, and stops asking the server while refused. Ported to Go to change it.
487 lines
16 KiB
Go
487 lines
16 KiB
Go
package main
|
|
|
|
// The Keycloak admin API client — keycloak's own code, living in the module (novox/hq ADR 0039).
|
|
// Ported from client.ts: the same environment, the same token cache, the same one retry on a 401.
|
|
//
|
|
// A representation is a map rather than a struct, so an update carries back every field the server
|
|
// sent — including ones this module does not know — and never drops what somebody else set.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Rep is a representation as the admin API sends and takes it.
|
|
type Rep = map[string]any
|
|
|
|
// ErrRejected marks a token request the server refused for the credentials: 401, or an
|
|
// `invalid_grant` — wrong password, missing or disabled user. The guard repairs exactly this.
|
|
var ErrRejected = errors.New("credentials rejected: invalid_grant")
|
|
|
|
// Client speaks to one Keycloak server's admin API as one admin.
|
|
type Client struct {
|
|
BaseURL string
|
|
AdminUser string
|
|
DefaultRealm string
|
|
// password is read each time a token is needed, so a rotated secret is used without a restart.
|
|
password func() (string, error)
|
|
http *http.Client
|
|
// onRejected is told when the server refuses the admin's credentials (the guard's nudge).
|
|
onRejected func()
|
|
|
|
mu sync.Mutex
|
|
token string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// meshConfig is the settings-merged config the mesh delivers (novox/hq ADR 0046).
|
|
func meshConfig(file string) map[string]any {
|
|
out := map[string]any{}
|
|
if file == "" {
|
|
return out
|
|
}
|
|
raw, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
_ = json.Unmarshal(raw, &out)
|
|
return out
|
|
}
|
|
|
|
func cfgString(cfg map[string]any, key string) string {
|
|
if s, ok := cfg[key].(string); ok {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstOf(values ...string) string {
|
|
for _, v := range values {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// secretFile is a secret file's value with its trailing newline trimmed.
|
|
func secretFile(file string) (string, error) {
|
|
raw, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
s := strings.TrimSuffix(string(raw), "\n")
|
|
if s == "" {
|
|
return "", fmt.Errorf("%s is empty", file)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// ClientFromEnv builds the client from the module's resolved environment, as client.ts did: the
|
|
// config file first, then MESH_KEYCLOAK_*, then the container's own KEYCLOAK_ADMIN* names.
|
|
// Refused when no admin password can be found at all: without one there is nothing to serve.
|
|
func ClientFromEnv(getenv func(string) string) (*Client, error) {
|
|
cfg := meshConfig(getenv("MESH_KEYCLOAK_CONFIG_FILE"))
|
|
port := firstOf(getenv("KEYCLOAK_PORT"), "8080")
|
|
base := firstOf(cfgString(cfg, "url"), getenv("MESH_KEYCLOAK_URL"), "http://127.0.0.1:"+port)
|
|
user := firstOf(cfgString(cfg, "user"), getenv("MESH_KEYCLOAK_ADMIN"), getenv("KEYCLOAK_ADMIN"), "admin")
|
|
realm := firstOf(cfgString(cfg, "realm"), getenv("MESH_KEYCLOAK_REALM"), "master")
|
|
|
|
// The admin password reaches the runtime as a file (novox/hq ADR 0086): the module's own `admin`
|
|
// secret. Read on every token request, so the guard and the provisioner always use the mesh's
|
|
// current one.
|
|
fixed := firstOf(cfgString(cfg, "password"))
|
|
file := getenv("MESH_KEYCLOAK_PASSWORD_FILE")
|
|
env := firstOf(getenv("MESH_KEYCLOAK_PASSWORD"), getenv("KEYCLOAK_ADMIN_PASSWORD"))
|
|
password := func() (string, error) {
|
|
if fixed != "" {
|
|
return fixed, nil
|
|
}
|
|
if file != "" {
|
|
if s, err := secretFile(file); err == nil {
|
|
return s, nil
|
|
} else if env == "" {
|
|
return "", fmt.Errorf("the admin password cannot be read: %w", err)
|
|
}
|
|
}
|
|
if env != "" {
|
|
return env, nil
|
|
}
|
|
return "", errors.New("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD_FILE (or MESH_KEYCLOAK_PASSWORD)")
|
|
}
|
|
if fixed == "" && file == "" && env == "" {
|
|
return nil, errors.New("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD_FILE (or MESH_KEYCLOAK_PASSWORD)")
|
|
}
|
|
return NewClient(base, user, password, realm), nil
|
|
}
|
|
|
|
// NewClient is a client for one server and admin.
|
|
func NewClient(base, user string, password func() (string, error), realm string) *Client {
|
|
return &Client{
|
|
BaseURL: strings.TrimRight(base, "/"), AdminUser: user, DefaultRealm: realm,
|
|
password: password, http: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// Password is the admin password the mesh holds now.
|
|
func (c *Client) Password() (string, error) { return c.password() }
|
|
|
|
// TokenError is a token request the server answered with something other than a token.
|
|
type TokenError struct {
|
|
Status int
|
|
Body string
|
|
}
|
|
|
|
func (e *TokenError) Error() string {
|
|
return fmt.Sprintf("Keycloak token request failed: %d %s", e.Status, e.Body)
|
|
}
|
|
|
|
// Unwrap makes a refusal of the credentials an ErrRejected.
|
|
func (e *TokenError) Unwrap() error {
|
|
if e.Status == http.StatusUnauthorized || strings.Contains(e.Body, "invalid_grant") {
|
|
return ErrRejected
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Login asks for a fresh token with the admin's credentials, bypassing the cache: what the guard
|
|
// checks with. It tells nobody about a refusal; getToken does.
|
|
func (c *Client) Login(ctx context.Context) (string, time.Duration, error) {
|
|
pw, err := c.password()
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
form := url.Values{"grant_type": {"password"}, "client_id": {"admin-cli"},
|
|
"username": {c.AdminUser}, "password": {pw}}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
c.BaseURL+"/realms/master/protocol/openid-connect/token", strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
res, err := c.http.Do(req)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
defer res.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
|
if res.StatusCode != http.StatusOK {
|
|
return "", 0, &TokenError{Status: res.StatusCode, Body: strings.TrimSpace(string(body))}
|
|
}
|
|
var data struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
if err := json.Unmarshal(body, &data); err != nil || data.AccessToken == "" {
|
|
return "", 0, fmt.Errorf("Keycloak token response unreadable: %v", err)
|
|
}
|
|
return data.AccessToken, time.Duration(data.ExpiresIn) * time.Second, nil
|
|
}
|
|
|
|
// getToken is a cached token, valid for at least thirty seconds more.
|
|
func (c *Client) getToken(ctx context.Context) (string, error) {
|
|
c.mu.Lock()
|
|
if c.token != "" && time.Now().Before(c.expiresAt) {
|
|
t := c.token
|
|
c.mu.Unlock()
|
|
return t, nil
|
|
}
|
|
c.mu.Unlock()
|
|
token, life, err := c.Login(ctx)
|
|
if err != nil {
|
|
// The guard is told, so a refused admin is repaired now rather than at its next check. Only
|
|
// here, never in Login: the guard checks with Login, and a check that nudged the guard
|
|
// would be a guard checking in a loop.
|
|
if errors.Is(err, ErrRejected) && c.onRejected != nil {
|
|
c.onRejected()
|
|
}
|
|
return "", err
|
|
}
|
|
c.mu.Lock()
|
|
c.token, c.expiresAt = token, time.Now().Add(life-30*time.Second)
|
|
c.mu.Unlock()
|
|
return token, nil
|
|
}
|
|
|
|
func (c *Client) dropToken() {
|
|
c.mu.Lock()
|
|
c.token = ""
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// APIError is an admin API answer that was not a success.
|
|
type APIError struct {
|
|
Status int
|
|
Body string
|
|
}
|
|
|
|
func (e *APIError) Error() string { return fmt.Sprintf("Keycloak API error %d: %s", e.Status, e.Body) }
|
|
|
|
// request calls the admin API under /admin/realms, decoding the answer into out when there is one.
|
|
func (c *Client) request(ctx context.Context, method, path string, in, out any) error {
|
|
var payload []byte
|
|
if in != nil {
|
|
var err error
|
|
if payload, err = json.Marshal(in); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
do := func(token string) (*http.Response, error) {
|
|
var body io.Reader
|
|
if payload != nil {
|
|
body = bytes.NewReader(payload)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+"/admin/realms"+path, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
return c.http.Do(req)
|
|
}
|
|
token, err := c.getToken(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := do(token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// A cached token that expired against the server's clock reads as 401; drop it and retry once.
|
|
if res.StatusCode == http.StatusUnauthorized {
|
|
res.Body.Close()
|
|
c.dropToken()
|
|
if token, err = c.getToken(ctx); err != nil {
|
|
return err
|
|
}
|
|
if res, err = do(token); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
defer res.Body.Close()
|
|
raw, _ := io.ReadAll(io.LimitReader(res.Body, 16<<20))
|
|
if res.StatusCode < 200 || res.StatusCode > 299 {
|
|
return &APIError{Status: res.StatusCode, Body: strings.TrimSpace(string(raw))}
|
|
}
|
|
// 201/204 carry no body — the admin API's create/update/delete answer with an empty response.
|
|
if out == nil || res.StatusCode == http.StatusCreated || res.StatusCode == http.StatusNoContent || len(raw) == 0 {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(raw, out)
|
|
}
|
|
|
|
func esc(s string) string { return url.PathEscape(s) }
|
|
|
|
// Realms
|
|
|
|
func (c *Client) ListRealms(ctx context.Context) ([]Rep, error) {
|
|
var out []Rep
|
|
return out, c.request(ctx, http.MethodGet, "/", nil, &out)
|
|
}
|
|
|
|
// Users
|
|
|
|
func (c *Client) ListUsers(ctx context.Context, realm, search string, max int) ([]Rep, error) {
|
|
q := url.Values{}
|
|
if search != "" {
|
|
q.Set("search", search)
|
|
}
|
|
if max > 0 {
|
|
q.Set("max", fmt.Sprint(max))
|
|
}
|
|
path := "/" + esc(realm) + "/users"
|
|
if len(q) > 0 {
|
|
path += "?" + q.Encode()
|
|
}
|
|
var out []Rep
|
|
return out, c.request(ctx, http.MethodGet, path, nil, &out)
|
|
}
|
|
|
|
func (c *Client) CreateUser(ctx context.Context, realm string, rep Rep) error {
|
|
body := Rep{"enabled": true}
|
|
for k, v := range rep {
|
|
body[k] = v
|
|
}
|
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/users", body, nil)
|
|
}
|
|
|
|
func (c *Client) UpdateUser(ctx context.Context, realm, id string, rep Rep) error {
|
|
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/users/"+esc(id), rep, nil)
|
|
}
|
|
|
|
func (c *Client) DeleteUser(ctx context.Context, realm, id string) error {
|
|
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/users/"+esc(id), nil, nil)
|
|
}
|
|
|
|
func (c *Client) ResetPassword(ctx context.Context, realm, id, password string, temporary bool) error {
|
|
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/users/"+esc(id)+"/reset-password",
|
|
Rep{"type": "password", "value": password, "temporary": temporary}, nil)
|
|
}
|
|
|
|
func (c *Client) UserSessions(ctx context.Context, realm, id string) ([]Rep, error) {
|
|
var out []Rep
|
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/sessions", nil, &out)
|
|
}
|
|
|
|
// Clients
|
|
|
|
func (c *Client) ListClients(ctx context.Context, realm string) ([]Rep, error) {
|
|
var out []Rep
|
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients", nil, &out)
|
|
}
|
|
|
|
func (c *Client) CreateClient(ctx context.Context, realm string, rep Rep) error {
|
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/clients", rep, nil)
|
|
}
|
|
|
|
// FindClient is the one client with exactly this clientId, or nil. The admin API's `clientId`
|
|
// filter is an exact match unless `search=true` is asked for.
|
|
func (c *Client) FindClient(ctx context.Context, realm, clientID string) (Rep, error) {
|
|
var found []Rep
|
|
if err := c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients?clientId="+url.QueryEscape(clientID), nil, &found); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, f := range found {
|
|
if f["clientId"] == clientID {
|
|
return f, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// resolveClientID is a client's internal id from the clientId a caller knows.
|
|
func (c *Client) resolveClientID(ctx context.Context, realm, clientID string) (string, error) {
|
|
clients, err := c.ListClients(ctx, realm)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, cl := range clients {
|
|
if cl["clientId"] == clientID {
|
|
id, _ := cl["id"].(string)
|
|
return id, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("Client '%s' not found in realm '%s'", clientID, realm)
|
|
}
|
|
|
|
func (c *Client) UpdateClient(ctx context.Context, realm, id string, rep Rep) error {
|
|
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/clients/"+esc(id), rep, nil)
|
|
}
|
|
|
|
func (c *Client) DeleteClientByID(ctx context.Context, realm, id string) error {
|
|
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/clients/"+esc(id), nil, nil)
|
|
}
|
|
|
|
func (c *Client) ClientSecretByID(ctx context.Context, realm, id string) (string, error) {
|
|
var out struct {
|
|
Value string `json:"value"`
|
|
}
|
|
err := c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients/"+esc(id)+"/client-secret", nil, &out)
|
|
return out.Value, err
|
|
}
|
|
|
|
func (c *Client) ListClientMappers(ctx context.Context, realm, id string) ([]Rep, error) {
|
|
var out []Rep
|
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients/"+esc(id)+"/protocol-mappers/models", nil, &out)
|
|
}
|
|
|
|
func (c *Client) AddClientMapper(ctx context.Context, realm, id string, mapper Rep) error {
|
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/clients/"+esc(id)+"/protocol-mappers/models", mapper, nil)
|
|
}
|
|
|
|
func (c *Client) UpdateClientMapper(ctx context.Context, realm, id string, mapper Rep) error {
|
|
mid, _ := mapper["id"].(string)
|
|
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/clients/"+esc(id)+"/protocol-mappers/models/"+esc(mid), mapper, nil)
|
|
}
|
|
|
|
func (c *Client) DeleteClient(ctx context.Context, realm, clientID string) error {
|
|
id, err := c.resolveClientID(ctx, realm, clientID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.DeleteClientByID(ctx, realm, id)
|
|
}
|
|
|
|
func (c *Client) GetClientSecret(ctx context.Context, realm, clientID string) (string, error) {
|
|
id, err := c.resolveClientID(ctx, realm, clientID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return c.ClientSecretByID(ctx, realm, id)
|
|
}
|
|
|
|
func (c *Client) AddProtocolMapper(ctx context.Context, realm, clientID string, mapper Rep) error {
|
|
id, err := c.resolveClientID(ctx, realm, clientID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := Rep{"protocol": "openid-connect"}
|
|
for k, v := range mapper {
|
|
body[k] = v
|
|
}
|
|
return c.AddClientMapper(ctx, realm, id, body)
|
|
}
|
|
|
|
// Roles
|
|
|
|
func (c *Client) ListRealmRoles(ctx context.Context, realm string) ([]Rep, error) {
|
|
var out []Rep
|
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/roles", nil, &out)
|
|
}
|
|
|
|
func (c *Client) CreateRealmRole(ctx context.Context, realm string, rep Rep) error {
|
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/roles", rep, nil)
|
|
}
|
|
|
|
func (c *Client) UserRealmRoles(ctx context.Context, realm, id string) ([]Rep, error) {
|
|
var out []Rep
|
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm", nil, &out)
|
|
}
|
|
|
|
func (c *Client) AvailableRealmRoles(ctx context.Context, realm, id string) ([]Rep, error) {
|
|
var out []Rep
|
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm/available", nil, &out)
|
|
}
|
|
|
|
func (c *Client) AssignRealmRoles(ctx context.Context, realm, id string, roles []Rep) error {
|
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm", roles, nil)
|
|
}
|
|
|
|
func (c *Client) RemoveRealmRoles(ctx context.Context, realm, id string, roles []Rep) error {
|
|
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm", roles, nil)
|
|
}
|
|
|
|
// Groups
|
|
|
|
func (c *Client) ListGroups(ctx context.Context, realm string) ([]Rep, error) {
|
|
var out []Rep
|
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/groups", nil, &out)
|
|
}
|
|
|
|
func (c *Client) CreateGroup(ctx context.Context, realm, name string) error {
|
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/groups", Rep{"name": name}, nil)
|
|
}
|
|
|
|
func (c *Client) UserGroups(ctx context.Context, realm, id string) ([]Rep, error) {
|
|
var out []Rep
|
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/groups", nil, &out)
|
|
}
|
|
|
|
func (c *Client) AddUserToGroup(ctx context.Context, realm, id, group string) error {
|
|
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/users/"+esc(id)+"/groups/"+esc(group), nil, nil)
|
|
}
|
|
|
|
func (c *Client) RemoveUserFromGroup(ctx context.Context, realm, id, group string) error {
|
|
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/users/"+esc(id)+"/groups/"+esc(group), nil, nil)
|
|
}
|