Promote an app between environments
Promote an app between environments
Client ids are minted per environment, so the app you configured in staging has no
counterpart in production until you create one. A ClientBlueprint carries the
configuration across — and nothing that belongs to the environment it came from.
What a blueprint carries
| Carried | Left out |
|---|---|
| name, client type, token-endpoint auth method | client_id — minted per environment |
| grants, scopes, first-party flag | secrets and the registration access token — credentials are never copied |
| redirect URIs, post-logout redirect URIs, manifest URL | the owning organization — an id in the source environment |
| access-token lifetime | the JWK Set — each environment should hold its own keys |
back-channel logout URI and its sid requirement |
customer API keys — they belong to the environment they were issued in |
customer-API-key prefix (api_key_prefix) |
Export
use Cbox\Id\OAuthServer\Contracts\ClientRegistry;
$json = app(ClientRegistry::class)->blueprint($stagingClient)->toJson();
{
"kind": "cbox-id.client-blueprint",
"version": 1,
"name": "Billing",
"client_type": "confidential",
"token_endpoint_auth_method": null,
"grant_types": [
"authorization_code",
"refresh_token"
],
"redirect_uris": [
"https://billing.staging.example.com/callback"
],
"post_logout_redirect_uris": [],
"scopes": [
"email",
"openid",
"profile"
],
"first_party": false,
"manifest_url": "https://billing.staging.example.com/cbox-id.json",
"access_token_ttl": 600,
"backchannel_logout_uri": "https://billing.staging.example.com/oidc/logout",
"backchannel_logout_session_required": false,
"api_key_prefix": "billing_test"
}
The document is deterministic: keys in a fixed order, every list de-duplicated and sorted, so the same app always exports the same bytes and a blueprint committed to a repository diffs cleanly.
Import
Run the import in the target environment, adjusting what names the source deployment:
use Cbox\Id\Kernel\Audit\ValueObjects\AuditActor;
use Cbox\Id\OAuthServer\ValueObjects\ClientBlueprint;
$blueprint = ClientBlueprint::fromJson($json)
->withRedirectUris(['https://billing.example.com/callback'])
->withManifestUrl('https://billing.example.com/cbox-id.json')
->withBackchannelLogout('https://billing.example.com/oidc/logout')
->withApiKeyPrefix('billing_live'); // mark production keys as live
$registered = app(ClientRegistry::class)->import(
$blueprint,
organizationId: null, // or the owning organization in THIS environment
actor: AuditActor::organizationMember($memberId),
);
$registered->client->client_id; // a new cid_…
$registered->secret; // a new csec_… — shown once
The key prefix is unique per environment. An import into an environment where another
app already declared the blueprint's api_key_prefix is refused with an
InvalidClientMetadata saying so — never imported with the prefix silently dropped, because
an app that quietly accepts none of its customers' keys is the failure nobody notices until
a customer does. Promoting to another environment keeps the prefix (or renames it, as above);
copying an app within one environment needs withApiKeyPrefix('other_test') or
withApiKeyPrefix(null) first. A document exported before 1.19 has none of the three
logout and key-prefix keys and reads as "not configured".
A private_key_jwt app is imported with the target environment's public key set:
import($blueprint, jwks: $productionJwks). Without it the import is refused.
The import is recorded as app.created with source: blueprint.
What is refused
ClientBlueprint::fromJson() / fromArray() refuse — never silently drop — anything they
cannot honour, with an InvalidClientMetadata naming the reason:
- an unknown key. A document carrying
client_secretorclient_idwas written by somebody who expects them to be honoured; - a
kindother thancbox-id.client-blueprint, or aversionthis server does not read; - an unknown client type, auth method or grant, or token exchange on a public client;
- a redirect URI that is not absolute, carries a fragment, or uses a single-word custom
scheme (
javascript:); anauthorization_codeapp with no redirect URI; - an access-token lifetime outside the configured bounds;
- a back-channel logout URI that is not https (or http on localhost), or carries a fragment
or credentials; an
api_key_prefixthat does not match^[a-z][a-z0-9]{1,15}_(live|test)$or uses the platform's reservedcbidroot; - values of the wrong type.
The target environment's own rules still apply on top: a console may hold redirect URIs to a
stricter bar (https only, say) before it calls import().