From f0e8281e575b8c820cdd6f0080315edaf5d31dbd Mon Sep 17 00:00:00 2001 From: danifronter Date: Sat, 1 Aug 2026 21:20:46 -0400 Subject: [PATCH 1/2] feat(sdk): generate reviewed Laravel OIDC configuration --- CHANGELOG.md | 6 ++ docs/INTEGRATION_LARAVEL.md | 35 ++++++- src/Oidc/OidcDiscoveryDocument.php | 29 ++++++ src/Oidc/OidcEnvironmentTemplate.php | 144 ++++++++++++++++++++++++++ tests/OidcEnvironmentTemplateTest.php | 102 ++++++++++++++++++ 5 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 src/Oidc/OidcEnvironmentTemplate.php create mode 100644 tests/OidcEnvironmentTemplateTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 773094c..39602c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 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 - Unreleased - Add durable, opaque, exact-once login intents for state, nonce, PKCE verifier, diff --git a/docs/INTEGRATION_LARAVEL.md b/docs/INTEGRATION_LARAVEL.md index 0ccc0b2..a3918ef 100644 --- a/docs/INTEGRATION_LARAVEL.md +++ b/docs/INTEGRATION_LARAVEL.md @@ -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 @@ -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. diff --git a/src/Oidc/OidcDiscoveryDocument.php b/src/Oidc/OidcDiscoveryDocument.php index d62cc91..700e1c9 100644 --- a/src/Oidc/OidcDiscoveryDocument.php +++ b/src/Oidc/OidcDiscoveryDocument.php @@ -42,6 +42,35 @@ public function configureClient(string $clientId, string $redirectUri, ?string $ ); } + /** + * @param array $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 diff --git a/src/Oidc/OidcEnvironmentTemplate.php b/src/Oidc/OidcEnvironmentTemplate.php new file mode 100644 index 0000000..fa80112 --- /dev/null +++ b/src/Oidc/OidcEnvironmentTemplate.php @@ -0,0 +1,144 @@ + $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 + */ + 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 + */ + 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.'"'; + } +} diff --git a/tests/OidcEnvironmentTemplateTest.php b/tests/OidcEnvironmentTemplateTest.php new file mode 100644 index 0000000..36cbc8f --- /dev/null +++ b/tests/OidcEnvironmentTemplateTest.php @@ -0,0 +1,102 @@ +discovery()->environmentTemplate( + clientId: 'platform-web', + redirectUri: 'https://platform.example.com/auth/callback', + ); + + self::assertSame([ + 'IDENTITY_ISSUER' => 'https://identity.example.com', + 'IDENTITY_CLIENT_ID' => 'platform-web', + 'IDENTITY_REDIRECT_URI' => 'https://platform.example.com/auth/callback', + 'IDENTITY_AUTHORIZATION_ENDPOINT' => 'https://identity.example.com/oauth/authorize', + 'IDENTITY_TOKEN_ENDPOINT' => 'https://identity.example.com/oauth/token', + 'IDENTITY_JWKS_URI' => 'https://identity.example.com/.well-known/jwks.json', + 'IDENTITY_SCOPES' => 'openid profile email', + 'IDENTITY_OIDC_PROFILE' => 'standard', + 'IDENTITY_CLIENT_AUTH_METHOD' => 'auto', + 'IDENTITY_USERINFO_ENDPOINT' => 'https://identity.example.com/oauth/userinfo', + ], $template->values()); + self::assertStringContainsString('IDENTITY_ISSUER="https://identity.example.com"', $template->toDotenv()); + self::assertStringNotContainsString('IDENTITY_CLIENT_SECRET=', $template->toDotenv()); + self::assertStringContainsString('secret manager', $template->toDotenv()); + } + + public function test_dotenv_export_prevents_multiline_value_injection(): void + { + $template = $this->discovery()->environmentTemplate( + clientId: "platform-web\nMALICIOUS_VALUE=1", + redirectUri: 'https://platform.example.com/auth/callback', + ); + + self::assertStringContainsString('IDENTITY_CLIENT_ID="platform-webMALICIOUS_VALUE=1"', $template->toDotenv()); + self::assertStringNotContainsString("\nMALICIOUS_VALUE=1", $template->toDotenv()); + } + + public function test_laravel_export_uses_the_adapter_contract_and_requires_an_explicit_shared_store(): void + { + $template = $this->discovery()->environmentTemplate( + clientId: 'platform-web', + redirectUri: 'https://platform.example.com/auth/callback', + ); + + self::assertSame([ + 'IDENTITY_OIDC_ISSUER' => 'https://identity.example.com', + 'IDENTITY_OIDC_CLIENT_ID' => 'platform-web', + 'IDENTITY_OIDC_REDIRECT_URI' => 'https://platform.example.com/auth/callback', + 'IDENTITY_OIDC_AUTHORIZATION_ENDPOINT' => 'https://identity.example.com/oauth/authorize', + 'IDENTITY_OIDC_TOKEN_ENDPOINT' => 'https://identity.example.com/oauth/token', + 'IDENTITY_OIDC_JWKS_URI' => 'https://identity.example.com/.well-known/jwks.json', + 'IDENTITY_OIDC_SCOPES' => 'openid profile email', + 'IDENTITY_OIDC_PROFILE' => 'standard', + 'IDENTITY_OIDC_CLIENT_AUTH_METHOD' => 'auto', + 'IDENTITY_OIDC_INTENT_CACHE_STORE' => 'redis', + 'IDENTITY_OIDC_USERINFO_ENDPOINT' => 'https://identity.example.com/oauth/userinfo', + ], $template->laravelValues('redis')); + self::assertStringContainsString('IDENTITY_OIDC_INTENT_CACHE_STORE="redis"', $template->toLaravelDotenv('redis')); + self::assertStringNotContainsString('IDENTITY_OIDC_CLIENT_SECRET=', $template->toLaravelDotenv('redis')); + + $this->expectException(OidcException::class); + $template->laravelValues(' '); + } + + public function test_high_assurance_template_requires_discovery_proof(): void + { + $this->expectException(OidcException::class); + + (new OidcDiscoveryDocument( + issuer: 'https://identity.example.com', + authorizationEndpoint: 'https://identity.example.com/oauth/authorize', + tokenEndpoint: 'https://identity.example.com/oauth/token', + jwksUri: 'https://identity.example.com/.well-known/jwks.json', + ))->environmentTemplate('platform-web', 'https://platform.example.com/auth/callback', profile: 'novvor-high-assurance-v1'); + } + + private function discovery(): OidcDiscoveryDocument + { + return new OidcDiscoveryDocument( + issuer: 'https://identity.example.com', + authorizationEndpoint: 'https://identity.example.com/oauth/authorize', + tokenEndpoint: 'https://identity.example.com/oauth/token', + jwksUri: 'https://identity.example.com/.well-known/jwks.json', + userinfoEndpoint: 'https://identity.example.com/oauth/userinfo', + pushedAuthorizationRequestEndpoint: 'https://identity.example.com/oauth/par', + responseModesSupported: ['query.jwt'], + tokenEndpointAuthMethodsSupported: ['private_key_jwt'], + dpopSigningAlgValuesSupported: ['ES256'], + authorizationResponseIssuerParameterSupported: true, + ); + } +} From 3eb79a9a629c34bc0c9ac8b65b639c4a59d37edb Mon Sep 17 00:00:00 2001 From: danifronter Date: Sun, 2 Aug 2026 10:15:35 -0400 Subject: [PATCH 2/2] docs(sdk): record Identity 2.5 release evidence --- CHANGELOG.md | 2 +- composer.json | 2 +- docs/RELEASE_2_5_PLAN.md | 13 ++++++------- docs/SDK_2_ADOPTION_AUDIT.md | 13 ++++++------- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39602c7..1f6e719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Laravel integration handoffs, including an explicit shared intent-cache store. The SDK never writes application `.env` files. -## 2.5.0 - Unreleased +## 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. diff --git a/composer.json b/composer.json index bee12b9..813dbd9 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/docs/RELEASE_2_5_PLAN.md b/docs/RELEASE_2_5_PLAN.md index 89d0295..f3918d5 100644 --- a/docs/RELEASE_2_5_PLAN.md +++ b/docs/RELEASE_2_5_PLAN.md @@ -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 | @@ -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. @@ -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 diff --git a/docs/SDK_2_ADOPTION_AUDIT.md b/docs/SDK_2_ADOPTION_AUDIT.md index f825ea5..c7df828 100644 --- a/docs/SDK_2_ADOPTION_AUDIT.md +++ b/docs/SDK_2_ADOPTION_AUDIT.md @@ -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.