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) }