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.
308 lines
9.0 KiB
Go
308 lines
9.0 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.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Mark is the attribute marking a client as the mesh's own work.
|
|
const Mark = "mesh.provisioned"
|
|
|
|
// 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"
|
|
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
|
|
}
|
|
|
|
// Remove withdraws a consumer's client — only one the mesh made. Answers what happened.
|
|
func (o OidcClients) Remove(ctx context.Context, as string) (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
|
|
}
|
|
id, _ := found["id"].(string)
|
|
return "removed", o.KC.DeleteClientByID(ctx, o.Realm, id)
|
|
}
|