App Sphere Examples

 

This page contains example interceptors that can be registered with Cluster Manager to handle App Sphere specific pointcuts.

Example: Starter Server interceptor for all AppSphere Pointcuts

 

The following example shows an interceptor that can be used as a starter App Sphere interceptor, implementing a hook method for each available pointcut.

/*-
 * #%L
 * Smile CDR - CDR
 * %%
 * Copyright (C) 2016 - 2026 Smile CDR, Inc.
 * %%
 * All rights reserved.
 * #L%
 */

package com.smilecdr.demo.appgallery;

import ca.cdr.api.fhir.interceptor.CdrHook;
import ca.cdr.api.fhir.interceptor.CdrPointcut;
import ca.cdr.api.model.json.appgallery.common.AGApplicationJson;
import ca.cdr.api.model.json.appgallery.common.AGScopeJson;
import ca.cdr.api.model.json.appgallery.console.AGConsoleJson;
import ca.cdr.api.model.json.appgallery.portal.AGClientSecretJson;
import ca.cdr.api.model.json.appgallery.portal.AGPortalJson;
import ca.uhn.fhir.interceptor.api.Interceptor;
import ca.uhn.fhir.util.StopWatch;
import jakarta.annotation.Nonnull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;

@SuppressWarnings({"unused"})
@Interceptor
public class AGInterceptorTemplate {
	private static final Logger ourLog = LoggerFactory.getLogger(AGInterceptorTemplate.class);

	@CdrHook(CdrPointcut.AG_APPLICATION_STATUS_UPDATING)
	public void statusUpdating(@Nonnull AGConsoleJson theAGConsoleJson) {
		ourLog.info("Interceptor AG_APPLICATION_STATUS_UPDATING - started");
		StopWatch stopWatch = new StopWatch();
		try {
			// theAGConsoleJson.oauth.secretInfo describes the current client secret but never carries the plaintext
			// - to receive that, hook AG_OAUTH2_CLIENT_SECRET_REGENERATED below.
			final String clientId = theAGConsoleJson.oauth.clientId;
			// your implementation goes here
		} finally {
			ourLog.info("Interceptor AG_APPLICATION_STATUS_UPDATING - ended, execution took {}", stopWatch);
		}
	}

	@CdrHook(CdrPointcut.AG_APPLICATION_STATUS_UPDATED)
	public void statusUpdated(@Nonnull AGConsoleJson theAGConsoleJson) {
		ourLog.info("Interceptor AG_APPLICATION_STATUS_UPDATED - started");
		StopWatch stopWatch = new StopWatch();
		try {
			// your implementation goes here
		} finally {
			ourLog.info("Interceptor AG_APPLICATION_STATUS_UPDATED - ended, execution took {}", stopWatch);
		}
	}

	@CdrHook(CdrPointcut.AG_OAUTH2_CLIENT_SECRET_REGENERATED)
	public void clientSecretRegenerated(
			@Nonnull AGClientSecretJson theClientSecret, @Nonnull AGPortalJson theApplication) {
		ourLog.info("Interceptor AG_OAUTH2_CLIENT_SECRET_REGENERATED - started");
		StopWatch stopWatch = new StopWatch();
		try {
			// theClientSecret.secret is the plaintext, delivered once - relay it here and never log it. Throwing
			// does not undo the rotation, so handle failures here.
			final String clientId = theClientSecret.clientId;
			final String clientSecret = theClientSecret.secret;
			// theApplication is the application the secret belongs to. Identify the new credential from
			// theClientSecret, not from theApplication.oauth.secretInfo.
			final List<AGScopeJson> scopes = theApplication.scopes;
			// your implementation goes here
		} finally {
			ourLog.info("Interceptor AG_OAUTH2_CLIENT_SECRET_REGENERATED - ended, execution took {}", stopWatch);
		}
	}

	@CdrHook(CdrPointcut.AG_APPLICATION_REGISTER)
	public void applicationRegister(@Nonnull AGApplicationJson theAGApplicationJson) {
		ourLog.info("Interceptor AG_APPLICATION_REGISTER - started");
		StopWatch stopWatch = new StopWatch();
		try {
			// your implementation goes here
		} finally {
			ourLog.info("Interceptor AG_APPLICATION_REGISTER - ended, execution took {}", stopWatch);
		}
	}

	@CdrHook(CdrPointcut.AG_APPLICATION_RE_REGISTER)
	public void applicationReRegister(@Nonnull AGApplicationJson theAGApplicationJson) {
		ourLog.info("Interceptor AG_APPLICATION_RE_REGISTER - started");
		StopWatch stopWatch = new StopWatch();
		try {
			// your implementation goes here
		} finally {
			ourLog.info("Interceptor AG_APPLICATION_RE_REGISTER - ended, execution took {}", stopWatch);
		}
	}
}

OIDC Client Secrets and Interceptors

 

Smile CDR keeps an application's OIDC client secret only as a one-way hash. The plaintext exists in memory for the duration of the request that generates it, and the only pointcut that carries it is AG_OAUTH2_CLIENT_SECRET_REGENERATED, described in Propagating a Regenerated Client Secret below.

The AGConsoleJson passed to the AG_APPLICATION_STATUS_UPDATING and AG_APPLICATION_STATUS_UPDATED pointcuts carries no plaintext secret. Its secretInfo object describes the application's current secret through its description, activation and expiration fields.

AG_OAUTH2_CLIENT_SECRET_REGENERATED also carries the application, as an AGPortalJson, and it carries no plaintext secret either. secretInfo never carries a plaintext on any payload; only AGClientSecretJson.secret does.

The AGSecretJson.secret field and the Cluster Manager property ag.interceptors.expose_oidc_client_secret, which together exposed the plaintext on that payload, have been removed. An interceptor that reads AGConsoleJson.oauth.secretInfo.secret no longer compiles; forward the secret from the AG_OAUTH2_CLIENT_SECRET_REGENERATED pointcut instead.

Propagating a Regenerated Client Secret

 

An appSphere developer generates the client secret for a 'Live' application on demand — see Generating a Client Secret. Each time they do, the AG_OAUTH2_CLIENT_SECRET_REGENERATED pointcut fires with the new credential, so an interceptor can relay it to an external Identity Provider and keep that provider in step with Smile CDR.

The hook receives two objects: the credential, and the application it belongs to. A rotation changes no application status, so no other pointcut fires at that moment to supply that context.

AGClientSecretJson carries the credential:

FieldDescription
clientIdThe OIDC client identifier of the application whose secret was generated.
secretThe plaintext client secret, delivered once. Smile CDR retains only a hash of this value.
warningSet by Smile CDR to INTERCEPTOR_NOTIFICATION_FAILED when a hook on this pointcut threw, otherwise null.

AGPortalJson carries the application, in the same shape the Developer Portal receives from GET /ag-portal/{node_id}/{module_id}/apps/{app_id}. The members most often needed here:

FieldDescription
identifier.pidThe application's identifier, the {app_id} whose secret was generated.
oauth.clientIdThe same OIDC client identifier as AGClientSecretJson.clientId.
oauth.enabledWhether the OIDC client is enabled.
oauth.secretInfoAn active client secret's description, activation and expiration. No plaintext.
applicationName, version, status and confidential-client configuration, including the JWKS fields.
scopesThe application's approved scopes.
profile, currentProfileThe owning developer's profile as recorded on the application, and as it stands now.
@CdrHook(CdrPointcut.AG_OAUTH2_CLIENT_SECRET_REGENERATED)
public void clientSecretRegenerated(
		@Nonnull AGClientSecretJson theClientSecret, @Nonnull AGPortalJson theApplication) {
	final String clientId = theClientSecret.clientId;
	final String clientSecret = theClientSecret.secret;
	final List<AGScopeJson> scopes = theApplication.scopes;
	// forward (clientId, clientSecret) and whatever context the Identity Provider needs
}

A hook method may declare either parameter, both, or neither, in any order — Smile CDR binds hook arguments by type, not by position. An interceptor written against the single-parameter signature keeps compiling and firing.

Do not use AGPortalJson.oauth.secretInfo to identify the secret you have just been handed. It reports an active secret, but not necessarily this one: during a rotation the application briefly holds two, and the outgoing one can be the one reported. AGClientSecretJson is the new credential.

Warning: secret is the plaintext OIDC client secret, and this pointcut is the only opportunity to relay it — Smile CDR cannot supply it a second time. Treat the value as sensitive and never include AGClientSecretJson in structured logs, audit events, or error messages without scrubbing the secret first. AGClientSecretJson.toString() masks the field, but JSON serialization writes the real value.

Timing and failure handling

The hook fires after the transaction that generated the secret has committed. The new secret is already persisted as a hash and the previous secret is already invalidated before any interceptor runs, so an exception thrown from this pointcut does not undo the rotation — the generated secret remains the application's credential and the response still carries the one-time plaintext.

The failure is not discarded: Smile CDR logs the exception at ERROR and sets warning on the response to INTERCEPTOR_NOTIFICATION_FAILED, so the developer can record the secret and escalate rather than assume the operation failed. The code reports only that a hook threw, not what the hook managed to do before throwing. The exception's message is not included in the response, where it would expose your internal detail to a third-party developer; only the log carries it. The first hook to throw ends the dispatch, so hooks ordered after it do not run at all, and the code does not distinguish that from a single hook failing.

This is how every appSphere pointcut that fires after a commit behaves, AG_APPLICATION_STATUS_UPDATED included. The pointcuts that fire before the commit — AG_APPLICATION_STATUS_UPDATING, AG_APPLICATION_REGISTER and AG_APPLICATION_RE_REGISTER — still propagate, so an interceptor can veto those operations by throwing.

Interceptors should nonetheless handle their own failures, including retries. A failed propagation leaves the external Identity Provider holding a secret Smile CDR no longer accepts, and the plaintext needed to repair that went to the developer — Smile CDR cannot supply it a second time.

The application context is built only when at least one interceptor is registered, so neither the extra query nor the icon encoding happens when nothing is listening. That build runs before the commit, unlike the hook itself: if it fails the whole operation is rolled back, and the developer receives an error rather than a warning.

Shared instance across UPDATING and UPDATED hooks

The same AGConsoleJson instance is passed to every AG_APPLICATION_STATUS_UPDATING interceptor and to the paired AG_APPLICATION_STATUS_UPDATED interceptor that fires after commit. Mutations made by one interceptor are visible to subsequent interceptors on the same chain. This allows state to be passed forward (for example, attaching a correlation identifier returned by an external authorization server in UPDATING and consuming it in UPDATED). Interceptor authors must be aware that order matters and that sibling interceptors on the same pointcut can observe each other's changes.

JWKS and JWKS URL are always available

The JWKS data used by clients that authenticate via private_key_jwt is always present on the AGConsoleJson and is not affected by the property above. It lives on the application's confidential info, not on the OAuth object:

  • theAGConsoleJson.application.confidentialInfo.confidentialType — the client authentication type
  • theAGConsoleJson.application.confidentialInfo.jwkSet — the public JWKS keystore text
  • theAGConsoleJson.application.confidentialInfo.jwkSetUrl — the public URL providing the JWKS