diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f79bfbb6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# AGENTS.md + +Guidance for coding agents working in `mosip/keycloak`. + +## Keycloak init mappers + +`keycloak-init/keycloak_init.py` creates protocol mappers. Each client `mappers` entry is passed to `create_mapper`, which calls `mapper_payload`. Add a new audience through that list. Do not add another mapper implementation. + +- Leave `protocol_mapper` unset, or set `oidc-usermodel-attribute-mapper`, for a user-attribute mapper. Those entries use `mapper_name`, `mapper_user_attribute`, and `token_claim_name`. +- Set `protocol_mapper: oidc-audience-mapper` to add one value to the access token `aud`. Set exactly one of `included_client_audience` (a client id in the same realm) or `included_custom_audience` (a literal audience). The script sets `access.token.claim` to `true`. An entry with both fields is rejected, because Keycloak keeps `included.client.audience` and ignores `included.custom.audience`. +- Any other `protocol_mapper` is rejected. `validate_configured_mappers` checks every entry before a realm or client is created or updated. One bad entry stops the run before Keycloak is changed. +- A re-run skips a mapper name that already exists (HTTP 409). The stored mapper is left as it is. To change an audience, delete that mapper in Keycloak and run init again. Any other Keycloak error is raised. +- Chart mapper lists live in `helm/keycloak-init/values.yaml`. Add an audience entry only on the client that needs it. Existing user-attribute mappers stay unchanged. diff --git a/helm/keycloak-init/values.yaml b/helm/keycloak-init/values.yaml index b22075e2..a4e7375d 100644 --- a/helm/keycloak-init/values.yaml +++ b/helm/keycloak-init/values.yaml @@ -262,6 +262,14 @@ keycloak: "Include In Token Scope": on attributes: {display.on.consent.screen: "false", include.in.token.scope: "true"} clients: + # A mapper without protocol_mapper stays oidc-usermodel-attribute-mapper + # (mapper_name, mapper_user_attribute, token_claim_name). + # Add an audience on the client that needs it: + # - mapper_name: esignet-audience + # protocol_mapper: oidc-audience-mapper + # included_client_audience: target-client-id + # Use included_custom_audience instead when the audience is not a client id. + # Set only one of those fields. - name: mosip-abis-client mappers: [] saroles: [] diff --git a/keycloak-init/README.md b/keycloak-init/README.md index 8b2dc8b3..ed976ed7 100644 --- a/keycloak-init/README.md +++ b/keycloak-init/README.md @@ -45,6 +45,34 @@ Keycloak will feature the Mosip theme as the default theme. To apply a customize Note: We are adding theme as a part of keycloak_init +# Protocol mappers + +A client `mappers` entry creates a protocol mapper. `protocol_mapper` selects the type. When it is omitted, or set to `oidc-usermodel-attribute-mapper`, the mapper stays a user-attribute mapper and uses `mapper_user_attribute` plus `token_claim_name`. + +An `oidc-audience-mapper` entry adds one audience to the access token `aud` claim. Set either `included_client_audience` (another client's client id in the same realm) or `included_custom_audience` (a literal audience), not both. Keycloak keeps `included.client.audience` and ignores `included.custom.audience` when both are present, so the script rejects that combination. The script sets `access.token.claim=true`. + +`protocol_mapper` must be omitted, `oidc-usermodel-attribute-mapper`, or `oidc-audience-mapper`. Any other value stops init before Keycloak is changed. + +Every mapper entry is checked before a realm or client is created or updated. One bad entry stops that run without applying it. + +```yaml +clients: + - name: mosip-pms-client + mappers: + - mapper_name: partnerType + mapper_user_attribute: partnerType + token_claim_name: partnerType + - mapper_name: esignet-audience + protocol_mapper: oidc-audience-mapper + included_client_audience: mosip-esignet-client + - mapper_name: custom-audience + protocol_mapper: oidc-audience-mapper + included_custom_audience: https://api.example.org + saroles: [] +``` + +A re-run does not change a mapper whose name already exists. Keycloak returns 409 and the script skips it. To change `included_client_audience` or `included_custom_audience` on a mapper that is already in the realm, delete that mapper in Keycloak and run init again. + # Keycloak Roles Assignment to Client This "assign_client_roles" method within the script is used to assign roles to clients within a specified realm in Keycloak using keycloak admin API. This method handles retrieving the roles from the values.yaml file, fetching the client ID, and assigning the specified roles within the values.yaml file to the client directly. diff --git a/keycloak-init/keycloak_init.py b/keycloak-init/keycloak_init.py index 234df2ec..93223f8f 100755 --- a/keycloak-init/keycloak_init.py +++ b/keycloak-init/keycloak_init.py @@ -12,6 +12,70 @@ from keycloak.exceptions import raise_error_from_response, KeycloakError, KeycloakGetError from keycloak.urls_patterns import URL_ADMIN_USER_REALM_ROLES +USER_ATTRIBUTE_MAPPER = "oidc-usermodel-attribute-mapper" +AUDIENCE_MAPPER = "oidc-audience-mapper" + +def mapper_payload(mapper): + """Build the Keycloak protocol-mapper body for one realm YAML mapper entry. + + Entries without protocol_mapper stay user-attribute mappers. An + oidc-audience-mapper entry sets exactly one of included_client_audience + or included_custom_audience on the access token aud. + """ + protocol_mapper = mapper.get("protocol_mapper") or USER_ATTRIBUTE_MAPPER + if protocol_mapper not in (USER_ATTRIBUTE_MAPPER, AUDIENCE_MAPPER): + raise ValueError( + 'mapper "%s" has unknown protocol_mapper "%s"' + % (mapper.get("mapper_name"), protocol_mapper) + ) + if protocol_mapper == AUDIENCE_MAPPER: + client_audience = str(mapper.get("included_client_audience") or "").strip() + custom_audience = str(mapper.get("included_custom_audience") or "").strip() + if client_audience and custom_audience: + raise ValueError( + 'audience mapper "%s" must set only one of included_client_audience or included_custom_audience' + % mapper.get("mapper_name") + ) + config = {"access.token.claim": "true"} + if client_audience: + config["included.client.audience"] = client_audience + elif custom_audience: + config["included.custom.audience"] = custom_audience + else: + raise ValueError( + 'audience mapper "%s" needs included_client_audience or included_custom_audience' + % mapper.get("mapper_name") + ) + else: + config = { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "multivalued": "", + "aggregate.attrs": "", + "user.attribute": mapper["mapper_user_attribute"], + "claim.name": mapper["token_claim_name"], + "jsonType.label": "String", + } + return { + "protocol": "openid-connect", + "config": config, + "name": mapper["mapper_name"], + "protocolMapper": protocol_mapper, + } + +def validate_configured_mappers(values): + """Fail before any realm or client is created when a mapper entry is invalid.""" + for realm, body in values.items(): + if realm == "del_realms" or not isinstance(body, dict): + continue + for client in body.get("clients") or []: + if not isinstance(client, dict): + continue + for mapper in client.get("mappers") or []: + if mapper: + mapper_payload(mapper) + class KeycloakSession: def __init__(self, realm, server_url, user, pwd, ssl_verify): self.keycloak_admin = KeycloakAdmin(server_url=server_url, @@ -425,35 +489,20 @@ def create_client(self, realm, client, secret, sa_roles=None): def create_mapper(self, realm, client, mapper, skip_exists=False): self.keycloak_admin.realm_name = realm # work around because otherwise client was getting created in master - client_id = self.keycloak_admin.get_client_id(client) - mapper_url = 'admin/realms/'+realm+'/clients/'+client_id+'/protocol-mappers/models' - payload = { - "protocol":"openid-connect", - "config": { - "id.token.claim":"true", - "access.token.claim":"true", - "userinfo.token.claim":"true", - "multivalued":"", - "aggregate.attrs":"", - "user.attribute":mapper['mapper_user_attribute'], - "claim.name":mapper['token_claim_name'], - "jsonType.label":"String" - }, - "name":mapper['mapper_name'], - "protocolMapper":"oidc-usermodel-attribute-mapper" - } try: + client_id = self.keycloak_admin.get_client_id(client) + mapper_url = 'admin/realms/'+realm+'/clients/'+client_id+'/protocol-mappers/models' + payload = mapper_payload(mapper) print('\t\tCreating Mapper %s' % mapper['mapper_name']) data_raw = self.keycloak_admin.connection.raw_post(mapper_url, data=json.dumps(payload)) return raise_error_from_response(data_raw, KeycloakGetError, skip_exists=skip_exists) - except KeycloakError as e: if e.response_code == 409: print('\t\tMapper %s Exists already exists; SKIPPING;' % mapper['mapper_name']) - - except: - self.keycloak_admin.realm_name = 'master' # restore + return raise + finally: + self.keycloak_admin.realm_name = 'master' # restore def create_user(self, realm, uname, email, fname, lname, password, temp_flag, attributes={}): self.keycloak_admin.realm_name = realm @@ -656,6 +705,7 @@ def main(): fp = open(input_yaml, 'rt') values = yaml.load(fp, Loader=yaml.FullLoader) + validate_configured_mappers(values) server_url = server_url # Full url to access api