Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Changelog

## 2.5.0 - Unreleased
## Next

- Add a Discovery-derived, non-secret environment template for reviewed
Laravel integration handoffs, including an explicit shared intent-cache
store. The SDK never writes application `.env` files.

## 2.5.0 - 2026-08-01

- Add durable, opaque, exact-once login intents for state, nonce, PKCE verifier,
allowlisted return paths, browser binding and correlation IDs.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"minimum-stability": "stable",
"prefer-stable": true,
"scripts": {
"analyse": "phpstan analyse --no-progress",
"analyse": "php -d memory_limit=512M vendor/bin/phpstan analyse --no-progress",
"test": "phpunit",
"verify": [
"@composer validate --strict",
Expand Down
35 changes: 32 additions & 3 deletions docs/INTEGRATION_LARAVEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,34 @@ Bind one `OidcClientConfiguration` from `config/identity.php`. Do not call
`env()` in controllers and do not provide `.test`, localhost or HTTP defaults
when `APP_ENV=production`.

## Public configuration handoff

After validating Discovery and registering the exact redirect URI, an installer
or control-plane UI may generate copy/paste values without ever touching the
application's `.env` file:

```php
$discovery = $oidcDiscoveryClient->fetch('https://identity.example.com');
$template = $discovery->environmentTemplate(
clientId: 'your-client-id',
redirectUri: 'https://app.example.com/auth/oidc/callback',
);

// Display or save this as a reviewed deployment artifact, not as a secret.
$publicEnvironment = $template->toLaravelDotenv(intentCacheStore: 'redis');
```

`toLaravelDotenv()` emits the exact public variable names consumed by
`novvor/identity-laravel`, including `IDENTITY_OIDC_INTENT_CACHE_STORE`. The
store name is mandatory and supplied by the deployment owner because Discovery
cannot prove that a particular Laravel cache driver is shared and atomic. Use a
reviewed shared store such as Redis in multi-node environments.

The template contains the issuer, endpoints, client ID, redirect URI, scopes
and selected profile. It intentionally excludes client secrets and private key
material. A deployment operator must place those only in the environment's
secret manager, then run the application's configuration/readiness gate.

Use `LoginIntentManager` with a shared, atomic `LoginIntentStore` to retain
`state`, `nonce`, `code_verifier`, the allowlisted intended destination and a
correlation ID. The browser-facing Laravel session may retain only the opaque
Expand Down Expand Up @@ -50,6 +78,7 @@ intent must fail closed.
- Treat Identity unavailability as a bounded error surface, not a redirect
loop.
- Readiness must verify configuration and key presence without exposing values.
- Use `novvor/identity-laravel` v2.0.1 for SDK 2.0 integrations. Do not claim
its 2.5 contract until its durable-login-intent upgrade is published; never
duplicate protocol orchestration in controllers.
- Use an adapter release that requires `novvor/identity-sdk-php ^2.5` and
exposes durable opaque login intents. Do not duplicate protocol
orchestration in controllers or place PKCE, nonce, DPoP or PAR state in a
browser session.
13 changes: 6 additions & 7 deletions docs/RELEASE_2_5_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Date: 2026-08-01
|---|---|
| `novvor/identity-contracts` v2.0.0 | Published baseline |
| `novvor/identity-sdk-php` v2.0.0 | Published baseline |
| SDK 2.5 core branch | Candidate; not tagged |
| SDK 2.5 core | Published as immutable `v2.5.0` |
| First-party Laravel adapter | `v2.0.1` published; 2.5 durable-intent upgrade pending |
| Platform and FilaSign runtime upgrade | Not yet validated against 2.5 |
| Console v1-to-v2 migration | Not started |
Expand All @@ -19,7 +19,7 @@ the core release gate or duplicate protocol logic in controllers.

## Core release gate

Before tagging `v2.5.0`, the candidate must pass:
The `v2.5.0` candidate passed the following gate before its immutable tag:

1. `composer validate --strict`.
2. A clean `composer install` using only published dependency tags.
Expand All @@ -37,14 +37,13 @@ consumer-rollout approval.

## Consumer order

1. Publish the core SDK `v2.5.0` only after the core gate is green.
2. Publish a Laravel integration package that uses durable login intents and
1. Publish a Laravel integration package that uses durable login intents and
makes the transaction lifecycle a single supported boundary.
3. Upgrade Enix Platform and FilaSign in independent branches; run their
2. Upgrade Enix Platform and FilaSign in independent branches; run their
browser and negative callback flows against the new package.
4. Migrate Enix Console from `^1.1` to `^2.5` in a separate review because it
3. Migrate Enix Console from `^1.1` to `^2.5` in a separate review because it
is an authentication-boundary change, not a dependency bump.
5. Verify each deployment independently before the next consumer is changed.
4. Verify each deployment independently before the next consumer is changed.

## Rollback

Expand Down
13 changes: 6 additions & 7 deletions docs/SDK_2_ADOPTION_AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,12 @@ contract; controllers must not reconstruct this flow.

## Remaining release blockers

1. Complete the 2.5 core release gate and publish an immutable `v2.5.0` tag.
2. Update, release and test the first-party Laravel adapter against durable
1. Update, release and test the first-party Laravel adapter against durable
`LoginIntentManager` storage; retain its current 2.0 contract until then.
3. Run a clean Composer install using tags only.
4. Validate Platform and FilaSign as reference consumers end to end.
5. Migrate Console from the v1 line as a separate, explicitly reviewed change.
6. Run negative issuer, callback replay, tenant mismatch and key-rotation tests.
7. Validate staging runtime and an external OpenID conformance profile.
2. Run a clean Composer install using tags only.
3. Validate Platform and FilaSign as reference consumers end to end.
4. Migrate Console from the v1 line as a separate, explicitly reviewed change.
5. Run negative issuer, callback replay, tenant mismatch and key-rotation tests.
6. Validate staging runtime and an external OpenID conformance profile.

No package should claim `PASS_RUNTIME` until those external gates have evidence.
29 changes: 29 additions & 0 deletions src/Oidc/OidcDiscoveryDocument.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,35 @@ public function configureClient(string $clientId, string $redirectUri, ?string $
);
}

/**
* @param array<int, string> $scopes
*/
public function environmentTemplate(
string $clientId,
string $redirectUri,
array $scopes = ['openid', 'profile', 'email'],
string $profile = 'standard',
): OidcEnvironmentTemplate {
if ($profile === 'novvor-high-assurance-v1' && ! $this->supportsHighAssuranceProfile()) {
throw new OidcException('Identity Discovery does not prove the requested high-assurance profile.');
}

$configuration = $this->configureClient($clientId, $redirectUri);

return new OidcEnvironmentTemplate(
issuer: $configuration->issuer,
clientId: $configuration->clientId,
redirectUri: $configuration->redirectUri,
authorizationEndpoint: $configuration->authorizationEndpoint,
tokenEndpoint: $configuration->tokenEndpoint,
jwksUri: $configuration->jwksUri,
scopes: $scopes,
profile: $profile,
clientAuthenticationMethod: $profile === 'novvor-high-assurance-v1' ? 'private_key_jwt' : 'auto',
userinfoEndpoint: $this->userinfoEndpoint,
);
}

public function supportsHighAssuranceProfile(): bool
{
return $this->pushedAuthorizationRequestEndpoint !== null
Expand Down
144 changes: 144 additions & 0 deletions src/Oidc/OidcEnvironmentTemplate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

declare(strict_types=1);

namespace Novvor\IdentitySdk\Oidc;

/**
* A non-secret, discovery-derived configuration handoff for a relying party.
*
* This deliberately does not write a .env file. Application configuration is
* environment-owned and may be cached, while credentials must stay in the
* deployment secret manager.
*/
final readonly class OidcEnvironmentTemplate
{
/**
* @param array<int, string> $scopes
*/
public function __construct(
public string $issuer,
public string $clientId,
public string $redirectUri,
public string $authorizationEndpoint,
public string $tokenEndpoint,
public string $jwksUri,
public array $scopes,
public string $profile,
public string $clientAuthenticationMethod,
public ?string $userinfoEndpoint = null,
) {
}

/**
* @return array<string, string>
*/
public function values(): array
{
$values = [
'IDENTITY_ISSUER' => $this->issuer,
'IDENTITY_CLIENT_ID' => $this->clientId,
'IDENTITY_REDIRECT_URI' => $this->redirectUri,
'IDENTITY_AUTHORIZATION_ENDPOINT' => $this->authorizationEndpoint,
'IDENTITY_TOKEN_ENDPOINT' => $this->tokenEndpoint,
'IDENTITY_JWKS_URI' => $this->jwksUri,
'IDENTITY_SCOPES' => implode(' ', $this->scopes),
'IDENTITY_OIDC_PROFILE' => $this->profile,
'IDENTITY_CLIENT_AUTH_METHOD' => $this->clientAuthenticationMethod,
];

if ($this->userinfoEndpoint !== null) {
$values['IDENTITY_USERINFO_ENDPOINT'] = $this->userinfoEndpoint;
}

return $values;
}

/**
* Produces a copy/paste template containing only public configuration.
* Secrets are named as deployment responsibilities, never emitted.
*/
public function toDotenv(): string
{
$lines = [
'# Generated from verified OpenID Connect Discovery metadata.',
'# Do not commit this file. Set credentials only in the environment secret manager.',
];

foreach ($this->values() as $name => $value) {
$lines[] = $name.'='.$this->escape($value);
}

$lines[] = '# Required secret reference: IDENTITY_CLIENT_SECRET (or private_key_jwt key material).';

return implode("\n", $lines)."\n";
}

/**
* @return array<string, string>
*/
public function laravelValues(string $intentCacheStore): array
{
$intentCacheStore = trim($intentCacheStore);
if ($intentCacheStore === '') {
throw new OidcException('Laravel integrations require an explicit shared OIDC intent cache store.');
}

$values = [
'IDENTITY_OIDC_ISSUER' => $this->issuer,
'IDENTITY_OIDC_CLIENT_ID' => $this->clientId,
'IDENTITY_OIDC_REDIRECT_URI' => $this->redirectUri,
'IDENTITY_OIDC_AUTHORIZATION_ENDPOINT' => $this->authorizationEndpoint,
'IDENTITY_OIDC_TOKEN_ENDPOINT' => $this->tokenEndpoint,
'IDENTITY_OIDC_JWKS_URI' => $this->jwksUri,
'IDENTITY_OIDC_SCOPES' => implode(' ', $this->scopes),
'IDENTITY_OIDC_PROFILE' => $this->profile,
'IDENTITY_OIDC_CLIENT_AUTH_METHOD' => $this->clientAuthenticationMethod,
'IDENTITY_OIDC_INTENT_CACHE_STORE' => $intentCacheStore,
];

if ($this->userinfoEndpoint !== null) {
$values['IDENTITY_OIDC_USERINFO_ENDPOINT'] = $this->userinfoEndpoint;
}

return $values;
}

/**
* Produces the public configuration names consumed by novvor/identity-laravel.
*
* The cache store is intentionally an explicit argument: Discovery cannot
* determine whether a relying party has a shared, atomic Laravel cache
* driver configured. The caller must select a reviewed deployment store.
*/
public function toLaravelDotenv(string $intentCacheStore): string
{
$lines = [
'# Generated from verified OpenID Connect Discovery metadata for novvor/identity-laravel.',
'# Do not commit this file. The intent cache store must be shared and atomic across application nodes.',
];

foreach ($this->laravelValues($intentCacheStore) as $name => $value) {
$lines[] = $name.'='.$this->escape($value);
}

if ($this->clientAuthenticationMethod === 'private_key_jwt') {
$lines[] = '# Required secret references: IDENTITY_OIDC_PRIVATE_KEY and IDENTITY_OIDC_PRIVATE_KEY_ID.';
} else {
$lines[] = '# Required secret reference when this client is confidential: IDENTITY_OIDC_CLIENT_SECRET.';
}

return implode("\n", $lines)."\n";
}

private function escape(string $value): string
{
$escaped = str_replace(
["\\", '"', "\r", "\n"],
["\\\\", '\\"', '', ''],
$value,
);

return '"'.$escaped.'"';
}
}
Loading
Loading