# App Sphere Examples

This page contains example interceptors that can be registered with [Cluster Manager](/content/docs/modules/cluster_mgr.html) 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.

```java
/*-
 * #%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);
		}
	}
}
```

Copy

# 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](/content/docs/interceptors/examples_app_sphere.html#propagating-a-regenerated-client-secret/index.html) 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](/content/docs/app_gallery/dev_portal.html#generating-a-client-secret/index.html). 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:

| Field | Description |
| --- | --- |
| `clientId` | The OIDC client identifier of the application whose secret was generated. |
| `secret` | The plaintext client secret, delivered once. Smile CDR retains only a hash of this value. |
| `warning` | Set 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:

| Field | Description |
| --- | --- |
| `identifier.pid` | The application's identifier, the `{app_id}` whose secret was generated. |
| `oauth.clientId` | The same OIDC client identifier as `AGClientSecretJson.clientId`. |
| `oauth.enabled` | Whether the OIDC client is enabled. |
| `oauth.secretInfo` | An active client secret's `description`, `activation` and `expiration`. No plaintext. |
| `application` | Name, version, status and confidential-client configuration, including the JWKS fields. |
| `scopes` | The application's approved scopes. |
| `profile`, `currentProfile` | The owning developer's profile as recorded on the application, and as it stands now. |

```java
@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
}
```

Copy

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

[Examples: Cluster Manager](/content/docs/interceptors/examples_cluster_manager.html)

Examples: App Sphere

Interceptors

[Interceptors](/content/docs/interceptors/interceptors.html)
[Pointcuts](/content/docs/interceptors/pointcuts.html)
[Starter Project](/content/docs/interceptors/starter_project.html)
[Examples: FHIR Endpoints](/content/docs/interceptors/examples_fhir_endpoint.html)
[Examples: HL7v2 Endpoints](/content/docs/interceptors/examples_hl7v2_endpoint.html)
[Examples: FHIR Storage](/content/docs/interceptors/examples_fhir_storage.html)
[Examples: FHIR Gateway](/content/docs/interceptors/examples_fhir_gateway.html)
[Examples: FHIR Client](/content/docs/interceptors/examples_fhir_client.html)
[Examples: MDM](/content/docs/interceptors/examples_mdm.html)
[Examples: Subscription](/content/docs/interceptors/examples_subscription.html)
[Examples: Channel Import](/content/docs/interceptors/examples_channel_import.html)
[Examples: Cluster Manager](/content/docs/interceptors/examples_cluster_manager.html)
[Examples: App Sphere](/content/docs/interceptors/examples_app_sphere.html)
[Examples: System To System Data Exchange](/content/docs/interceptors/examples_s2s_data_exchange.html)
[2024.02.01 Migration Guide](/content/docs/interceptors/jakarta_upgrade.html)

[Examples: System To System Data Exchange](/content/docs/interceptors/examples_s2s_data_exchange.html)
