Skip to content

Commit

Permalink
feat(docker-admin-ui): admin-ui license registration (#844)
Browse files Browse the repository at this point in the history
  • Loading branch information
iromli authored Mar 12, 2023
1 parent 9eb7ed4 commit 1b64602
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 45 deletions.
2 changes: 1 addition & 1 deletion docker-admin-ui/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ RUN apk update \
# TODO:
# - use NODE_ENV=production
# - download build package (not git clone)
ENV ADMIN_UI_VERSION=468f4e9555ce8846edf76e896a7c3ec6acbbe18f
ENV ADMIN_UI_VERSION=8e61c66208ae7e54a584dab10746afb5fd9c582e

RUN mkdir -p /opt/flex

Expand Down
2 changes: 2 additions & 0 deletions docker-admin-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ The following environment variables are supported by the container:
- `CN_GOOGLE_SPANNER_DATABASE_ID`: Google Spanner database ID.
- `GLUU_ADMIN_UI_PLUGINS`: Comma-separated additional plugins to be enabled (default to empty string). See [Adding plugins](#adding-plugins) for details.
- `GLUU_ADMIN_UI_AUTH_METHOD`: Authentication method for admin-ui (one of `basic` or `casa`; default to `basic`). Note, changing the value require restart to jans-config-api.
- `GLUU_SCAN_AUTH_URL`: Base URL to auth server to register license client (i.e. `https://account-dev.gluu.cloud`; default to empty string). If omitted or use default value, the URL will be pre-populated from `iss` claim included in SSA file.
- `GLUU_SCAN_API_URL`: Base URL to SCAN API host (i.e. `https://cloud-dev.gluu.cloud`; default to empty string). If omitted or use default value, the URL will be pre-populated and modified based on `iss` claim included in SSA file.

### Hybrid mapping

Expand Down
1 change: 1 addition & 0 deletions docker-admin-ui/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
libcst<0.4
# pinned to py3-grpcio version to avoid failure on native extension build
grpcio==1.41.0
jwcrypto==1.4.2
git+https://github.com/JanssenProject/jans@bd3d59b28259982fc803b0dccdbeda07f328bf92#egg=jans-pycloudlib&subdirectory=jans-pycloudlib
62 changes: 47 additions & 15 deletions docker-admin-ui/scripts/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from jans.pycloudlib.persistence.utils import PersistenceMapper

from settings import LOGGING_CONFIG
from ssa import get_license_config

logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger("entrypoint")
Expand Down Expand Up @@ -48,12 +49,6 @@ def render_nginx_conf(manager):
def main():
manager = get_manager()

# if not os.path.isfile("/etc/certs/web_https.crt"):
# manager.secret.to_file("ssl_cert", "/etc/certs/web_https.crt")

# if not os.path.isfile("/etc/certs/web_https.key"):
# manager.secret.to_file("ssl_key", "/etc/certs/web_https.key")

render_env(manager)
render_nginx_conf(manager)

Expand Down Expand Up @@ -102,15 +97,14 @@ def get_token_server_ctx(self):
if not os.path.isfile(pw_file):
self.manager.secret.to_file("token_server_admin_ui_client_pw", pw_file)

ctx = {
return {
"token_server_admin_ui_client_id": os.environ.get("CN_TOKEN_SERVER_CLIENT_ID") or self.manager.config.get("token_server_admin_ui_client_id"),
"token_server_admin_ui_client_pw": read_from_file(pw_file),
"token_server_authz_url": f"https://{hostname}{authz_endpoint}",
"token_server_token_url": f"https://{hostname}{token_endpoint}",
"token_server_introspection_url": f"https://{hostname}{introspection_endpoint}",
"token_server_userinfo_url": f"https://{hostname}{userinfo_endpoint}",
}
return ctx

@cached_property
def ctx(self):
Expand Down Expand Up @@ -155,6 +149,8 @@ def ctx(self):

ctx.update(self.get_token_server_ctx())

ctx.update(get_license_config(self.manager))

# finalized contexts
return ctx

Expand All @@ -181,9 +177,16 @@ def save_config(self):
table_name = "jansAppConf"

entry = self.client.get(table_name, dn)
conf = entry.get("jansConfApp") or "{}"

should_update, merged_conf = resolve_conf_app(
json.loads(conf),
json.loads(conf_from_file),
)

if not entry["jansConfApp"]:
entry["jansConfApp"] = conf_from_file
if should_update:
logger.info("Updating admin-ui config app")
entry["jansConfApp"] = json.dumps(merged_conf)
entry["jansRevision"] += 1
self.client.update(table_name, dn, entry)

Expand All @@ -195,9 +198,16 @@ def save_config(self):
entry = req.json()["results"][0]

conf = entry.get("jansConfApp") or {}
if not conf:

should_update, merged_conf = resolve_conf_app(
conf,
json.loads(conf_from_file),
)

if should_update:
logger.info("Updating admin-ui config app")
rev = entry["jansRevision"] + 1
self.client.exec_query(f"UPDATE {bucket} USE KEYS '{dn}' SET jansConfApp={conf_from_file}, jansRevision={rev}")
self.client.exec_query(f"UPDATE {bucket} USE KEYS '{dn}' SET jansConfApp={json.dumps(merged_conf)}, jansRevision={rev}")

else:
entry = self.client.get(dn)
Expand All @@ -206,17 +216,39 @@ def save_config(self):
try:
conf = attrs.get("jansConfApp", [])[0]
except IndexError:
conf = ""
conf = "{}"

should_update, merged_conf = resolve_conf_app(
json.loads(conf),
json.loads(conf_from_file),
)

if not conf:
if should_update:
logger.info("Updating admin-ui config app")
self.client.modify(
dn,
{
"jansRevision": [(self.client.MODIFY_REPLACE, attrs["jansRevision"][0] + 1)],
"jansConfApp": [(self.client.MODIFY_REPLACE, conf_from_file)],
"jansConfApp": [(self.client.MODIFY_REPLACE, json.dumps(merged_conf))],
}
)


def resolve_conf_app(old_conf, new_conf):
should_update = False

# old_conf may still empty; replace with new_conf
if not old_conf:
return True, new_conf

# licenseConfig is new property added after v1.0.9 release
if "licenseConfig" not in old_conf:
old_conf["licenseConfig"] = new_conf["licenseConfig"]
should_update = True

# finalized status and conf
return should_update, old_conf


if __name__ == "__main__":
main()
137 changes: 137 additions & 0 deletions docker-admin-ui/scripts/ssa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import json
import logging.config
import os
import tempfile
import uuid

import requests
from jwcrypto.jwt import JWT

from jans.pycloudlib.utils import exec_cmd
from jans.pycloudlib.utils import generate_base64_contents

from settings import LOGGING_CONFIG

logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger("entrypoint")


def register_license_client(ssa, reg_url):
data = {
"software_statement": ssa,
"client_name": "Gluu Flex License Client",
"response_types": ["token"],
"redirect_uris": ["http://localhost"],
}

logger.info(f"Registering license client at {reg_url}")

req = requests.post(
reg_url,
json=data,
# TODO: configurable verification
verify=False, # nosec: B501
)

if not req.ok:
# FIXME: remote URL is throwing 422 Unprocessable entity
raise RuntimeError(f"Failed to register client at {req.request.url}; reason={req.reason} status_code={req.status_code}")
return req.json()


def get_enc_keys():
logger.info("Generating public and private keys for license")

with tempfile.TemporaryDirectory() as tmpdir:
priv_fn = os.path.join(tmpdir, "private.pem")
privkey_fn = os.path.join(tmpdir, "private_key.pem")
pubkey_fn = os.path.join(tmpdir, "public_key.pem")

cmds = [
f"openssl genrsa -out {priv_fn} 2048",
f"openssl rsa -in {priv_fn} -pubout -outform PEM -out {pubkey_fn}",
f"openssl pkcs8 -topk8 -inform PEM -in {priv_fn} -out {privkey_fn} -nocrypt",
]

for cmd in cmds:
out, err, code = exec_cmd(cmd)

if code != 0:
err = err or out
raise RuntimeError("Unable to generate encode/decode keys for license; reason={err.decode()}")

with open(pubkey_fn) as f:
enc_pub_key = generate_base64_contents(f.read(), 0)

with open(privkey_fn) as f:
enc_priv_key = generate_base64_contents(f.read(), 0)
return enc_pub_key, enc_priv_key


def get_license_client_creds(manager):
# used mostly for testing on fresh deployment to re-use license client ID thus client registration will be skipped
# in production mode, omit or set empty string to force registering license client (if required)
client_id = os.environ.get("GLUU_LICENSE_CLIENT_ID", "")
if client_id:
logger.warning("Got license client ID from GLUU_LICENSE_CLIENT_ID env which is not suitable for production")
else:
client_id = manager.config.get("license_client_id")

# used mostly for testing on fresh deployment to re-use license client secret
client_secret = os.environ.get("GLUU_LICENSE_CLIENT_SECRET", "")
if client_secret:
logger.warning("Got license client secret from GLUU_LICENSE_CLIENT_SECRET env which is not suitable for production")
else:
client_secret = manager.secret.get("license_client_pw")
return client_id, client_secret


def get_license_config(manager):
# decode SSA from file
ssa_file = os.environ.get("GLUU_SSA_FILE", "/etc/jans/conf/ssa")

with open(ssa_file) as f:
ssa = f.read().strip()

jwt = JWT(jwt=ssa)
payload = json.loads(jwt.token.objects["payload"].decode())

auth_url = os.environ.get("GLUU_SCAN_AUTH_URL") or payload["iss"]
reg_url = f"{auth_url}/jans-auth/restv1/register"
scan_url = os.environ.get("GLUU_SCAN_API_URL") or auth_url.replace("account", "cloud")

# get license client credentials
client_id, client_secret = get_license_client_creds(manager)

if not client_id:
resp = register_license_client(ssa, reg_url)
client_id = resp["client_id"]
client_secret = resp["client_secret"]

# save client creds
manager.config.set("license_client_id", client_id)
manager.secret.set("license_client_pw", client_secret)

# hardware key (unique per-installation)
hw_key = manager.config.get("license_hardware_key")
if not hw_key:
hw_key = str(uuid.uuid4())
manager.config.set("license_hardware_key", hw_key)

enc_pub_key = manager.secret.get("license_enc_pub_key")
enc_priv_key = manager.secret.get("license_enc_priv_key")

if not (enc_pub_key or enc_priv_key):
enc_pub_key, enc_priv_key = get_enc_keys()
manager.secret.set("license_enc_pub_key", enc_pub_key)
manager.secret.set("license_enc_priv_key", enc_priv_key)

return {
"cred_enc_public_key": enc_pub_key,
"cred_enc_private_key": enc_priv_key,
"license_hardware_key": hw_key,
"oidc_client_id": client_id,
"oidc_client_secret": client_secret,
"scan_license_api_hostname": scan_url,
"scan_license_auth_server_hostname": auth_url,
}
8 changes: 8 additions & 0 deletions docker-admin-ui/scripts/wait.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ def main():
if auth_method not in ("basic", "casa"):
raise ValueError(f"Unsupported authentication method {auth_method}")

ssa_file = os.environ.get("GLUU_SSA_FILE", "/etc/jans/conf/ssa")
if not os.path.exists(ssa_file):
raise ValueError(
f"The required SSA file is not found (default to {ssa_file}); "
"please set the location via GLUU_SSA_FILE environment variable "
"if not using the default"
)

manager = get_manager()
deps = ["config", "secret"]
wait_for(manager, deps)
Expand Down
78 changes: 49 additions & 29 deletions docker-admin-ui/templates/auiConfiguration.json
Original file line number Diff line number Diff line change
@@ -1,33 +1,53 @@
{
"oidcConfig": {
"authServerClient": {
"opHost": "https://%(hostname)s/admin",
"clientId": "%(admin_ui_client_id)s",
"clientSecret": "%(admin_ui_client_encoded_pw)s",
"scopes": [
"openid",
"profile",
"user_name",
"email"
],
"acrValues": [
"%(admin_ui_auth_method)s"
],
"redirectUri": "https://%(hostname)s/admin",
"postLogoutUri": "https://%(hostname)s/admin",
"frontchannelLogoutUri": "https://%(hostname)s/admin/logout"
"oidcConfig": {
"authServerClient": {
"opHost": "https://%(hostname)s/admin",
"clientId": "%(admin_ui_client_id)s",
"clientSecret": "%(admin_ui_client_encoded_pw)s",
"scopes": [
"openid",
"profile",
"user_name",
"email"
],
"acrValues": [
"%(admin_ui_auth_method)s"
],
"redirectUri": "https://%(hostname)s/admin",
"postLogoutUri": "https://%(hostname)s/admin",
"frontchannelLogoutUri": "https://%(hostname)s/admin/logout"
},
"tokenServerClient": {
"opHost": "https://%(hostname)s/admin",
"clientId": "%(token_server_admin_ui_client_id)s",
"clientSecret": "%(token_server_admin_ui_client_encoded_pw)s",
"tokenEndpoint": "%(token_server_token_url)s",
"scopes": [
"openid",
"profile",
"user_name",
"email"
]
}
},
"tokenServerClient": {
"opHost": "https://%(hostname)s/admin",
"clientId": "%(token_server_admin_ui_client_id)s",
"clientSecret": "%(token_server_admin_ui_client_encoded_pw)s",
"tokenEndpoint": "%(token_server_token_url)s",
"scopes": [
"openid",
"profile",
"user_name",
"email"
]
"licenseConfig": {
"scanLicenseApiHostname": "%(scan_license_api_hostname)s",
"scanLicenseAuthServerHostname": "%(scan_license_auth_server_hostname)s",
"licenseHardwareKey": "%(license_hardware_key)s",
"credentialsEncryptionKey": {
"alg": "RS256",
"publicKey": "%(cred_enc_public_key)s",
"privateKey": "%(cred_enc_private_key)s"
},
"oidcClient": {
"clientId": "%(oidc_client_id)s",
"clientSecret": "%(oidc_client_secret)s",
"tokenEndpoint": null,
"redirectUri": null,
"postLogoutUri": null,
"frontchannelLogoutUri": null,
"scopes": null,
"acrValues": null
}
}
}
}

0 comments on commit 1b64602

Please sign in to comment.