diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index fe5e653..ce189ab 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -4,6 +4,8 @@ on: pull_request: push: branches: [main] + tags: ["v*"] + workflow_dispatch: permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8cfd6..773094c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.5.0 - Unreleased + +- Add durable, opaque, exact-once login intents for state, nonce, PKCE verifier, + allowlisted return paths, browser binding and correlation IDs. +- Require a shared atomic `LoginIntentStore` outside test and local-only + experiments; the in-memory store is explicitly unsuitable for production. +- Preserve the SDK 2.0 protocol surface while aligning the 2.5 Laravel guidance + with the published adapter's pending durable-intent upgrade. + ## 2.0.0 - Unreleased - Add typed PAR transactions and strict HTTPS transport. diff --git a/README.md b/README.md index 16c5b29..1469974 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Official server-side relying-party SDK for Novvor Cloud Identity. It provides OI discovery, Authorization Code plus PKCE, state and nonce validation, signed ID token validation, JWKS rotation, assurance claims, and logout validation. -The upcoming 2.0 line also provides an opt-in high-assurance profile: +The 2.x line provides an opt-in high-assurance profile: Authorization Code + PKCE S256 + PAR + JARM + RFC 9207 + DPoP + `private_key_jwt`. It is designed for government and regulated enterprise integrations without weakening the interoperable standard profile. @@ -24,6 +24,16 @@ token validator. Never expose a client secret or token in browser code. The administrative API is intentionally excluded from this package. +## Durable login intents (SDK 2.5) + +`LoginIntentManager` preserves the internal application destination together +with `state`, `nonce`, and the PKCE verifier under an opaque, one-time handle. +Production applications must implement `LoginIntentStore` using a shared, +transactional database or cache. Cookies and callback query strings carry only +the opaque handle; they are never the authority for the return destination. + +The included `InMemoryLoginIntentStore` exists only for tests and local +experiments. It is intentionally unsuitable for multiple processes or nodes. ## Security profiles - `standard`: interoperable OIDC relying-party behavior. Existing 1.x @@ -41,9 +51,13 @@ Never infer high-assurance support from a successful login. Discover metadata, run `EnterpriseProfileValidator`, store the authorization transaction server-side, and process callbacks through `AuthorizationResponseProcessor`. See [the Laravel integration guide](docs/INTEGRATION_LARAVEL.md). -The cross-package adoption audit and release gates are in +The first-party `novvor/identity-laravel` adapter is published for SDK 2.0. Its +2.5 release must adopt durable login intents before consumers can claim the +2.5 integration contract. Until then Laravel applications must keep protocol +orchestration in one tested application adapter, never in controllers. The +cross-package adoption audit and 2.5 release gates are in [`docs/SDK_2_ADOPTION_AUDIT.md`](docs/SDK_2_ADOPTION_AUDIT.md) and -[`docs/RELEASE_2_0_PLAN.md`](docs/RELEASE_2_0_PLAN.md). +[`docs/RELEASE_2_5_PLAN.md`](docs/RELEASE_2_5_PLAN.md). ## Workload authentication diff --git a/composer.json b/composer.json index c04f1b8..bee12b9 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "novvor/identity-sdk-php", - "description": "Novvor Cloud Identity relying-party SDK for confidential server-side PHP applications.", + "description": "Novvor Cloud Identity SDK 2.5 for confidential server-side PHP applications.", "type": "library", "license": "Apache-2.0", "require": { @@ -18,10 +18,22 @@ "Novvor\\IdentitySdk\\": "src/" } }, + "extra": { + "branch-alias": { + "dev-codex/identity-sdk-2": "2.5.x-dev" + } + }, "minimum-stability": "stable", "prefer-stable": true, "scripts": { "analyse": "phpstan analyse --no-progress", - "test": "phpunit" + "test": "phpunit", + "verify": [ + "@composer validate --strict", + "@test", + "@analyse", + "@composer audit", + "git diff --check" + ] } } diff --git a/docs/INTEGRATION_LARAVEL.md b/docs/INTEGRATION_LARAVEL.md index efb22ec..0ccc0b2 100644 --- a/docs/INTEGRATION_LARAVEL.md +++ b/docs/INTEGRATION_LARAVEL.md @@ -10,29 +10,36 @@ 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`. -Persist `state`, `nonce`, `code_verifier`, the intended destination and a -correlation ID in the encrypted server-side session. Do not serialize private -keys, tokens or authorization codes into the session. +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 +intent handle and a browser-session binding. Do not serialize protocol secrets, +private keys, tokens or authorization codes into a session or cookie. ## High-assurance sequence ```php $transaction = $requests->transaction($configuration); +$intent = $loginIntents->begin( + transaction: $transaction, + returnPath: $allowlistedReturnPath, + browserBinding: session()->getId(), + correlationId: $correlationId, +); $par = $pushed->push($configuration, $transaction, $discovery->pushedAuthorizationRequestEndpoint); -session()->put('identity.transaction', [ - 'state' => $transaction->state, - 'nonce' => $transaction->nonce, - 'code_verifier' => $transaction->codeVerifier, -]); +session()->put('identity.intent_handle', $intent->handle); return redirect()->away($requests->pushedAuthorizationUrl($configuration, $par->requestUri)); ``` -The callback must use `AuthorizationResponseProcessor`, consume the transaction -once, exchange the code with `AuthorizationCodeClient`, validate the ID token -nonce, regenerate the Laravel session ID, bind the authenticated tenant and -only then restore the allowlisted destination. +The callback must obtain the intent handle from its server-side session, +atomically consume the intent before the code exchange, and use +`AuthorizationResponseProcessor`. It then exchanges the code with +`AuthorizationCodeClient`, validates the ID token nonce, regenerates the +Laravel session ID, binds the authenticated tenant and only then restores the +allowlisted destination. A consumed, expired or browser-binding-mismatched +intent must fail closed. ## Operational requirements @@ -43,3 +50,6 @@ only then restore the allowlisted destination. - 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. diff --git a/docs/RELEASE_2_5_PLAN.md b/docs/RELEASE_2_5_PLAN.md new file mode 100644 index 0000000..89d0295 --- /dev/null +++ b/docs/RELEASE_2_5_PLAN.md @@ -0,0 +1,60 @@ +# Identity SDK 2.5 release plan + +Date: 2026-08-01 + +## Release status + +| Item | Status | +|---|---| +| `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 | +| 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 | + +The published adapter is an SDK 2.0 integration boundary. Its missing 2.5 +durable-intent capability is a consumer rollout blocker, not a reason to weaken +the core release gate or duplicate protocol logic in controllers. + +## Core release gate + +Before tagging `v2.5.0`, the candidate must pass: + +1. `composer validate --strict`. +2. A clean `composer install` using only published dependency tags. +3. `composer verify` (PHPUnit, PHPStan, Composer audit and `git diff --check`). +4. Positive and negative tests for login-intent exact-once consumption, browser + binding, expiry, state, nonce, PKCE, JARM, PAR, DPoP, RFC 9207 and token + validation. +5. A changelog that names any non-compatible behavior. +6. An immutable annotated Git tag and a clean installation test from that tag. + +The `verify` script is the canonical local and CI-equivalent preflight. The +quality workflow also runs for immutable version tags so the release evidence +remains attached to the exact tagged source. A tag is not a deployment or +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 + makes the transaction lifecycle a single supported boundary. +3. 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 + is an authentication-boundary change, not a dependency bump. +5. Verify each deployment independently before the next consumer is changed. + +## Rollback + +Consumers retain their exact Composer lock until their independent runtime +validation passes. A failed consumer rollout is rolled back by restoring its +prior lockfile and deployment artifact; the immutable SDK tag is not rewritten. + +## Truthful readiness + +`identity-sdk-php` may become `PASS_PACKAGE_RELEASE` after its core gate. The +ecosystem becomes `PASS_RUNTIME` only after each consuming application proves +the configured OIDC flow, tenant binding, callbacks and negative cases at +runtime. diff --git a/docs/SDK_2_ADOPTION_AUDIT.md b/docs/SDK_2_ADOPTION_AUDIT.md index e61ecf4..f825ea5 100644 --- a/docs/SDK_2_ADOPTION_AUDIT.md +++ b/docs/SDK_2_ADOPTION_AUDIT.md @@ -1,13 +1,18 @@ -# Identity SDK 2.0 adoption audit +# Identity SDK 2.5 adoption audit Date: 2026-07-28 ## Executive verdict -The PHP core now covers the relying-party protocol surface needed by Laravel -and framework-neutral backend consumers. Adoption was previously fragile -because consumers had to assemble transaction state and advanced protocol -features themselves. The official Laravel adapter now owns that orchestration. +The PHP core covers the relying-party protocol surface needed by Laravel and +framework-neutral backend consumers. It now includes durable, opaque login +intents so applications can keep a return destination, PKCE verifier, nonce and +state on the server under exact-once consumption semantics. + +The first-party Laravel adapter exists and is released as `v2.0.1`. It uses an +encrypted Laravel session transaction and has not adopted the SDK 2.5 durable, +opaque login-intent contract. Therefore a consumer-wide Laravel upgrade to 2.5 +is not releasable until the adapter is updated, tested and released. This is `PASS_LOCAL_INTEGRATION`, not OpenID certification or production proof. @@ -17,7 +22,7 @@ This is `PASS_LOCAL_INTEGRATION`, not OpenID certification or production proof. |---|---|---| | `identity-contracts` | claim names and security profiles | transport, secrets | | `identity-sdk-php` | framework-neutral OAuth/OIDC protocol | Laravel session, admin APIs | -| `identity-laravel` | Laravel config, DI and transaction lifecycle | tenant authorization policy | +| `identity-laravel` | Laravel config, DI and transaction lifecycle; published `v2.0.1`, 2.5 durable-intent upgrade pending | tenant authorization policy | | `identity-admin-sdk-php` | privileged control-plane transport | user login/session logic | | `identity-sdk-testing` | truthful fakes and negative fixtures | real keys, tokens, customer data | @@ -37,21 +42,23 @@ This is `PASS_LOCAL_INTEGRATION`, not OpenID certification or production proof. 11. Bind UserInfo `sub` to the ID Token `sub`. 12. Map tenant and permissions in the application, then regenerate its session. -Laravel consumers should use `novvor/identity-laravel` rather than reproduce -these steps in controllers. +Laravel consumers must use the published adapter for SDK 2.0 or one tested +application integration service. The adapter requires a 2.5 update and release +against `LoginIntentManager` before it can be treated as the consumer-wide 2.5 +contract; controllers must not reconstruct this flow. ## Capability truth -| Capability | Core | Laravel adapter | Server evidence required | +| Capability | Core | Laravel integration boundary | Server evidence required | |---|---:|---:|---| -| Authorization Code | yes | orchestrated | response type | -| PKCE S256 | yes | orchestrated | S256 metadata | -| state / nonce | yes | encrypted session | runtime callback | -| RFC 9207 | yes | enforced | issuer parameter | -| PAR | yes | orchestrated | PAR endpoint | -| JARM | yes | orchestrated | query.jwt + RS256 | -| DPoP | yes | per-session ES256 key | ES256 + DPoP token | -| private_key_jwt | yes | configured | registered public key | +| Authorization Code | yes | shared app service required | response type | +| PKCE S256 | yes | shared app service required | S256 metadata | +| state / nonce | yes | durable login intent required | runtime callback | +| RFC 9207 | yes | shared app service required | issuer parameter | +| PAR | yes | shared app service required | PAR endpoint | +| JARM | yes | shared app service required | query.jwt + RS256 | +| DPoP | yes | per-session ES256 key required | ES256 + DPoP token | +| private_key_jwt | yes | configured by app | registered public key | | refresh rotation | yes | bound client | replacement refresh token | | UserInfo | yes | subject-bound | endpoint | | introspection/revocation | yes | bound clients | endpoints | @@ -61,13 +68,13 @@ these steps in controllers. ## Remaining release blockers -1. Tag `identity-contracts` 2.0. -2. Tag `identity-sdk-php` 2.0. -3. Replace the Laravel adapter's temporary Draft-branch constraint with `^2.0`. -4. Tag `identity-sdk-testing` and `identity-admin-sdk-php` 2.0. -5. Run a clean Composer install using tags only. -6. Validate one Laravel reference consumer end to end. -7. Run negative issuer, callback replay, tenant mismatch and key-rotation tests. -8. Validate staging runtime and an external OpenID conformance profile. +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 + `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. No package should claim `PASS_RUNTIME` until those external gates have evidence. diff --git a/src/Oidc/InMemoryLoginIntentStore.php b/src/Oidc/InMemoryLoginIntentStore.php new file mode 100644 index 0000000..9ad345d --- /dev/null +++ b/src/Oidc/InMemoryLoginIntentStore.php @@ -0,0 +1,37 @@ + */ + private array $intents = []; + + public function put(LoginIntent $intent): void + { + if (isset($this->intents[$intent->handle])) { + throw new OidcException('Login intent handle already exists.'); + } + + $this->intents[$intent->handle] = $intent; + } + + public function get(string $handle): ?LoginIntent + { + return $this->intents[$handle] ?? null; + } + + public function consume(string $handle): ?LoginIntent + { + $intent = $this->intents[$handle] ?? null; + unset($this->intents[$handle]); + + return $intent; + } +} diff --git a/src/Oidc/LoginIntent.php b/src/Oidc/LoginIntent.php new file mode 100644 index 0000000..851371e --- /dev/null +++ b/src/Oidc/LoginIntent.php @@ -0,0 +1,41 @@ += $this->expiresAt; + } +} diff --git a/src/Oidc/LoginIntentManager.php b/src/Oidc/LoginIntentManager.php new file mode 100644 index 0000000..b2ca0b0 --- /dev/null +++ b/src/Oidc/LoginIntentManager.php @@ -0,0 +1,70 @@ + 1800) { + throw new OidcException('Login intent TTL must be between 60 and 1800 seconds.'); + } + } + + public function begin( + AuthorizationTransaction $transaction, + string $returnPath, + string $browserBinding, + string $correlationId, + ?int $now = null, + ): LoginIntent { + $now ??= time(); + $intent = new LoginIntent( + $this->token(), + $transaction->state, + $transaction->nonce, + $transaction->codeVerifier, + $returnPath, + $this->fingerprint($browserBinding), + $correlationId, + $now, + $now + $this->ttlSeconds, + ); + $this->store->put($intent); + + return $intent; + } + + public function consume(string $handle, string $browserBinding, ?int $now = null): LoginIntent + { + $intent = $this->store->consume($handle); + if ($intent === null) { + throw new OidcException('Login intent is unknown or was already consumed.'); + } + if ($intent->isExpired($now ?? time())) { + throw new OidcException('Login intent has expired.'); + } + if (! hash_equals($intent->browserBinding, $this->fingerprint($browserBinding))) { + throw new OidcException('Login intent does not belong to this browser session.'); + } + return $intent; + } + + private function fingerprint(string $binding): string + { + if ($binding === '') { + throw new OidcException('Browser session binding is required.'); + } + + return hash('sha256', $binding); + } + + private function token(): string + { + return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + } +} diff --git a/src/Oidc/LoginIntentStore.php b/src/Oidc/LoginIntentStore.php new file mode 100644 index 0000000..2d50d4f --- /dev/null +++ b/src/Oidc/LoginIntentStore.php @@ -0,0 +1,19 @@ +begin($this->transaction(), '/manage/dashboard?tab=security', 'session-secret', 'correlation-1', 1000); + + self::assertSame('/manage/dashboard?tab=security', $intent->returnPath); + self::assertNotSame('session-secret', $intent->browserBinding); + self::assertSame($intent, $store->get($intent->handle)); + } + + public function test_consumes_exactly_once_and_checks_browser_binding(): void + { + $manager = new LoginIntentManager(new InMemoryLoginIntentStore()); + $intent = $manager->begin($this->transaction(), '/admin', 'browser-a', 'correlation-1', 1000); + + self::assertSame($intent->handle, $manager->consume($intent->handle, 'browser-a', 1001)->handle); + + $this->expectException(OidcException::class); + $manager->consume($intent->handle, 'browser-a', 1002); + } + + public function test_binding_mismatch_fails_closed_and_burns_intent(): void + { + $manager = new LoginIntentManager(new InMemoryLoginIntentStore()); + $intent = $manager->begin($this->transaction(), '/admin', 'browser-a', 'correlation-1', 1000); + + try { + $manager->consume($intent->handle, 'attacker', 1001); + self::fail('A mismatched browser binding must fail.'); + } catch (OidcException $exception) { + self::assertSame('Login intent does not belong to this browser session.', $exception->getMessage()); + } + + $this->expectException(OidcException::class); + $manager->consume($intent->handle, 'browser-a', 1002); + } + + public function test_expired_intent_fails_closed(): void + { + $manager = new LoginIntentManager(new InMemoryLoginIntentStore(), 60); + $intent = $manager->begin($this->transaction(), '/admin', 'browser-a', 'correlation-1', 1000); + + $this->expectException(OidcException::class); + $manager->consume($intent->handle, 'browser-a', 1060); + } + + public function test_rejects_external_return_urls(): void + { + $this->expectException(OidcException::class); + (new LoginIntentManager(new InMemoryLoginIntentStore())) + ->begin($this->transaction(), 'https://attacker.example', 'browser-a', 'correlation-1'); + } + + private function transaction(): AuthorizationTransaction + { + return new AuthorizationTransaction('state', 'nonce', str_repeat('v', 43), ['client_id' => 'client']); + } +}