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/", 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) }