Files
mesh-catalog/modules/keycloak/cmd/keycloak-provider/oidc.go
T
jochen e68ef88333 Retire a consumer the mesh stops asking for, and delete only on a person's word (hq ADR 0230)
The hourly release of ADR 0229's brake still ended in the mesh acting alone on
a mistake. A consumer now stays active until the same unasked set holds for
five passes, waits for a person past three or half of those held, is disabled
and marked rather than withdrawn, comes back as it was when asked again, and is
deleted only through the provider's delete tool. The backend keeps the mark, so
a restart forgets nothing and finds what was withdrawn before.
2026-10-06 13:54:10 +02:00

382 lines
12 KiB
Go

package main
// What the `oidc-client` provision means in Keycloak: one confidential OpenID Connect client per
// consumer, in the realm this module serves, under the name and secret the mesh gave both ends.
// Ported from oidc.ts, behaviour for behaviour.
//
// **The client id and the secret are the mesh's, not Keycloak's (novox/hq ADR 0048).** The mesh
// derives the consumer's identity (`as`) and hands it to both ends, and mints the secret, which this
// sets as the client's secret. Keycloak generates neither.
//
// **Where the consumer's browser comes back to is the consumer's to say.** Its contribution carries
// `callback` (a path) and the mesh composes its endpoint's names into `name` (public) and
// `internal-name` (private network) exactly as it does for a route (novox/hq ADR 0056, 0138).
//
// **Only what the mesh made is touched.** A client this module creates carries the attribute
// `mesh.provisioned=true`. A client with the same id that lacks the mark is somebody else's: it is
// refused, never adopted, never updated, never deleted.
//
// **Retired is the client disabled and marked** (novox/hq ADR 0230). `enabled: false` — Keycloak then
// refuses the client's authorization and token requests, so nobody signs in through it — and the
// attributes `mesh.retired` (when) and `mesh.retired-why` (why). Its secret, redirects, mappers and
// every other setting are kept, so asked for again it is enabled as it was: Ensure sets `enabled` and
// removes the two attributes. Deleted — only through `cleanup delete` — the client is removed.
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"regexp"
"sort"
"strings"
"time"
)
// Mark is the attribute marking a client as the mesh's own work.
const Mark = "mesh.provisioned"
// MarkRetired and MarkRetiredWhy mark a retired client: when and why (novox/hq ADR 0230).
const (
MarkRetired = "mesh.retired"
MarkRetiredWhy = "mesh.retired-why"
)
// RolesMapper is the mapper every mesh client carries: realm roles as a flat `roles` claim in the id
// token, the access token and userinfo — what a consumer maps its own roles from.
func RolesMapper() Rep {
return Rep{
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": map[string]any{
"claim.name": "roles",
"jsonType.label": "String",
"multivalued": "true",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
},
}
}
var realmPath = regexp.MustCompile(`/realms/([^/]+)/?$`)
// RealmOf is the realm named by an issuer URL — `https://id.example/realms/Novox` is realm `Novox`.
// The issuer is the one value an assignment sets, so the realm is read out of it rather than set a
// second time where the two could disagree.
func RealmOf(issuer string) (string, error) {
u, err := url.Parse(issuer)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("the issuer %q is not a URL", issuer)
}
m := realmPath.FindStringSubmatch(u.EscapedPath())
if m == nil {
return "", fmt.Errorf("the issuer %q does not end in /realms/<realm>", issuer)
}
realm, err := url.PathUnescape(m[1])
if err != nil {
return "", err
}
return realm, nil
}
// RedirectsOf is the redirect URIs a consumer's contribution asks for: its callback under each name
// the mesh composed for its endpoint. Refused when there is nothing to register.
func RedirectsOf(values map[string]any) (root string, redirects []string, err error) {
callback, _ := values["callback"].(string)
if !strings.HasPrefix(callback, "/") {
raw, _ := json.Marshal(values["callback"])
return "", nil, fmt.Errorf("contributes no callback path (`callback`, starting with \"/\"): %s", raw)
}
var names []string
for _, key := range []string{"name", "internal-name"} {
n, _ := values[key].(string)
n = strings.TrimSpace(n)
if n != "" && !contains(names, n) {
names = append(names, n)
}
}
if len(names) == 0 {
return "", nil, errors.New("has no name the mesh composed (`name` / `internal-name`) — contribute a `label` and the `endpoint` it is reached on")
}
for _, n := range names {
redirects = append(redirects, "https://"+n+callback)
}
return "https://" + names[0], redirects, nil
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// wanted is the fields the mesh owns on a client it made. Everything else is left as found.
func wanted(p Provision) (Rep, []string, error) {
root, redirects, err := RedirectsOf(p.Values)
if err != nil {
return nil, nil, err
}
whose := "a consumer"
if p.Consumer != "" {
whose = "a module on " + p.Consumer
}
uris := make([]any, len(redirects))
for i, r := range redirects {
uris[i] = r
}
return Rep{
"clientId": p.As,
"name": p.As,
"description": "made by the mesh for " + whose + " — do not edit; it is reset",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"clientAuthenticatorType": "client-secret",
"secret": p.Password,
"rootUrl": root,
"baseUrl": root,
"redirectUris": uris,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
}, redirects, nil
}
func stringList(v any) []string {
list, _ := v.([]any)
out := make([]string, 0, len(list))
for _, x := range list {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
func sameSet(a, b []string) bool {
x, y := append([]string(nil), a...), append([]string(nil), b...)
sort.Strings(x)
sort.Strings(y)
if len(x) != len(y) {
return false
}
for i := range x {
if x[i] != y[i] {
return false
}
}
return true
}
func attributes(c Rep) map[string]any {
if a, ok := c["attributes"].(map[string]any); ok {
return a
}
return map[string]any{}
}
func marked(c Rep) bool { return attributes(c)[Mark] == "true" }
// OidcClients is the provision's meaning in one realm.
type OidcClients struct {
KC *Client
Realm string
}
// Ensure creates the consumer's client, or brings the mesh's existing one back to what the grant
// says. Answers "created" or "updated". Idempotent.
func (o OidcClients) Ensure(ctx context.Context, p Provision) (string, error) {
want, _, err := wanted(p)
if err != nil {
return "", err
}
found, err := o.KC.FindClient(ctx, o.Realm, p.As)
if err != nil {
return "", err
}
if found != nil && !marked(found) {
return "", fmt.Errorf("realm %s already has a client %s the mesh did not make — left alone; "+
"delete or rename it if the mesh should own that id", o.Realm, p.As)
}
if found == nil {
want["attributes"] = map[string]any{Mark: "true"}
want["protocolMappers"] = []any{RolesMapper()}
if err := o.KC.CreateClient(ctx, o.Realm, want); err != nil {
return "", err
}
return "created", nil
}
// Overlay what the mesh owns on what is there, so a field Keycloak added or an operator set on a
// field the mesh does not own survives the update.
merged := Rep{}
for k, v := range found {
merged[k] = v
}
for k, v := range want {
merged[k] = v
}
attrs := map[string]any{}
for k, v := range attributes(found) {
attrs[k] = v
}
attrs[Mark] = "true"
// Asked for again: no longer marked to delete (novox/hq ADR 0230).
delete(attrs, MarkRetired)
delete(attrs, MarkRetiredWhy)
merged["attributes"] = attrs
id, _ := found["id"].(string)
if err := o.KC.UpdateClient(ctx, o.Realm, id, merged); err != nil {
return "", err
}
return "updated", o.ensureMapper(ctx, id)
}
func (o OidcClients) ensureMapper(ctx context.Context, id string) error {
want := RolesMapper()
mappers, err := o.KC.ListClientMappers(ctx, o.Realm, id)
if err != nil {
return err
}
var have Rep
for _, m := range mappers {
if m["name"] == want["name"] {
have = m
break
}
}
if have == nil {
return o.KC.AddClientMapper(ctx, o.Realm, id, want)
}
drifted := have["protocolMapper"] != want["protocolMapper"]
hc, _ := have["config"].(map[string]any)
for k, v := range want["config"].(map[string]any) {
if hc[k] != v {
drifted = true
}
}
if drifted {
want["id"] = have["id"]
return o.KC.UpdateClientMapper(ctx, o.Realm, id, want)
}
return nil
}
// Holds says whether Keycloak still holds this consumer's client exactly as the grant says: present,
// the mesh's, enabled, confidential, with the mesh's secret and the redirects asked for. Reads only.
func (o OidcClients) Holds(ctx context.Context, p Provision) (bool, error) {
_, redirects, err := wanted(p)
if err != nil {
return false, err
}
found, err := o.KC.FindClient(ctx, o.Realm, p.As)
if err != nil {
return false, err
}
if found == nil || !marked(found) || found["enabled"] == false || found["publicClient"] == true {
return false, nil
}
if !sameSet(stringList(found["redirectUris"]), redirects) {
return false, nil
}
id, _ := found["id"].(string)
mappers, err := o.KC.ListClientMappers(ctx, o.Realm, id)
if err != nil {
return false, err
}
hasMapper := false
for _, m := range mappers {
if m["name"] == RolesMapper()["name"] {
hasMapper = true
}
}
if !hasMapper {
return false, nil
}
secret, err := o.KC.ClientSecretByID(ctx, o.Realm, id)
if err != nil {
return false, err
}
return secret == p.Password, nil
}
// Retire disables a consumer's client — only one the mesh made — and marks it with when and why.
// Answers what happened: "retired", "absent" or "not ours".
func (o OidcClients) Retire(ctx context.Context, as, why string, at time.Time) (string, error) {
found, err := o.KC.FindClient(ctx, o.Realm, as)
if err != nil {
return "", err
}
if found == nil {
return "absent", nil
}
if !marked(found) {
return "not ours", nil
}
merged := Rep{}
for k, v := range found {
merged[k] = v
}
attrs := map[string]any{}
for k, v := range attributes(found) {
attrs[k] = v
}
attrs[MarkRetired] = at.UTC().Format(time.RFC3339)
attrs[MarkRetiredWhy] = why
merged["attributes"] = attrs
merged["enabled"] = false
id, _ := found["id"].(string)
return "retired", o.KC.UpdateClient(ctx, o.Realm, id, merged)
}
// Inventory is every client in the realm the mesh made: enabled ones active, disabled ones retired —
// with when and why from the mark, or none for one disabled before the mark existed.
func (o OidcClients) Inventory(ctx context.Context) (Inventory, error) {
inv := Inventory{Active: []string{}, Retired: []Retired{}}
clients, err := o.KC.ListClients(ctx, o.Realm)
if err != nil {
return inv, err
}
for _, c := range clients {
if !marked(c) {
continue
}
id, _ := c["clientId"].(string)
a := attributes(c)
when, _ := a[MarkRetired].(string)
if c["enabled"] != false && when == "" {
inv.Active = append(inv.Active, id)
continue
}
at, _ := time.Parse(time.RFC3339, when)
why, _ := a[MarkRetiredWhy].(string)
inv.Retired = append(inv.Retired, Retired{Consumer: id, RetiredAt: at, Why: why, SizeBytes: -1, Kind: KindConsumer})
}
return inv, nil
}
// Delete removes a retired client — only one the mesh made, and only while it is disabled.
func (o OidcClients) Delete(ctx context.Context, as string) error {
found, err := o.KC.FindClient(ctx, o.Realm, as)
if err != nil {
return err
}
if found == nil {
return nil
}
if !marked(found) {
return fmt.Errorf("realm %s's client %s was not made by the mesh — left alone", o.Realm, as)
}
if found["enabled"] != false {
return fmt.Errorf("client %s is enabled — it is active, not retired, and is not deleted", as)
}
id, _ := found["id"].(string)
return o.KC.DeleteClientByID(ctx, o.Realm, id)
}