diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index c4016de..415fa35 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -14,7 +14,8 @@ jobs: with: tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} - generate_release_notes: true + body_path: RELEASE.md + generate_release_notes: true draft: false prerelease: false env: diff --git a/.github/workflows/ember.yml b/.github/workflows/ember.yml index 2a8a633..704457f 100644 --- a/.github/workflows/ember.yml +++ b/.github/workflows/ember.yml @@ -6,23 +6,22 @@ on: tags: - 'v*' pull_request: - branches: [ main ] + branches: [ main, 'release/v*', 'dev-v*' ] + +env: + NODE_VERSION: 22.x jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [18.x] # Build on Node.js 18 - steps: - uses: actions/checkout@v2 - - name: Setup Node.js ${{ matrix.node-version }} + - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v2 with: - node-version: ${{ matrix.node-version }} + node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm uses: pnpm/action-setup@v2.0.1 @@ -42,10 +41,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Setup Node.js 18.x + - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v2 with: - node-version: 18.x + node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm uses: pnpm/action-setup@v2.0.1 @@ -71,10 +70,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Setup Node.js 18.x + - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v2 with: - node-version: 18.x + node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm uses: pnpm/action-setup@v2.0.1 @@ -102,10 +101,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Setup Node.js 18.x + - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v2 with: - node-version: 18.x + node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm uses: pnpm/action-setup@v2.0.1 @@ -126,35 +125,3 @@ jobs: - name: Publish to Fleetbase registry run: flb publish - - fleetbase_publish_qa: - needs: build - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' - steps: - - uses: actions/checkout@v2 - - - name: Setup Node.js 18.x - uses: actions/setup-node@v2 - with: - node-version: 18.x - - - name: Setup pnpm - uses: pnpm/action-setup@v2.0.1 - with: - version: latest - - - name: Install Dependencies - run: pnpm install - - - name: Build - run: pnpm run build - - - name: Install Fleetbase CLI - run: npm i -g @fleetbase/cli npm-cli-login - - - name: Login to Fleetbase Registry - run: npm-cli-login -u ${{SECRETS.FLEETBASE_QA_REGISTRY_USERNAME}} -p ${{SECRETS.FLEETBASE_QA_REGISTRY_PASSWORD}} -e ${{SECRETS.FLEETBASE_QA_REGISTRY_EMAIL}} -r https://registry.qa.fleetbase.io - - - name: Publish to Fleetbase registry - run: flb publish --registry https://registry.qa.fleetbase.io diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..24b8c3b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release Tag + +# Tags a release when a release branch is merged to main, and pushes the tag. Delegates +# to the reusable workflow in fleetbase/fleetbase. Requires org secret _GITHUB_AUTH_TOKEN +# (inherited); no-ops with a warning until it is set. +# +# It pushes the tag and nothing else — create-release.yml and the publish jobs in this +# repository already chain off `push: tags: v*`. +# +# The version is never retyped: it comes from the branch name, and the tag is refused +# unless the files below and RELEASE.md already agree with it. This repository is +# an ember addon plus a server, so it carries composer.json, package.json and extension.json. +# All three must be bumped together — the tag job fails if any one of them disagrees. +# +# RELEASE.md is required, and its first line must name the version being released. That +# check is the only thing that can tell notes written for this release from the previous +# release's notes nobody replaced. +# +# Deliberately unpinned, like postman.yml — it tracks the reusable workflow on main, so +# there is no ref here to remember to bump. +# +# workflow_dispatch is the recovery path. Use it when a release failed validation and the +# fix landed on main after the merge: re-running the merge event would check out the +# merge commit and never see that fix. + +on: + pull_request: + types: [closed] + branches: [main] + workflow_dispatch: + inputs: + version: + description: "Version to tag, e.g. 1.2.3. Recovery only — a normal release reads it from the branch." + required: true +permissions: + contents: read + +jobs: + tag: + if: github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + (startsWith(github.event.pull_request.head.ref, 'release/v') || + startsWith(github.event.pull_request.head.ref, 'dev-v'))) + uses: fleetbase/fleetbase/.github/workflows/release-tag.yml@main + with: + version-files: composer.json,package.json,extension.json + version: ${{ github.event.inputs.version || '' }} + secrets: inherit diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index badbe2f..83a4135 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -6,7 +6,7 @@ on: tags: - 'v*' pull_request: - branches: [ main ] + branches: [ main, 'release/v*', 'dev-v*' ] jobs: build: @@ -28,14 +28,51 @@ jobs: sudo apt-get update sudo apt-get install -y libzip-dev libgd-dev libfreetype6-dev libjpeg-dev libpng-dev imagemagick libmagickwand-dev libmemcached-dev libgeos-dev libgmp-dev default-mysql-client libicu-dev + - name: Configure Composer Auth for Github + run: composer config --global github-oauth.github.com ${{ secrets._GITHUB_AUTH_TOKEN }} + - name: Validate composer.json and composer.lock run: composer validate - name: Install dependencies run: composer install --prefer-dist --no-progress + # test:lint rather than lint: `composer lint` runs php-cs-fixer in *fix* mode, + # so in CI it rewrote files in the runner and exited 0 whatever the state of + # the branch. The dry-run form is the actual gate, and the tree is clean now. - name: Run Lint - run: composer lint + run: composer test:lint + + # Newly enforceable: phpstan at level max is clean against + # phpstan-baseline.neon, so new code is held to it. + - name: Run Static Analysis + run: composer test:types + + # Re-enabled. Pest's binary resolves its autoloader from a hardcoded + # `vendor/`, which this package does not have -- it installs to server_vendor -- + # so `pest` could not start here at all. scripts/pest-runner.php, taken from + # storefront, bridges that with a symlink for the duration of the run. + - name: Run Tests + run: composer test:unit - # - name: Run Tests -- Will add tests back after phppest issue https://github.com/pestphp/pest/issues/920 is fixed - # run: composer test:unit + - name: Generate Coverage Baseline + run: XDEBUG_MODE=coverage composer coverage:baseline + + - name: Upload Coverage Baseline + uses: actions/upload-artifact@v4 + with: + name: solid-coverage-clover + path: coverage/clover.xml + if-no-files-found: error + + # No --fail-under gate, unlike fleetops: most of this package predates its + # tests and sits at 0%, so a gate would only ever be red. The report is + # published so the number is visible and can be moved deliberately. + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/clover.xml + disable_search: true + flags: backend + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index edf7568..b918f5f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ composer.lock /server_vendor /server/vendor/ .phpunit.result.cache +/.phpunit.cache .php_cs.cache .php-cs-fixer.cache *.swp diff --git a/README.md b/README.md index 603e47c..ca9429d 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,26 @@

+

+ + Repository + + + License: AGPL-3.0-or-later + + + Coverage + + + NPM package + + + Packagist package + + Node >= 18 + PHP ^8.1 +

+ # Introduction: Solid, an innovative technology developed by Sir Tim Berners-Lee, offers a groundbreaking approach to managing data by enabling decentralized data ownership and interoperability through Linked Data principles. In the realm of logistics, Solid presents a promising solution for revolutionizing supply chain management by facilitating seamless data sharing among stakeholders. This document outlines Solid's capabilities and requirements for implementing a logistics solution, along with a user needs assessment highlighting UI/UX changes necessary for optimal user experience. @@ -162,6 +182,26 @@ Fleetbase has implemented a Solid Client which implements the Standard Solid aut - Ability to link Fleetbase account with Solid Web ID later via user settings. +#### Solid-OIDC implementation + +The relying-party handshake lives in `server/src/Client/OpenIDConnectClient.php` and +`server/src/Auth/`, with no third-party OIDC library. Solid-OIDC needs three things a +general-purpose OAuth client does not provide — RFC 7591 dynamic client registration, +RFC 9449 DPoP-bound tokens, and an issuer discovered per tenant at runtime — so the +extension owns that code rather than wrapping a library around it. ID token +verification follows the same conventions as Core API's `Fleetbase\Auth\OAuth\IdTokenVerifier`: +a short-lived JWKS cache, resolution by `kid`, one forced refetch when a token names an +unknown key, and TLS verification that is never inferred from the app environment. + +What the handshake checks on the authorization callback: the `state` (single use, server +side), the ID token's signature against the provider's published JWKS, its `iss`, `aud` +and `azp`, its expiry, its `nonce`, and that the access token is bound to the DPoP key +this identity holds. + +Everything is configured under the `oidc` key of `server/config/solid.php`. The one +setting a local development environment usually needs is `SOLID_OIDC_VERIFY_TLS=false`, +for a Solid server with a self-signed certificate. + # Funding This project is funded through [NGI0 Entrust](https://nlnet.nl/entrust), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page]( https://nlnet.nl/project/Fleetbase-Solid). diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..8186ebe --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,75 @@ +> v0.0.8 ~ "Solid-OIDC brought in-house, and a verified authorization callback" + +--- +## Highlights + +Solid can be installed alongside current Core API again. `jumbojett/openid-connect-php` pinned `phpseclib/phpseclib ^3.0.7`, and Core API's `laravel/socialite ^5.31` requires `^4.0`, so the two could not coexist — Composer refused to resolve the application at all. Upstream Jumbojett has not migrated to phpseclib 4, and phpseclib 4 renamed its root namespace, so widening the constraint would resolve and then fail at runtime. + +The dependency turned out to be carrying that pin for code Solid never ran. Jumbojett touches phpseclib in one method, RSA signature verification, which this extension already overrode — and the authorization callback never reached Jumbojett's verification at all. Roughly 150 lines of a 2,100-line library were in use, with transport, token requests, client registration, session storage and discovery all replaced locally. + +So the library is gone and the Solid-OIDC client lives in the extension, in `server/src/Client/OpenIDConnectClient.php` and `server/src/Auth/`. Solid-OIDC needs three things no general-purpose OAuth client provides — RFC 7591 dynamic client registration, RFC 9449 DPoP-bound tokens, and an issuer discovered per tenant at runtime — and all three were already written here. ID token verification follows the conventions of Core API's `Fleetbase\Auth\OAuth\IdTokenVerifier`. + +Closing that out fixed a set of real gaps in the handshake, described below. **No user has to sign in again:** existing client registrations are reused and existing DPoP keys are migrated rather than regenerated. + +--- +## Security + +The authorization callback now verifies what it receives. Previously it exchanged whatever code it was handed, and the WebID that drives every pod read and write was taken from an unverified JWT. + +- **ID token verification.** Signature checked against the provider's published JWKS, plus `iss` (exact), `aud`, `azp` when `aud` is multi-valued, expiry with a bounded 60-second leeway, `nonce`, and the presence of `sub`. None of these were checked before. +- **`state` validation.** Server-side, single use, consumed before comparison so a rejected callback cannot be retried, and a mismatch now voids the whole pending request. `state` was previously ignored entirely. +- **DPoP private keys are no longer web-reachable.** They were written with a bare `Storage::put()`, and the application's default disk is `public` — rooted at `storage/app/public`, symlinked to `public/storage` and served over HTTP — or an S3/GCS bucket. Keys now go to an explicitly named private disk (`solid.oidc.key_disk`, default `local`), and a key found in the old location is moved there and the exposed copy deleted. +- **Algorithm confusion.** A key without an `alg` is dropped rather than assumed to be RS256, and a token whose header algorithm differs from its key's is rejected, so `alg: none` and HS256-signed-with-the-RSA-public-key both fail. +- **PKCE** is mandatory and fails loudly if the provider advertises methods that exclude S256, instead of silently dropping the challenge. +- **TLS verification** is one explicit flag, `solid.oidc.verify_tls`, rather than inferred from `APP_ENV` — which also disabled it in any environment an operator happened to name `local` or `development`. Local development against a self-signed certificate is unchanged by default; set `SOLID_OIDC_VERIFY_TLS=false` to say so explicitly. +- **Redirects are no longer followed** on OIDC requests. The old transport set `CURLOPT_FOLLOWLOCATION`, which could replay an `Authorization: Basic` client secret against whatever host a `Location` header named. +- **Tokens are out of the logs.** The Solid client was logging the decoded access-token payload and then the whole header set, including `Authorization: DPoP ` and the DPoP proof. +- **`state`, `nonce` and the PKCE verifier now expire.** They were written to Redis with no TTL, so an abandoned sign-in left them readable indefinitely. +- The access token's `cnf.jkt` is checked against the DPoP key held for the identity, so a token bound to a different key fails at sign-in rather than on every subsequent request. + +--- +## Fixes + +- DPoP `htu` strips the query and fragment, as RFC 9449 §4.2 requires. A proof built for a URL with parameters was unusable against a server that compares `htu` strictly. +- RFC 9449 §8 `use_dpop_nonce` is honoured: the token request is retried once with the nonce the provider asks for. +- Provider discovery is cached, per process and in the application cache. Every Solid request built a fresh client and refetched `.well-known/openid-configuration`, so a single controller action touching four resources made four extra round trips. +- The authorization callback reports a provider `error` instead of turning it into "missing authorization code". +- `authenticate()` returns a redirect response rather than calling `header()` and `exit`, so the redirect goes through the framework. +- Dynamic client registration declares `grant_types` including `refresh_token`. Without it a provider defaults to `authorization_code` alone, which made the requested `offline_access` scope unusable. Existing registrations are untouched; this applies to new ones. +- Requested scopes are deduplicated — the authorization request was sending `openid webid offline_access openid`. +- `firebase/php-jwt` is declared. It was already in use but relied on arriving transitively. + +--- +## Dependencies + +- Removed: `jumbojett/openid-connect-php`, all seven `web-token/jwt-*` packages, `php-http/guzzle7-adapter`, `psr/http-factory-implementation`. The `web-token` stack and the PSR adapters it needed had no references anywhere in the extension. +- Added: `firebase/php-jwt ^6.10|^7.0` (shared with Socialite rather than duplicating a JWT stack), `ext-json`, `ext-openssl`. +- `php` raised from `^8.0` to `^8.1`, matching Core API and phpseclib 4. +- Engine dependencies moved to their latest releases: `@fleetbase/ember-core` `^0.3.24`, `@fleetbase/ember-ui` `^0.4.3`, `@fleetbase/fleetops-data` `^0.2.2`. CI builds on Node 22. + +--- +## Also fixed + +Four defects that predate this release, found while auditing the OIDC code: + +- **`PodService` resolved a class that does not exist.** The commit that removed the CSS-credential approach in favour of OIDC tokens deleted `CssAccountService` but left two `app(CssAccountService::class)` call sites behind, so pod creation and the pod listing threw on entry. Those branches are removed, finishing that change. +- **`getAccountIndex()` ended in `dd()`** on the live `GET solid/int/v1/account` route, halting the request and dumping the raw pod response. It returns JSON now. Seven more routes pointed at controller methods that do not exist (`play`, `getProfileData` and the five `sync-*` actions) and would 500 on hit; nothing calls them, so they are removed. Every routed method now resolves. +- **The admin server-configuration UI had no effect.** `saveServerConfig()` wrote `system.solid.server` and `SolidClient` never read it, so changing the Solid host or port in the console changed nothing. Resolution is now explicit option → saved setting → config default. Two bugs in the same lines went with it: a caller-supplied host was silently discarded, and `new SolidClient([])` raised a `TypeError`. +- **`composer test` failed in all three stages.** It passes now: `test:unit` is back on Pest through a runner that works around the package's custom `vendor-dir`, `test:types` is clean at phpstan `level: max` against a committed baseline, and the six long-unformatted files are formatted. CI enforces all three, and `Run Lint` is now the dry-run form — it previously ran php-cs-fixer in *fix* mode, so it could never fail. + +Coverage is now generated, uploaded to Codecov and shown as a README badge: 25.45% overall, with the code added here at 81–100%. + +--- +## Tests + +The suite went from one placeholder test to **108 tests / 208 assertions**, covering ID token verification and its failure modes, the full authorization and callback flow, `state` and `nonce` handling, DPoP proof structure and key storage, JWKS caching and rotation, and dynamic client registration. Verified on PHP 8.2 and 8.4. + +The suite had been disabled in CI because Pest's binary resolves its autoloader from a hardcoded `vendor/`, which this package does not have — it sets `vendor-dir` to `server_vendor` — so `pest` could not start here at all. `composer test:unit` now goes through `scripts/pest-runner.php`, adopted from storefront, which bridges that for the duration of the run. CI runs the tests again, plus static analysis, lint and coverage. + +--- +## Upgrading + +- Root `composer.json`: `"fleetbase/solid-api": "^0.0.8"`. A `^0.0.7` constraint will not pick this up. +- `exchangeCodeForTokens($code, $state)` now requires the `state` from the callback. The signature is unchanged, but a missing one throws — without it there is no CSRF control on the callback. +- `Jumbojett\OpenIDConnectClientException` is replaced by `Fleetbase\Solid\Exceptions\OpenIDConnectClientException`, same name and same `\Exception` parent. +- If `FILESYSTEM_DRIVER` points at S3 or GCS, check that bucket for existing `solid/dpop_keys_*.json` objects and delete them. The automatic migration only covers the disk the application is currently configured with. diff --git a/composer.json b/composer.json index 3084940..190465a 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/solid-api", - "version": "0.0.7", + "version": "0.0.8", "description": "Solid Protocol Extension to Store and Share Data with Fleetbase", "keywords": [ "fleetbase-extension", @@ -27,21 +27,14 @@ } ], "require": { - "php": "^8.0", + "php": "^8.1", + "ext-json": "*", + "ext-openssl": "*", + "easyrdf/easyrdf": "^1.1", + "firebase/php-jwt": "^6.10|^7.0", "fleetbase/core-api": "*", "fleetbase/fleetops-api": "*", - "php-http/guzzle7-adapter": "^1.0", - "psr/http-factory-implementation": "*", - "jumbojett/openid-connect-php": "^1.0.2", - "easyrdf/easyrdf": "^1.1", - "ml/json-ld": "^1.2", - "web-token/jwt-core": "^3.0", - "web-token/jwt-key-mgmt": "^3.0", - "web-token/jwt-signature": "^3.0", - "web-token/jwt-checker": "^3.0", - "web-token/jwt-signature-algorithm-hmac": "^3.0", - "web-token/jwt-signature-algorithm-ecdsa": "^3.0", - "web-token/jwt-signature-algorithm-rsa": "^3.0" + "ml/json-ld": "^1.2" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.34.1", @@ -78,15 +71,21 @@ "sort-packages": true, "preferred-install": "dist", "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true + "pestphp/pest-plugin": true } }, "scripts": { "lint": "php-cs-fixer fix -v", "test:lint": "php-cs-fixer fix -v --dry-run", - "test:types": "phpstan analyse --ansi --memory-limit=0", - "test:unit": "pest --colors=always", + "test:types": "phpstan analyse --ansi --memory-limit=-1", + "test:unit": "php scripts/pest-runner.php --colors=always server/tests", + "test:coverage": "php scripts/coverage-runner.php --coverage --coverage-text --colors=always", + "test:coverage:clover": "mkdir -p coverage && php scripts/coverage-runner.php --coverage-clover=coverage/clover.xml --colors=always", + "coverage:summary": "php scripts/coverage-summary.php coverage/clover.xml", + "coverage:baseline": [ + "@test:coverage:clover", + "@coverage:summary" + ], "test": [ "@test:lint", "@test:types", diff --git a/extension.json b/extension.json index 61ea0e9..63a001c 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Solid", - "version": "0.0.7", + "version": "0.0.8", "description": "Solid Protocol Extension to Store and Share Data with Fleetbase", "repository": "https://github.com/fleetbase/solid", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index c7f4fec..e8e693f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/solid-engine", - "version": "0.0.7", + "version": "0.0.8", "description": "Solid Protocol Extension to Store and Share Data with Fleetbase", "fleetbase": { "route": "solid-protocol" @@ -45,9 +45,9 @@ }, "dependencies": { "@babel/core": "^7.23.2", - "@fleetbase/ember-core": "^0.3.10", - "@fleetbase/ember-ui": "^0.3.17", - "@fleetbase/fleetops-data": "^0.1.25", + "@fleetbase/ember-core": "^0.3.24", + "@fleetbase/ember-ui": "^0.4.3", + "@fleetbase/fleetops-data": "^0.2.2", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", "@fortawesome/free-brands-svg-icons": "6.4.0", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..7295376 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,816 @@ +parameters: + ignoreErrors: + - + message: "#^Call to an undefined static method Illuminate\\\\Support\\\\Facades\\\\Redis\\:\\:del\\(\\)\\.$#" + count: 1 + path: server/src/Auth/RedisStateStore.php + + - + message: "#^Call to an undefined static method Illuminate\\\\Support\\\\Facades\\\\Redis\\:\\:get\\(\\)\\.$#" + count: 1 + path: server/src/Auth/RedisStateStore.php + + - + message: "#^Call to an undefined static method Illuminate\\\\Support\\\\Facades\\\\Redis\\:\\:set\\(\\)\\.$#" + count: 1 + path: server/src/Auth/RedisStateStore.php + + - + message: "#^Call to an undefined static method Illuminate\\\\Support\\\\Facades\\\\Redis\\:\\:setex\\(\\)\\.$#" + count: 1 + path: server/src/Auth/RedisStateStore.php + + - + message: "#^Access to an undefined property Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:\\$uuid\\.$#" + count: 8 + path: server/src/Client/OpenIDConnectClient.php + + - + message: "#^Cannot cast mixed to int\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Cannot cast mixed to string\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Constant Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:DEFAULT_MIME_TYPE is unused\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Constant Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:LDP_BASIC_CONTAINER is unused\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Constant Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:LDP_RESOURCE is unused\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Constant Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:OIDC_ISSUER is unused\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Function app not found\\.$#" + count: 2 + path: server/src/Client/SolidClient.php + + - + message: "#^Function config not found\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:__construct\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:authenticatedRequest\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:authenticatedRequest\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:create\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:createRequestUrl\\(\\) should return string but returns string\\|null\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:delete\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:delete\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:get\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:get\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:patch\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:patch\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:post\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:post\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:put\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:put\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:request\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:request\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:requestWithIdentity\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Client\\\\SolidClient\\:\\:requestWithIdentity\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Parameter \\#1 \\$haystack of static method Illuminate\\\\Support\\\\Str\\:\\:startsWith\\(\\) expects string, string\\|null given\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Unsafe usage of new static\\(\\)\\.$#" + count: 1 + path: server/src/Client/SolidClient.php + + - + message: "#^Function app not found\\.$#" + count: 2 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Function response not found\\.$#" + count: 19 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\DataController\\:\\:createFolder\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\DataController\\:\\:deleteItem\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\DataController\\:\\:importResources\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\DataController\\:\\:index\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\DataController\\:\\:showFolder\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Negated boolean expression is always false\\.$#" + count: 5 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Parameter \\#1 \\$string of function trim expects string, mixed given\\.$#" + count: 1 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Parameter \\#3 \\$folderName of method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:createFolder\\(\\) expects string, mixed given\\.$#" + count: 1 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Parameter \\#3 \\$resourceTypes of method Fleetbase\\\\Solid\\\\Services\\\\ResourceSyncService\\:\\:importResources\\(\\) expects array, mixed given\\.$#" + count: 1 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Part \\$folderName \\(mixed\\) of encapsed string cannot be cast to string\\.$#" + count: 1 + path: server/src/Http/Controllers/DataController.php + + - + message: "#^Call to an undefined static method Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:where\\(\\)\\.$#" + count: 1 + path: server/src/Http/Controllers/OIDCController.php + + - + message: "#^Function redirect not found\\.$#" + count: 2 + path: server/src/Http/Controllers/OIDCController.php + + - + message: "#^Access to an undefined property Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:\\$created_at\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Access to an undefined property Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:\\$id\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Access to an undefined property Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:\\$identifier\\.$#" + count: 2 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Call to an undefined method Fleetbase\\\\Http\\\\Requests\\\\AdminRequest\\:\\:array\\(\\)\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Call to an undefined static method Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:where\\(\\)\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Function config not found\\.$#" + count: 3 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Function redirect not found\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Function response not found\\.$#" + count: 16 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^If condition is always true\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\SolidController\\:\\:authenticate\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\SolidController\\:\\:getAccountIndex\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\SolidController\\:\\:getAuthenticationStatus\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\SolidController\\:\\:getPods\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\SolidController\\:\\:getServerConfig\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\SolidController\\:\\:hello\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\SolidController\\:\\:logout\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\SolidController\\:\\:parseContainers\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\SolidController\\:\\:requestAuthentication\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\SolidController\\:\\:saveServerConfig\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Negated boolean expression is always false\\.$#" + count: 2 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Parameter \\#1 \\$url of method Fleetbase\\\\Solid\\\\Http\\\\Controllers\\\\SolidController\\:\\:extractPodName\\(\\) expects string, mixed given\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Parameter \\#2 \\$uri of method Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:request\\(\\) expects string, mixed given\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, mixed given\\.$#" + count: 1 + path: server/src/Http/Controllers/SolidController.php + + - + message: "#^Access to an undefined property Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:\\$identifier\\.$#" + count: 1 + path: server/src/Models/SolidIdentity.php + + - + message: "#^Call to an undefined static method Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:firstOrCreate\\(\\)\\.$#" + count: 1 + path: server/src/Models/SolidIdentity.php + + - + message: "#^Call to an undefined static method Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:where\\(\\)\\.$#" + count: 2 + path: server/src/Models/SolidIdentity.php + + - + message: "#^Function session not found\\.$#" + count: 4 + path: server/src/Models/SolidIdentity.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:getRedirectUri\\(\\) has parameter \\$query with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Models/SolidIdentity.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:request\\(\\) has no return type specified\\.$#" + count: 1 + path: server/src/Models/SolidIdentity.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:request\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Models/SolidIdentity.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:request\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Models/SolidIdentity.php + + - + message: "#^PHPDoc type array of property Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:\\$fillable is not covariant with PHPDoc type array\\ of overridden property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$fillable\\.$#" + count: 1 + path: server/src/Models/SolidIdentity.php + + - + message: "#^Property Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:\\$casts type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Models/SolidIdentity.php + + - + message: "#^Property Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:\\$fillable type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Models/SolidIdentity.php + + - + message: "#^Property Fleetbase\\\\Solid\\\\Providers\\\\SolidServiceProvider\\:\\:\\$observers type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Providers/SolidServiceProvider.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\AclService\\:\\:findWritableLocations\\(\\) has parameter \\$profile with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/AclService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\AclService\\:\\:findWritableLocations\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/AclService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:\\$webid\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Call to method firstWhere\\(\\) on an unknown class IlluminateAgnostic\\\\StrAgnostic\\\\Str\\\\Support\\\\Collection\\.$#" + count: 2 + path: server/src/Services/PodService.php + + - + message: "#^Cannot access offset 'url' on mixed\\.$#" + count: 2 + path: server/src/Services/PodService.php + + - + message: "#^Function app not found\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Function now not found\\.$#" + count: 5 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:createPod\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:createPodInStorage\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:extractResourceMetadata\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:getContainers\\(\\) has parameter \\$storageLocations with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:getContainers\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:getPodContents\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:getProfileData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:getUserPods\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:parseContainerContents\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:parseContainerUrls\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:parsePodData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\PodService\\:\\:parseProfile\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Offset 'path' does not exist on array\\{scheme\\: string, host\\: string, port\\?\\: int\\<0, 65535\\>, user\\?\\: string, pass\\?\\: string, path\\?\\: string, query\\?\\: string, fragment\\?\\: string\\}\\.$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Parameter \\#2 \\$uri of method Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:request\\(\\) expects string, mixed given\\.$#" + count: 2 + path: server/src/Services/PodService.php + + - + message: "#^Undefined variable\\: \\$folderUrl$#" + count: 1 + path: server/src/Services/PodService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\Solid\\\\Models\\\\SolidIdentity\\:\\:\\$webid\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Cannot access property \\$public_id on mixed\\.$#" + count: 2 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Function app not found\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Function auth not found\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Function session not found\\.$#" + count: 2 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\ResourceSyncService\\:\\:convertToRDF\\(\\) has parameter \\$resource with no type specified\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\ResourceSyncService\\:\\:formatRDFValue\\(\\) has parameter \\$value with no type specified\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\ResourceSyncService\\:\\:getFleetopsResources\\(\\) should return Illuminate\\\\Support\\\\Collection but returns Illuminate\\\\Support\\\\Collection\\|IlluminateAgnostic\\\\StrAgnostic\\\\Str\\\\Support\\\\Collection\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\ResourceSyncService\\:\\:getResourceProperties\\(\\) has parameter \\$resource with no type specified\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\ResourceSyncService\\:\\:getResourceProperties\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\ResourceSyncService\\:\\:importResourceType\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\ResourceSyncService\\:\\:importResources\\(\\) has parameter \\$resourceTypes with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\ResourceSyncService\\:\\:importResources\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Variable \\$resources in empty\\(\\) always exists and is not falsy\\.$#" + count: 1 + path: server/src/Services/ResourceSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$created_at\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$driver\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$location\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$make\\.$#" + count: 2 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$model\\.$#" + count: 2 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$online\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$plate_number\\.$#" + count: 3 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$status\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$trim\\.$#" + count: 2 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$type\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$updated_at\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$uuid\\.$#" + count: 5 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$vendor\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$vin\\.$#" + count: 2 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle\\:\\:\\$year\\.$#" + count: 2 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$driver\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$make\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$model\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$plate_number\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$status\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$updated_at\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$uuid\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$vendor\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$vin\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$year\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Cannot access property \\$uuid on mixed\\.$#" + count: 3 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Cannot call method map\\(\\) on array\\\\|Illuminate\\\\Database\\\\Eloquent\\\\Collection\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Dead catch \\- Throwable is never thrown in the try block\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Function now not found\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Function session not found\\.$#" + count: 2 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\VehicleSyncService\\:\\:getAvailableVehicles\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\VehicleSyncService\\:\\:getSyncStatus\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\VehicleSyncService\\:\\:syncVehiclesToPod\\(\\) has parameter \\$vehicleIds with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Services\\\\VehicleSyncService\\:\\:syncVehiclesToPod\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Parameter \\#1 \\$vehicle of method Fleetbase\\\\Solid\\\\Services\\\\VehicleSyncService\\:\\:getVehicleDisplayName\\(\\) expects Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle, Illuminate\\\\Database\\\\Eloquent\\\\Model given\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Parameter \\#3 \\$vehicle of method Fleetbase\\\\Solid\\\\Services\\\\VehicleSyncService\\:\\:syncSingleVehicle\\(\\) expects Fleetbase\\\\FleetOps\\\\Models\\\\Vehicle, mixed given\\.$#" + count: 1 + path: server/src/Services/VehicleSyncService.php + + - + message: "#^Deprecated in PHP 8\\.0\\: Required parameter \\$key follows optional parameter \\$data\\.$#" + count: 1 + path: server/src/Support/Utils.php + + - + message: "#^Deprecated in PHP 8\\.0\\: Required parameter \\$value follows optional parameter \\$data\\.$#" + count: 1 + path: server/src/Support/Utils.php + + - + message: "#^Method Fleetbase\\\\Solid\\\\Support\\\\Utils\\:\\:searchPods\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: server/src/Support/Utils.php + + - + message: "#^Parameter \\#1 \\$data of static method Fleetbase\\\\Solid\\\\Support\\\\Utils\\:\\:searchPods\\(\\) expects array, mixed given\\.$#" + count: 1 + path: server/src/Support/Utils.php + + - + message: "#^Parameter \\#1 \\$string of function strtolower expects string, mixed given\\.$#" + count: 1 + path: server/src/Support/Utils.php + + - + message: "#^Parameter \\#1 \\$string1 of function strcasecmp expects string, mixed given\\.$#" + count: 1 + path: server/src/Support/Utils.php + + - + message: "#^Function config not found\\.$#" + count: 2 + path: server/src/routes.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 082eb27..27f5644 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,3 +1,16 @@ +includes: + # Errors that predate this package having any static analysis at all: Laravel + # symbols phpstan cannot discover without larastan (facade __callStatic, + # Eloquent magic properties, global helpers) plus untyped legacy signatures. + # larastan is not an option here -- it needs laravel/framework, and this + # package requires only the illuminate/* components it uses. + # + # The baseline exists so `composer test:types` is a gate rather than a + # permanently red command: new code is held to level max, and anything in it + # that gets fixed can be regenerated out with + # `phpstan analyse --generate-baseline=phpstan-baseline.neon`. + - phpstan-baseline.neon + parameters: level: max paths: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 57d2719..2ed232e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,16 +1,20 @@ - ./server/tests + + ./server/tests - - + + ./server/src - - + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e76a6b5..d6f57c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^7.23.2 version: 7.28.5 '@fleetbase/ember-core': - specifier: ^0.3.10 - version: 0.3.10(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(eslint@8.57.1)(webpack@5.104.1) + specifier: ^0.3.24 + version: 0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(eslint@8.57.1)(webpack@5.104.1) '@fleetbase/ember-ui': - specifier: ^0.3.17 - version: 0.3.17(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(@glimmer/component@1.1.2(@babel/core@7.28.5))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(postcss@8.5.6)(rollup@2.79.2)(tracked-built-ins@3.4.0(@babel/core@7.28.5))(webpack@5.104.1) + specifier: ^0.4.3 + version: 0.4.3(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(@glimmer/component@1.1.2(@babel/core@7.28.5))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(postcss@8.5.6)(rollup@2.79.2)(tracked-built-ins@3.4.0(@babel/core@7.28.5))(webpack@5.104.1) '@fleetbase/fleetops-data': - specifier: ^0.1.25 - version: 0.1.25(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(eslint@8.57.1)(webpack@5.104.1) + specifier: ^0.2.2 + version: 0.2.2(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(eslint@8.57.1)(webpack@5.104.1) '@fortawesome/ember-fontawesome': specifier: ^2.0.0 version: 2.0.0(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(rollup@2.79.2)(webpack@5.104.1) @@ -1290,22 +1290,25 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@event-calendar/core@5.14.1': + resolution: {integrity: sha512-sHsoHsxFcBGYj8FtYMM3kl9LrSSraSZTlkkkc1Nal0s8y0RJTw4qVfLIrwawC8YGsej5CQgFMB6JbGQVSFtv9Q==} + '@fleetbase/ember-accounting@0.0.1': resolution: {integrity: sha512-61WGQ/VtmkEloBfdNEd83C9E57axiBXbBPdXAbaS3dsCBpKmqwPo1CkrYUN7vVa3oUP9ZRouVVVffbE0YDnAng==} engines: {node: '>= 18'} peerDependencies: ember-source: '>= 4.0.0' - '@fleetbase/ember-core@0.3.10': - resolution: {integrity: sha512-qPavjAd/WuRiu1E3KbJ58NUMv1elpkvTQZ51X1XeN2W2P4m9Q2bubrwv8TrOpUOOI15lIaG8optlbHMxeCNzig==} + '@fleetbase/ember-core@0.3.24': + resolution: {integrity: sha512-WG32JlX4S75ofa34RQEMxFEqc20fTv2Qn3A5+NlqXtL0lfnCthdO+a1DrGqIIierthHl3mVTxhe+z63f8sM6EA==} engines: {node: '>= 18'} - '@fleetbase/ember-ui@0.3.17': - resolution: {integrity: sha512-hRPa+QAo/Sx7oDmyzw2AGmNw6T4GBJnAe4/bJ3O/ZCm/nsdhsmre4CuY+sK3CagC4cYWii3iOtEf4AHNEB86Og==} + '@fleetbase/ember-ui@0.4.3': + resolution: {integrity: sha512-A8JgpgtfomGl0a2D0eZFSlqMThLzPaEnGyeA2PK7zDWpmshHKTNwRX0hm9MWGLgbtlULQyfQBRetM0X9XSauwA==} engines: {node: '>= 18'} - '@fleetbase/fleetops-data@0.1.25': - resolution: {integrity: sha512-uCX/qB4ANDGNN+EM1vdsVc4inprGEwj1dT0G5OTYKsFaHL3CWOeXsOg8qSa5EDClqxIodadx6stB+dSwrhYowg==} + '@fleetbase/fleetops-data@0.2.2': + resolution: {integrity: sha512-HDgcGpJAk8q+sSmV070nCh02o4S0kuFCvwrf+Ltb+IrUkQ2RhDL/w8gdrqM65HrgbgWxQnslIeqzKfF6pV4/DA==} engines: {node: '>= 18'} '@floating-ui/core@1.7.3': @@ -1517,6 +1520,9 @@ packages: resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} engines: {node: '>=18'} + '@interactjs/types@1.10.28': + resolution: {integrity: sha512-vPmu4HWmsg0Fub3BPKGk7DuozkhgVK84TVkziY47v8Uh0A14gph55/vVRaQN4oZov0lGNJNK6Jyddim2qd/4vw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1537,6 +1543,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -1628,6 +1637,11 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} + peerDependencies: + acorn: ^8.9.0 + '@szmarczak/http-timer@1.1.2': resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==} engines: {node: '>=6'} @@ -1853,6 +1867,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@4.19.7': resolution: {integrity: sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==} @@ -2089,6 +2106,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + ag-channel@5.0.0: resolution: {integrity: sha512-bArHkdqQxynim981t8FLZM5TfA0v7p081OlFdOxs6clB79GSGcGlOQMDa31DT9F5VMjzqNiJmhfGwinvfU/3Zg==} @@ -2225,6 +2247,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2346,6 +2372,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + babel-code-frame@6.26.0: resolution: {integrity: sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g==} @@ -3010,6 +3040,10 @@ packages: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + code-error-fragment@0.0.230: resolution: {integrity: sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw==} engines: {node: '>= 4'} @@ -3592,6 +3626,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -4393,6 +4430,9 @@ packages: deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + esm@3.2.25: resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} engines: {node: '>=6'} @@ -4415,6 +4455,14 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} + esrap@2.3.9: + resolution: {integrity: sha512-wshgAMVu4xdUeXi07YDnzim4ddkg8rx2yDTW3WyvskWkDdCYa4+jUMLaHXEO1e1+dmrqhrwbGsgRjQqzGJkqJQ==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -5233,6 +5281,9 @@ packages: resolution: {integrity: sha512-pFGGdaHrmRKMh4WoDDSowddgjT1Vkl90atobmTeSmcPGdYiwikch/m/Ef5wRaiamHejtw0cUUMMerzDUXCci2w==} engines: {node: '>=18'} + interactjs@1.10.28: + resolution: {integrity: sha512-QCa4ksTPd2p/FZ4I6hrH6fogjLqru1TxYywRiKYNuLZtrlAxTIYUa/MYsE7PnUgWZJU3SrGVDjiz065BeaXYng==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -5426,6 +5477,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -5703,6 +5757,9 @@ packages: loader.js@4.7.0: resolution: {integrity: sha512-9M2KvGT6duzGMgkOcTkWb+PR/Q2Oe54df/tLgHGVmFpAmtqJ553xJh6N63iFYI2yjo2PeJXbS5skHi/QpJq4vA==} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@2.0.0: resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==} engines: {node: '>=4'} @@ -7797,6 +7854,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte@5.57.1: + resolution: {integrity: sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==} + engines: {node: '>=18'} + svg-tags@1.0.0: resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} @@ -8181,6 +8242,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache@2.4.0: @@ -8432,6 +8494,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zimmerframe@1.1.5: + resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -9826,6 +9891,12 @@ snapshots: '@eslint/js@8.57.1': {} + '@event-calendar/core@5.14.1': + dependencies: + svelte: 5.57.1 + transitivePeerDependencies: + - '@typescript-eslint/types' + '@fleetbase/ember-accounting@0.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))': dependencies: '@babel/core': 7.28.5 @@ -9835,7 +9906,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@fleetbase/ember-core@0.3.10(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(eslint@8.57.1)(webpack@5.104.1)': + '@fleetbase/ember-core@0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(eslint@8.57.1)(webpack@5.104.1)': dependencies: '@babel/core': 7.28.5 compress-json: 3.4.0 @@ -9868,13 +9939,14 @@ snapshots: - utf-8-validate - webpack - '@fleetbase/ember-ui@0.3.17(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(@glimmer/component@1.1.2(@babel/core@7.28.5))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(postcss@8.5.6)(rollup@2.79.2)(tracked-built-ins@3.4.0(@babel/core@7.28.5))(webpack@5.104.1)': + '@fleetbase/ember-ui@0.4.3(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(@glimmer/component@1.1.2(@babel/core@7.28.5))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(postcss@8.5.6)(rollup@2.79.2)(tracked-built-ins@3.4.0(@babel/core@7.28.5))(webpack@5.104.1)': dependencies: '@babel/core': 7.28.5 '@ember/render-modifiers': 2.1.0(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)) '@ember/string': 3.1.1 '@embroider/addon': 0.30.0 '@embroider/macros': 1.19.5 + '@event-calendar/core': 5.14.1 '@fleetbase/ember-accounting': 0.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)) '@floating-ui/dom': 1.7.4 '@fortawesome/ember-fontawesome': 2.0.0(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(rollup@2.79.2)(webpack@5.104.1) @@ -9942,6 +10014,7 @@ snapshots: ember-wormhole: 0.6.1 gridstack: 7.3.0 imask: 6.6.3 + interactjs: 1.10.28 intl-tel-input: 22.0.2 leaflet: 1.9.4 postcss-at-rules-variables: 0.3.0(postcss@8.5.6) @@ -9957,6 +10030,7 @@ snapshots: - '@glimmer/tracking' - '@glint/environment-ember-loose' - '@glint/template' + - '@typescript-eslint/types' - ember-cli-mirage - ember-resolver - ember-source @@ -9971,10 +10045,10 @@ snapshots: - webpack-command - yaml - '@fleetbase/fleetops-data@0.1.25(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(eslint@8.57.1)(webpack@5.104.1)': + '@fleetbase/fleetops-data@0.2.2(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(eslint@8.57.1)(webpack@5.104.1)': dependencies: '@babel/core': 7.28.5 - '@fleetbase/ember-core': 0.3.10(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(eslint@8.57.1)(webpack@5.104.1) + '@fleetbase/ember-core': 0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.28.5)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.104.1))(eslint@8.57.1)(webpack@5.104.1) date-fns: 2.30.0 ember-cli-babel: 8.2.0(@babel/core@7.28.5) ember-cli-htmlbars: 6.3.0 @@ -10335,6 +10409,8 @@ snapshots: '@inquirer/figures@1.0.15': {} + '@interactjs/types@1.10.28': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -10363,6 +10439,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -10462,6 +10540,10 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@sveltejs/acorn-typescript@1.0.13(acorn@8.18.0)': + dependencies: + acorn: 8.18.0 + '@szmarczak/http-timer@1.1.2': dependencies: defer-to-connect: 1.1.3 @@ -10703,6 +10785,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/express-serve-static-core@4.19.7': dependencies: '@types/node': 25.0.3 @@ -10732,7 +10816,7 @@ snapshots: '@types/glob@9.0.0': dependencies: - glob: 8.1.0 + glob: 10.5.0 '@types/http-errors@2.0.5': {} @@ -10757,7 +10841,7 @@ snapshots: '@types/minimatch@6.0.0': dependencies: - minimatch: 7.4.6 + minimatch: 9.0.5 '@types/minimist@1.2.5': {} @@ -11013,6 +11097,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.18.0: {} + ag-channel@5.0.0: dependencies: consumable-stream: 2.0.0 @@ -11132,6 +11218,8 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.1: {} + aria-query@5.3.2: {} arr-diff@4.0.0: {} @@ -11257,6 +11345,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axobject-query@4.1.0: {} + babel-code-frame@6.26.0: dependencies: chalk: 1.1.3 @@ -12457,6 +12547,8 @@ snapshots: clone@2.1.2: {} + clsx@2.1.1: {} + code-error-fragment@0.0.230: {} collection-visit@1.0.0: @@ -12883,6 +12975,8 @@ snapshots: detect-newline@3.1.0: {} + devalue@5.9.4: {} + didyoumean@1.2.2: {} diff@5.2.0: {} @@ -14658,6 +14752,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm-env@1.2.2: {} + esm@3.2.25: {} espree@9.6.1: @@ -14674,6 +14770,10 @@ snapshots: dependencies: estraverse: 5.3.0 + esrap@2.3.9: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -15758,6 +15858,10 @@ snapshots: transitivePeerDependencies: - '@types/node' + interactjs@1.10.28: + dependencies: + '@interactjs/types': 1.10.28 + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -15938,6 +16042,10 @@ snapshots: is-plain-object@5.0.0: {} + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -16193,6 +16301,8 @@ snapshots: loader.js@4.7.0: {} + locate-character@3.0.0: {} + locate-path@2.0.0: dependencies: p-locate: 2.0.0 @@ -16367,7 +16477,7 @@ snapshots: md5.js@1.3.5: dependencies: - hash-base: 3.0.5 + hash-base: 3.1.2 inherits: 2.0.4 safe-buffer: 5.2.1 @@ -18555,6 +18665,26 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte@5.57.1: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.6.0 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.18.0) + '@types/estree': 1.0.9 + acorn: 8.18.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.9.4 + esm-env: 1.2.2 + esrap: 2.3.9 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.5 + transitivePeerDependencies: + - '@typescript-eslint/types' + svg-tags@1.0.0: {} symlink-or-copy@1.3.1: {} @@ -19390,3 +19520,5 @@ snapshots: yocto-queue@1.2.2: {} yoctocolors-cjs@2.1.3: {} + + zimmerframe@1.1.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..15e69ae --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +allowBuilds: + '@fortawesome/fontawesome-common-types': false + '@fortawesome/fontawesome-svg-core': false + '@fortawesome/free-brands-svg-icons': false + '@fortawesome/free-solid-svg-icons': false + core-js: false + fsevents: false +minimumReleaseAge: 0 +packages: [] \ No newline at end of file diff --git a/scripts/coverage-runner.php b/scripts/coverage-runner.php new file mode 100644 index 0000000..3f630c6 --- /dev/null +++ b/scripts/coverage-runner.php @@ -0,0 +1,51 @@ +\n"); + exit(1); +} + +$pestRunner = getcwd() . '/scripts/pest-runner.php'; +if (!is_file($pestRunner)) { + fwrite(STDERR, "Unable to find Pest runner at scripts/pest-runner.php.\n"); + exit(1); +} + +$hasCoverageExtension = extension_loaded('xdebug') || extension_loaded('pcov'); + +if (!$hasCoverageExtension) { + fwrite(STDERR, "No PHP coverage driver is available.\n\n"); + fwrite(STDERR, "Install or enable one of:\n"); + fwrite(STDERR, " - Xdebug with XDEBUG_MODE=coverage\n"); + fwrite(STDERR, " - PCOV\n"); + fwrite(STDERR, "\n"); + fwrite(STDERR, 'Current PHP binary: ' . PHP_BINARY . "\n"); + exit(1); +} + +putenv('XDEBUG_MODE=coverage'); +$_ENV['XDEBUG_MODE'] = 'coverage'; +$_SERVER['XDEBUG_MODE'] = 'coverage'; + +$hasTestTarget = false; +foreach ($args as $arg) { + if ($arg !== '' && $arg[0] !== '-') { + $hasTestTarget = true; + break; + } +} + +if (!$hasTestTarget && is_dir(getcwd() . '/server/tests')) { + $args[] = 'server/tests'; +} + +$command = array_merge([PHP_BINARY, $pestRunner], $args); +$escapedCommand = implode(' ', array_map('escapeshellarg', $command)); + +passthru($escapedCommand, $exitCode); + +exit($exitCode); diff --git a/scripts/coverage-summary.php b/scripts/coverage-summary.php new file mode 100644 index 0000000..caf6b2c --- /dev/null +++ b/scripts/coverage-summary.php @@ -0,0 +1,131 @@ +metrics[$name] ?? 0); +} + +$project = $xml->project; +$metrics = $project->metrics; + +$statements = (int) ($metrics['statements'] ?? 0); +$coveredStatements = (int) ($metrics['coveredstatements'] ?? 0); +$methods = (int) ($metrics['methods'] ?? 0); +$coveredMethods = (int) ($metrics['coveredmethods'] ?? 0); +$classes = (int) ($metrics['classes'] ?? 0); +$coveredClasses = (int) ($metrics['coveredclasses'] ?? 0); + +$files = []; +$directories = []; +$classNodes = $project->xpath('.//class') ?: []; + +if ($classNodes !== []) { + $classes = 0; + $coveredClasses = 0; + + foreach ($classNodes as $classNode) { + $classStatements = intMetric($classNode, 'statements'); + $coveredClassStatements = intMetric($classNode, 'coveredstatements'); + + if ($classStatements === 0) { + continue; + } + + $classes++; + + if ($coveredClassStatements === $classStatements) { + $coveredClasses++; + } + } +} + +foreach ($project->xpath('.//file') ?: [] as $file) { + $path = (string) $file['name']; + $fileStatements = intMetric($file, 'statements'); + $coveredFileLines = intMetric($file, 'coveredstatements'); + $fileMethods = intMetric($file, 'methods'); + $coveredFileMethod = intMetric($file, 'coveredmethods'); + + if ($fileStatements === 0) { + continue; + } + + $files[] = [ + 'path' => $path, + 'covered' => $coveredFileLines, + 'statements' => $fileStatements, + 'methods' => $fileMethods, + 'covered_methods' => $coveredFileMethod, + 'percent' => coveragePercent($coveredFileLines, $fileStatements), + ]; + + $relativePath = preg_replace('#^' . preg_quote(getcwd(), '#') . '/?#', '', $path); + $parts = explode('/', $relativePath ?: $path); + $directory = count($parts) > 2 ? $parts[0] . '/' . $parts[1] : dirname($relativePath ?: $path); + + if (!isset($directories[$directory])) { + $directories[$directory] = [ + 'covered' => 0, + 'statements' => 0, + ]; + } + + $directories[$directory]['covered'] += $coveredFileLines; + $directories[$directory]['statements'] += $fileStatements; +} + +usort($files, function (array $a, array $b): int { + return $a['percent'] <=> $b['percent'] + ?: $b['statements'] <=> $a['statements']; +}); + +$directoryRows = []; +foreach ($directories as $directory => $directoryMetrics) { + $directoryRows[] = [ + 'directory' => $directory, + 'covered' => $directoryMetrics['covered'], + 'statements' => $directoryMetrics['statements'], + 'percent' => coveragePercent($directoryMetrics['covered'], $directoryMetrics['statements']), + ]; +} + +usort($directoryRows, function (array $a, array $b): int { + return $a['percent'] <=> $b['percent'] + ?: $b['statements'] <=> $a['statements']; +}); + +printf("Line coverage: %.2f%% (%d/%d statements)\n", coveragePercent($coveredStatements, $statements), $coveredStatements, $statements); +printf("Method coverage: %.2f%% (%d/%d methods)\n", coveragePercent($coveredMethods, $methods), $coveredMethods, $methods); +printf("Class coverage: %.2f%% (%d/%d classes)\n", coveragePercent($coveredClasses, $classes), $coveredClasses, $classes); + +echo "\nLowest covered directories:\n"; +foreach (array_slice($directoryRows, 0, 10) as $row) { + printf(" %6.2f%% %5d/%-5d %s\n", $row['percent'], $row['covered'], $row['statements'], $row['directory']); +} + +echo "\nLowest covered files:\n"; +foreach (array_slice($files, 0, 20) as $file) { + $relativePath = preg_replace('#^' . preg_quote(getcwd(), '#') . '/?#', '', $file['path']); + printf(" %6.2f%% %5d/%-5d %s\n", $file['percent'], $file['covered'], $file['statements'], $relativePath ?: $file['path']); +} diff --git a/scripts/pest-bootstrap.php b/scripts/pest-bootstrap.php new file mode 100644 index 0000000..bf09ff2 --- /dev/null +++ b/scripts/pest-bootstrap.php @@ -0,0 +1,23 @@ + [ 'version' => '0.0.1', 'routing' => [ - 'prefix' => 'solid', - 'internal_prefix' => 'int' + 'prefix' => 'solid', + 'internal_prefix' => 'int', ], ], + 'server' => [ - 'host' => env('SOLID_HOST', 'http://solid'), - 'port' => (int) env('SOLID_PORT', 3000), - 'secure' => (bool) env('SOLID_SECURE', false) - ] + 'host' => env('SOLID_HOST', 'http://solid'), + 'port' => (int) env('SOLID_PORT', 3000), + 'secure' => (bool) env('SOLID_SECURE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Solid-OIDC + |-------------------------------------------------------------------------- + | + | Settings for the Solid-OIDC handshake in Fleetbase\Solid\Client\OpenIDConnectClient. + | Everything here has a safe default; an operator only needs to touch these + | when the identity provider is reached differently from the data API, or + | when running against a development server with a self-signed certificate. + | + */ + 'oidc' => [ + /* + | Where `.well-known/openid-configuration` is fetched from. Null means + | "the Solid server URL above", which is the normal single-host setup. + | Set it when the handshake goes through a different origin than the API + | — for example HTTPS via a proxy for OIDC while API calls stay on the + | internal HTTP address. The issuer every ID token is then checked + | against is whatever the discovery document itself reports, never this + | value. + */ + 'issuer' => env('SOLID_OIDC_ISSUER'), + + /* + | TLS peer verification for discovery, registration, the token endpoint + | and the JWKS document. + | + | This is a single explicit flag rather than a check on the app + | environment. The previous implementation disabled verification + | whenever APP_ENV was `local` or `development`, which silently also + | covered any staging environment named that way. The default preserves + | the old behaviour for local development while making it impossible for + | verification to be off in production without someone saying so. + */ + 'verify_tls' => env( + 'SOLID_OIDC_VERIFY_TLS', + !in_array(env('APP_ENV', 'production'), ['local', 'development'], true) + ), + + /* + | How long an in-flight authorization request stays valid, in seconds. + | The `state`, `nonce` and PKCE verifier are all stored for this long and + | then expire on their own — an abandoned sign-in must not leave them + | readable indefinitely. + */ + 'state_ttl' => (int) env('SOLID_OIDC_STATE_TTL', 600), + + /* + | How long a dynamic client registration is kept, in seconds. 0 means it + | never expires, which is the default: a registration has to outlive any + | single sign-in, and re-registering on every login would leave orphaned + | clients behind on the provider. Logging out clears it explicitly. + */ + 'client_credentials_ttl' => (int) env('SOLID_OIDC_CLIENT_CREDENTIALS_TTL', 0), + + /* + | How long the provider's JWKS and discovery documents are cached. Both + | are short so that a provider rotating a signing key cannot lock users + | out for more than a few minutes; an ID token naming an unknown key also + | forces one immediate refetch regardless of this value. + */ + 'jwks_cache_ttl' => (int) env('SOLID_OIDC_JWKS_CACHE_TTL', 300), + 'discovery_cache_ttl' => (int) env('SOLID_OIDC_DISCOVERY_CACHE_TTL', 300), + + /* + | Clock skew tolerated on an ID token's exp, iat and nbf claims, in + | seconds. Keep this small: it is the window in which an expired token is + | still accepted. + */ + 'leeway' => (int) env('SOLID_OIDC_LEEWAY', 60), + + /* + | Reject an ID token that does not echo the nonce we sent. Providers + | built on node-oidc-provider — including Community Solid Server — always + | echo it, so this stays on. It exists only for a provider that violates + | OIDC Core 3.1.3.7, and turning it off removes a replay control. + */ + 'require_nonce' => (bool) env('SOLID_OIDC_REQUIRE_NONCE', true), + + /* + | Request timeout for OIDC HTTP calls, in seconds. + */ + 'timeout' => (int) env('SOLID_OIDC_TIMEOUT', 15), + + /* + | The filesystem disk that holds each identity's DPoP private key. + | + | Named explicitly, and never `filesystems.default`: in a Fleetbase + | install that default is the `public` disk — rooted at + | storage/app/public, symlinked to public/storage and served over HTTP — + | or an S3/GCS bucket. An earlier version of this extension wrote the + | keys there; a key found in the old location is moved here automatically + | on first use and the exposed copy is deleted. + | + | Whatever disk is named must not be reachable from the internet. + */ + 'key_disk' => env('SOLID_OIDC_KEY_DISK', 'local'), + ], ]; diff --git a/server/src/Auth/CachedJwksResolver.php b/server/src/Auth/CachedJwksResolver.php new file mode 100644 index 0000000..f7e395d --- /dev/null +++ b/server/src/Auth/CachedJwksResolver.php @@ -0,0 +1,56 @@ + + */ + public function resolve(string $jwksUri, bool $forceRefresh = false): array + { + $cacheKey = 'solid:oidc:jwks:' . sha1($jwksUri); + + if ($forceRefresh) { + Cache::forget($cacheKey); + } + + $cached = Cache::get($cacheKey); + + if (is_array($cached)) { + return $cached; + } + + $jwks = $this->transport->get($jwksUri)->array('the provider jwks_uri'); + + if ($this->ttlSeconds > 0) { + Cache::put($cacheKey, $jwks, $this->ttlSeconds); + } + + return $jwks; + } +} diff --git a/server/src/Auth/Contracts/JwksResolver.php b/server/src/Auth/Contracts/JwksResolver.php new file mode 100644 index 0000000..9e15592 --- /dev/null +++ b/server/src/Auth/Contracts/JwksResolver.php @@ -0,0 +1,26 @@ + + * + * @throws \Fleetbase\Solid\Exceptions\OpenIDConnectClientException + */ + public function resolve(string $jwksUri, bool $forceRefresh = false): array; +} diff --git a/server/src/Auth/Contracts/OidcResponse.php b/server/src/Auth/Contracts/OidcResponse.php new file mode 100644 index 0000000..f97b2f4 --- /dev/null +++ b/server/src/Auth/Contracts/OidcResponse.php @@ -0,0 +1,86 @@ + header names lower-cased for lookup + */ + private array $headers; + + /** + * @param array> $headers + */ + public function __construct( + public readonly int $status, + public readonly string $body, + array $headers = [], + ) { + $normalized = []; + + foreach ($headers as $name => $value) { + $normalized[strtolower($name)] = is_array($value) ? (string) ($value[0] ?? '') : $value; + } + + $this->headers = $normalized; + } + + public function successful(): bool + { + return $this->status >= 200 && $this->status < 300; + } + + /** + * A response header, matched case-insensitively as HTTP requires. + */ + public function header(string $name): ?string + { + $value = $this->headers[strtolower($name)] ?? null; + + return $value === '' ? null : $value; + } + + /** + * The body decoded as a JSON object. + * + * @throws OpenIDConnectClientException when the body is not a JSON object + */ + public function object(string $context): \stdClass + { + $decoded = json_decode($this->body, false); + + if (!$decoded instanceof \stdClass) { + throw new OpenIDConnectClientException('Expected a JSON object from ' . $context . '.'); + } + + return $decoded; + } + + /** + * The body decoded as a JSON object, as an array. + * + * @return array + * + * @throws OpenIDConnectClientException when the body is not a JSON object + */ + public function array(string $context): array + { + $decoded = json_decode($this->body, true); + + if (!is_array($decoded)) { + throw new OpenIDConnectClientException('Expected a JSON object from ' . $context . '.'); + } + + return $decoded; + } +} diff --git a/server/src/Auth/Contracts/OidcStateStore.php b/server/src/Auth/Contracts/OidcStateStore.php new file mode 100644 index 0000000..05d46b0 --- /dev/null +++ b/server/src/Auth/Contracts/OidcStateStore.php @@ -0,0 +1,38 @@ + $headers + * + * @throws \Fleetbase\Solid\Exceptions\OpenIDConnectClientException on a transport failure + */ + public function get(string $url, array $headers = []): OidcResponse; + + /** + * @param array $body + * @param array $headers + * + * @throws \Fleetbase\Solid\Exceptions\OpenIDConnectClientException on a transport failure + */ + public function postJson(string $url, array $body, array $headers = []): OidcResponse; + + /** + * An `application/x-www-form-urlencoded` POST, as the token endpoint requires. + * + * @param array $fields + * @param array $headers + * + * @throws \Fleetbase\Solid\Exceptions\OpenIDConnectClientException on a transport failure + */ + public function postForm(string $url, array $fields, array $headers = []): OidcResponse; +} diff --git a/server/src/Auth/DPoPKeyPair.php b/server/src/Auth/DPoPKeyPair.php new file mode 100644 index 0000000..e243454 --- /dev/null +++ b/server/src/Auth/DPoPKeyPair.php @@ -0,0 +1,225 @@ + 'sha256', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($resource === false) { + throw new OpenIDConnectClientException('Unable to generate a DPoP key pair.'); + } + + $privateKeyPem = ''; + + if (!openssl_pkey_export($resource, $privateKeyPem)) { + throw new OpenIDConnectClientException('Unable to export the DPoP private key.'); + } + + $details = openssl_pkey_get_details($resource); + + if (!is_array($details) || !isset($details['key'], $details['rsa']['n'], $details['rsa']['e'])) { + throw new OpenIDConnectClientException('Unable to read the generated DPoP public key.'); + } + + return new self($privateKeyPem, (string) $details['key'], [ + 'kty' => 'RSA', + 'n' => self::base64UrlEncode((string) $details['rsa']['n']), + 'e' => self::base64UrlEncode((string) $details['rsa']['e']), + ]); + } + + /** + * Rehydrate a key pair previously produced by `toArray()`. + * + * Returns null rather than throwing for anything it does not recognise: a + * truncated or older stored shape should cause a fresh key to be generated, + * not a failed request. + * + * @param array|null $stored + */ + public static function fromArray(?array $stored): ?self + { + $privateKeyPem = $stored['private_key'] ?? null; + $publicKeyPem = $stored['public_key'] ?? null; + $publicJwk = $stored['public_jwk'] ?? null; + + if (!is_string($privateKeyPem) || $privateKeyPem === '' || !is_array($publicJwk)) { + return null; + } + + if (!isset($publicJwk['kty'], $publicJwk['n'], $publicJwk['e'])) { + return null; + } + + return new self( + $privateKeyPem, + is_string($publicKeyPem) ? $publicKeyPem : '', + [ + 'kty' => (string) $publicJwk['kty'], + 'n' => (string) $publicJwk['n'], + 'e' => (string) $publicJwk['e'], + ] + ); + } + + /** + * The persistable form. Contains the private key, so the caller must store it + * somewhere only the application can read. + * + * @return array{private_key: string, public_key: string, public_jwk: array{kty: string, n: string, e: string}} + */ + public function toArray(): array + { + return [ + 'private_key' => $this->privateKeyPem, + 'public_key' => $this->publicKeyPem, + 'public_jwk' => $this->publicJwk, + ]; + } + + /** + * @return array{kty: string, n: string, e: string} + */ + public function publicJwk(): array + { + return $this->publicJwk; + } + + /** + * The RFC 7638 JWK thumbprint of the public key. + * + * This is the value a Solid server puts in the access token's `cnf.jkt` + * claim. Comparing the two is how we detect that a token was bound to a + * different key than the one we hold — see + * `OpenIDConnectClient::assertTokenBoundToDPoPKey()`. + */ + public function thumbprint(): string + { + // RFC 7638 requires the required members only, lexicographically ordered, + // with no whitespace: for RSA that is exactly e, kty, n. + $canonical = json_encode([ + 'e' => $this->publicJwk['e'], + 'kty' => $this->publicJwk['kty'], + 'n' => $this->publicJwk['n'], + ], JSON_UNESCAPED_SLASHES); + + return self::base64UrlEncode(hash('sha256', (string) $canonical, true)); + } + + /** + * Sign a DPoP proof for one request. + * + * @param string $method the HTTP method the proof is bound to + * @param string $url the HTTP URI the proof is bound to; query and + * fragment are stripped, as RFC 9449 §4.2 requires + * @param string|null $accessToken when present, adds the `ath` claim binding the + * proof to that access token. Omitted for the + * token endpoint, where no access token exists yet. + * @param string|null $nonce a server-supplied DPoP nonce, echoed back when + * the provider demands one + * + * @throws OpenIDConnectClientException + */ + public function proof(string $method, string $url, ?string $accessToken = null, ?string $nonce = null): string + { + $header = [ + 'typ' => 'dpop+jwt', + 'alg' => self::ALGORITHM, + 'jwk' => $this->publicJwk, + ]; + + $payload = [ + 'jti' => bin2hex(random_bytes(16)), + 'htm' => strtoupper($method), + 'htu' => self::normalizeHtu($url), + 'iat' => time(), + ]; + + if (is_string($accessToken) && $accessToken !== '') { + $payload['ath'] = self::base64UrlEncode(hash('sha256', $accessToken, true)); + } + + if (is_string($nonce) && $nonce !== '') { + $payload['nonce'] = $nonce; + } + + $signingInput = self::base64UrlEncode((string) json_encode($header)) + . '.' . self::base64UrlEncode((string) json_encode($payload)); + + $privateKey = openssl_pkey_get_private($this->privateKeyPem); + + if ($privateKey === false) { + throw new OpenIDConnectClientException('Unable to load the DPoP private key.'); + } + + $signature = ''; + + if (!openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { + throw new OpenIDConnectClientException('Unable to sign the DPoP proof.'); + } + + return $signingInput . '.' . self::base64UrlEncode($signature); + } + + /** + * RFC 9449 §4.2: `htu` is the request URI without query or fragment. + * + * Leaving a query string in place makes the proof unusable for any request + * whose parameters differ, which is how the previous implementation broke + * against servers that compare `htu` strictly. + */ + private static function normalizeHtu(string $url): string + { + return explode('?', explode('#', $url, 2)[0], 2)[0]; + } + + private static function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/server/src/Auth/HttpOidcTransport.php b/server/src/Auth/HttpOidcTransport.php new file mode 100644 index 0000000..a65f963 --- /dev/null +++ b/server/src/Auth/HttpOidcTransport.php @@ -0,0 +1,84 @@ +send('GET', $url, fn (PendingRequest $request) => $request->get($url), $headers); + } + + public function postJson(string $url, array $body, array $headers = []): OidcResponse + { + return $this->send('POST', $url, fn (PendingRequest $request) => $request->asJson()->post($url, $body), $headers); + } + + public function postForm(string $url, array $fields, array $headers = []): OidcResponse + { + return $this->send('POST', $url, fn (PendingRequest $request) => $request->asForm()->post($url, $fields), $headers); + } + + /** + * @param array $headers + * @param \Closure(PendingRequest): \Illuminate\Http\Client\Response $dispatch + * + * @throws OpenIDConnectClientException + */ + private function send(string $method, string $url, \Closure $dispatch, array $headers): OidcResponse + { + $request = Http::withHeaders($headers) + ->withOptions([ + // A 3xx from a token or registration endpoint is a protocol error, + // not something to chase: following it would replay the request — + // and its credentials — against whatever host the Location names. + 'allow_redirects' => false, + 'verify' => $this->verifyTls, + ]) + ->timeout($this->timeout) + ->connectTimeout($this->connectTimeout); + + try { + $response = $dispatch($request); + } catch (\Throwable $e) { + throw new OpenIDConnectClientException(sprintf('%s %s failed: %s', $method, self::redact($url), $e->getMessage()), 0, $e); + } + + return new OidcResponse($response->status(), $response->body(), $response->headers()); + } + + /** + * Endpoint URLs are safe to log, but a query string on one is not worth the + * risk of carrying a code or token into an exception message. + */ + private static function redact(string $url): string + { + return explode('?', $url, 2)[0]; + } +} diff --git a/server/src/Auth/RedisStateStore.php b/server/src/Auth/RedisStateStore.php new file mode 100644 index 0000000..9bdb46a --- /dev/null +++ b/server/src/Auth/RedisStateStore.php @@ -0,0 +1,47 @@ +prefix . $key); + + return is_string($value) ? $value : null; + } + + public function put(string $key, string $value, int $ttlSeconds): void + { + if ($ttlSeconds <= 0) { + // No expiry. Used for a dynamic client registration, which has to + // survive between sign-ins and is removed explicitly on logout. + Redis::set($this->prefix . $key, $value); + + return; + } + + // SETEX rather than SET followed by EXPIRE: applying the TTL in a second + // command leaves a window in which the key would never expire. + Redis::setex($this->prefix . $key, $ttlSeconds, $value); + } + + public function forget(string $key): void + { + Redis::del($this->prefix . $key); + } +} diff --git a/server/src/Auth/SolidIdTokenVerifier.php b/server/src/Auth/SolidIdTokenVerifier.php new file mode 100644 index 0000000..38ac258 --- /dev/null +++ b/server/src/Auth/SolidIdTokenVerifier.php @@ -0,0 +1,305 @@ + + */ + public const DEFAULT_ALGORITHMS = ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512']; + + /** + * @param array $allowedAlgorithms + * @param int $leewaySeconds clock skew tolerated on exp/iat/nbf + */ + public function __construct( + private readonly JwksResolver $jwks, + private readonly array $allowedAlgorithms = self::DEFAULT_ALGORITHMS, + private readonly int $leewaySeconds = 60, + private readonly bool $requireNonce = true, + ) { + } + + /** + * Verify an ID token against a provider and return its claims. + * + * @param string $idToken the raw compact JWS + * @param string $jwksUri the provider's `jwks_uri`, from discovery + * @param string $issuer the exact issuer the token must name; discovery's + * `issuer`, not a value taken from the token + * @param string $clientId the audience the token must be addressed to + * @param string|null $expectedNonce the nonce sent on the authorization request, + * or null when this token did not originate + * from one (e.g. a refresh) + * + * @return array the verified claims + * + * @throws OpenIDConnectClientException + */ + public function verify(string $idToken, string $jwksUri, string $issuer, string $clientId, ?string $expectedNonce = null): array + { + if ($idToken === '') { + throw new OpenIDConnectClientException('The token response carried no id_token.'); + } + + if ($issuer === '') { + throw new OpenIDConnectClientException('Refusing to verify an id_token without a known issuer.'); + } + + // An empty client id would make the audience check vacuous. + if ($clientId === '') { + throw new OpenIDConnectClientException('Refusing to verify an id_token without a registered client id.'); + } + + $keyId = $this->keyIdFrom($idToken); + $claims = $this->decode($idToken, $jwksUri, $keyId); + + $this->assertIssuer($claims, $issuer); + $this->assertAudience($claims, $clientId); + $this->assertSubject($claims); + $this->assertNonce($claims, $expectedNonce); + + return $claims; + } + + /** + * Read the `kid` out of the (still unverified) JWS header. + * + * Nothing is trusted from this beyond picking which published key to check + * the signature with — a forged `kid` simply fails to resolve. + * + * @throws OpenIDConnectClientException + */ + private function keyIdFrom(string $idToken): string + { + $segments = explode('.', $idToken); + + if (count($segments) !== 3) { + throw new OpenIDConnectClientException('The id_token is not a compact JWS.'); + } + + $header = json_decode((string) self::base64UrlDecode($segments[0]), true); + + if (!is_array($header)) { + throw new OpenIDConnectClientException('The id_token header could not be decoded.'); + } + + $algorithm = $header['alg'] ?? null; + + // Rejected here as well as by php-jwt so the log names the real reason. + if (!is_string($algorithm) || !in_array($algorithm, $this->allowedAlgorithms, true)) { + throw new OpenIDConnectClientException('The id_token is signed with an unsupported algorithm.'); + } + + $keyId = $header['kid'] ?? null; + + if (!is_string($keyId) || $keyId === '') { + throw new OpenIDConnectClientException('The id_token header carries no kid.'); + } + + return $keyId; + } + + /** + * Check the signature and the time-based claims. + * + * A `kid` missing from the cached JWKS is indistinguishable from a rotated + * key, so the document is refetched once before the token is rejected. This + * mirrors the cache-busting in Core API's `IdTokenVerifier::resolvePublicKey()`. + * + * @return array + * + * @throws OpenIDConnectClientException + */ + private function decode(string $idToken, string $jwksUri, string $keyId): array + { + $keys = $this->parseKeySet($this->jwks->resolve($jwksUri)); + + if (!isset($keys[$keyId])) { + $keys = $this->parseKeySet($this->jwks->resolve($jwksUri, true)); + } + + if (!isset($keys[$keyId])) { + throw new OpenIDConnectClientException('The id_token names a signing key the provider does not publish.'); + } + + // Applies to exp, iat and nbf inside php-jwt. Static rather than injected + // because php-jwt exposes it only as a static property. + JWT::$leeway = $this->leewaySeconds; + + try { + $claims = JWT::decode($idToken, $keys[$keyId]); + } catch (\Throwable $e) { + // Signature, expiry or structure. Which one is an operator detail, and + // the token itself is deliberately kept out of the message. + throw new OpenIDConnectClientException('The id_token failed verification: ' . $e->getMessage(), 0, $e); + } + + // php-jwt hands back a stdClass tree; re-decoding is the least surprising + // way to get plain arrays all the way down. + $decoded = json_decode((string) json_encode($claims), true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $jwks + * + * @return array + * + * @throws OpenIDConnectClientException + */ + private function parseKeySet(array $jwks): array + { + try { + // No default algorithm is supplied: a key that does not declare `alg` + // is dropped rather than assumed to be RS256. Core API passes 'RS256' + // here because Microsoft publishes keys without one; Solid providers + // built on node-oidc-provider always declare it, and guessing would + // reopen the algorithm-confusion hole this class exists to close. + return JWK::parseKeySet($jwks); + } catch (\Throwable $e) { + throw new OpenIDConnectClientException('The provider JWKS could not be read: ' . $e->getMessage(), 0, $e); + } + } + + /** + * @param array $claims + * + * @throws OpenIDConnectClientException + */ + private function assertIssuer(array $claims, string $issuer): void + { + $tokenIssuer = $claims['iss'] ?? null; + + // Exact comparison. Solid has no multi-tenant issuer pattern of the kind + // that forces Core API to take a predicate here. + if (!is_string($tokenIssuer) || !hash_equals($issuer, $tokenIssuer)) { + throw new OpenIDConnectClientException('The id_token was issued by an unexpected issuer.'); + } + } + + /** + * @param array $claims + * + * @throws OpenIDConnectClientException + */ + private function assertAudience(array $claims, string $clientId): void + { + $audience = $claims['aud'] ?? null; + $audiences = is_array($audience) ? $audience : [$audience]; + $matched = false; + + foreach ($audiences as $candidate) { + if (is_string($candidate) && hash_equals($clientId, $candidate)) { + $matched = true; + break; + } + } + + if (!$matched) { + throw new OpenIDConnectClientException('The id_token is not addressed to this client.'); + } + + // OIDC Core 3.1.3.7: when `aud` names more than one party, `azp` must be + // present and must be us. Without this a token minted for another client + // that happens to list us as an audience would be accepted. + if (count($audiences) > 1) { + $authorizedParty = $claims['azp'] ?? null; + + if (!is_string($authorizedParty) || !hash_equals($clientId, $authorizedParty)) { + throw new OpenIDConnectClientException('The id_token has multiple audiences and no azp naming this client.'); + } + } + } + + /** + * @param array $claims + * + * @throws OpenIDConnectClientException + */ + private function assertSubject(array $claims): void + { + $subject = $claims['sub'] ?? null; + + if (!is_string($subject) || $subject === '') { + throw new OpenIDConnectClientException('The id_token carries no subject.'); + } + } + + /** + * @param array $claims + * + * @throws OpenIDConnectClientException + */ + private function assertNonce(array $claims, ?string $expectedNonce): void + { + if ($expectedNonce === null || $expectedNonce === '') { + // No nonce was sent, so there is nothing to bind the token to. Only + // reachable for flows that do not start at an authorization request. + return; + } + + $tokenNonce = $claims['nonce'] ?? null; + + if (!is_string($tokenNonce) || $tokenNonce === '') { + if (!$this->requireNonce) { + return; + } + + throw new OpenIDConnectClientException('The id_token echoed no nonce for an authorization request that sent one.'); + } + + if (!hash_equals($expectedNonce, $tokenNonce)) { + throw new OpenIDConnectClientException('The id_token nonce does not match this authorization request.'); + } + } + + private static function base64UrlDecode(string $value): string + { + return (string) base64_decode(strtr($value, '-_', '+/'), true); + } +} diff --git a/server/src/Client/OpenIDConnectClient.php b/server/src/Client/OpenIDConnectClient.php index b98b024..7860ce6 100644 --- a/server/src/Client/OpenIDConnectClient.php +++ b/server/src/Client/OpenIDConnectClient.php @@ -2,851 +2,1299 @@ namespace Fleetbase\Solid\Client; -use Firebase\JWT\JWK; -use Firebase\JWT\JWT; +use Fleetbase\Solid\Auth\CachedJwksResolver; +use Fleetbase\Solid\Auth\Contracts\OidcResponse; +use Fleetbase\Solid\Auth\Contracts\OidcStateStore; +use Fleetbase\Solid\Auth\Contracts\OidcTransport; +use Fleetbase\Solid\Auth\DPoPKeyPair; +use Fleetbase\Solid\Auth\HttpOidcTransport; +use Fleetbase\Solid\Auth\RedisStateStore; +use Fleetbase\Solid\Auth\SolidIdTokenVerifier; +use Fleetbase\Solid\Exceptions\OpenIDConnectClientException; use Fleetbase\Solid\Models\SolidIdentity; -use Illuminate\Http\Client\Response; +use Illuminate\Container\Container; +use Illuminate\Contracts\Filesystem\Filesystem; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -use Jumbojett\OpenIDConnectClient as BaseOpenIDConnectClient; -use Jumbojett\OpenIDConnectClientException; -const CLIENT_NAME = 'Fleetbase-v2'; // v2: Added webid scope support -final class OpenIDConnectClient extends BaseOpenIDConnectClient +/** + * The name Fleetbase registers itself under with a Solid identity provider. + * + * Part of the Redis key that caches the resulting client credentials, so changing + * it forces every identity to register a fresh client on its next sign-in. + * v2 added the `webid` scope. + */ +const CLIENT_NAME = 'Fleetbase-v2'; + +/** + * A Solid-OIDC relying-party client. + * + * ## Why this is not a library + * + * This class used to extend `Jumbojett\OpenIDConnectClient`, which pinned + * `phpseclib/phpseclib ^3.0.7` and so could not be installed beside Core API's + * `laravel/socialite ^5.31` (phpseclib ^4.0). The pin bought nothing: Jumbojett + * touches phpseclib in exactly one method, `verifyRSAJWTSignature()`, which this + * class already overrode away — and the callback path never reached Jumbojett's + * verification at all. Meanwhile almost everything else was overridden too + * (transport, token request, registration, session storage, discovery), so about + * 150 lines of a 2,100-line dependency were actually in use. + * + * Solid-OIDC also needs three things no general-purpose OAuth client offers, and + * that Socialite in particular does not: RFC 7591 dynamic client registration, + * RFC 9449 DPoP-bound tokens, and a per-tenant issuer discovered at runtime. All + * three were already hand-written here. So the library was removed rather than + * replaced, and what remains reuses Core API's *verification* conventions + * (see `Fleetbase\Solid\Auth\SolidIdTokenVerifier`) rather than its transport. + * + * ## What changed for callers + * + * - `authenticate()` returns a `RedirectResponse` instead of calling + * `header()` + `exit`, so the redirect now goes through Laravel. + * - `exchangeCodeForTokens()` requires the `state` from the callback and + * verifies the returned `id_token`. Both were previously skipped entirely. + * + * Every collaborator is injectable through the options array, which is what makes + * the handshake testable without a network or a Redis server. + */ +final class OpenIDConnectClient { + /** + * Scopes requested at registration and at authorization. + * + * `webid` is what makes the provider put the user's WebID in the ID token; + * `offline_access` is what makes it issue a refresh token. + */ + private const SCOPES = ['openid', 'webid', 'offline_access']; + + /** + * PKCE is mandatory for Solid-OIDC, and S256 is the only method accepted. + */ + private const CODE_CHALLENGE_METHOD = 'S256'; + private ?SolidClient $solid; private ?SolidIdentity $identity; - private ?\stdClass $openIdConfig; - private string $code; - private static $dpopKeyPairCache = []; + private OidcTransport $transport; + private OidcStateStore $stateStore; + private SolidIdTokenVerifier $verifier; + + /** + * The discovery document, once fetched. + */ + private ?\stdClass $openIdConfig = null; + + /** + * Endpoint overrides merged on top of discovery, for `providerConfigParam()`. + * + * @var array + */ + private array $providerConfig = []; + + private ?string $providerUrl = null; + private ?string $issuer = null; + private string $clientName = CLIENT_NAME; + private ?string $clientId = null; + private ?string $clientSecret = null; + private ?string $redirectUri = null; + private ?string $code = null; + private string $codeChallengeMethod = self::CODE_CHALLENGE_METHOD; + + /** + * @var array + */ + private array $scopes = self::SCOPES; + + private ?DPoPKeyPair $dpopKeyPair = null; + + private ?\stdClass $tokenResponse = null; + private ?string $idToken = null; + private ?string $accessToken = null; + private ?string $refreshToken = null; + + /** + * Claims from the last ID token this client verified. Empty until a token has + * actually been verified — never populated from an unverified decode. + * + * @var array + */ + private array $verifiedClaims = []; + + /** + * Key pairs are cached per identity for the life of the request so that a + * single Solid operation signing several DPoP proofs does not re-read storage + * for each one. + * + * @var array + */ + private static array $dpopKeyPairCache = []; + + /** + * Discovery documents already fetched in this process, keyed by URL. + * + * @var array + */ + private static array $discoveryCache = []; + + /** + * Drop the process-level discovery and DPoP key memos. + * + * Needed when the Solid server configuration changes inside a long-lived + * worker — Octane keeps the process alive between requests — and by tests + * that script a different provider. + */ + public static function flushProviderCaches(): void + { + self::$discoveryCache = []; + self::$dpopKeyPairCache = []; + } + /** + * @param array $options + */ public function __construct(array $options = []) { - // Call parent constructor to initialize the OIDC client properly - // Pass null for provider_url as we'll set it later in create() - parent::__construct(null, null, null, null); - - // Set the scope during registration - $this->addRegistrationParam(['scope' => 'openid webid offline_access']); - - $this->solid = data_get($options, 'solid'); - $this->identity = data_get($options, 'identity'); - if ($this->identity instanceof SolidIdentity) { - $this->setRedirectURL($this->identity->getRedirectUri()); + $solid = $options['solid'] ?? null; + $this->solid = $solid instanceof SolidClient ? $solid : null; + + $identity = $options['identity'] ?? null; + $this->identity = $identity instanceof SolidIdentity ? $identity : null; + + $transport = $options['transport'] ?? null; + $this->transport = $transport instanceof OidcTransport + ? $transport + : new HttpOidcTransport(self::shouldVerifyTls(), self::timeout()); + + $stateStore = $options['stateStore'] ?? null; + $this->stateStore = $stateStore instanceof OidcStateStore + ? $stateStore + // Keyed on the identity uuid, not its `identifier`: the identifier is + // regenerated by SolidIdentity::initialize(), so state written under it + // would be unreachable after any concurrent re-initialisation. + : new RedisStateStore('oidc:' . ($this->identity->uuid ?? 'default') . ':'); + + $verifier = $options['verifier'] ?? null; + $this->verifier = $verifier instanceof SolidIdTokenVerifier + ? $verifier + : new SolidIdTokenVerifier( + new CachedJwksResolver($this->transport, self::intConfig('solid.oidc.jwks_cache_ttl', CachedJwksResolver::DEFAULT_TTL_SECONDS)), + SolidIdTokenVerifier::DEFAULT_ALGORITHMS, + self::intConfig('solid.oidc.leeway', 60), + (bool) self::config('solid.oidc.require_nonce', true) + ); + + $dpopKeyPair = $options['dpopKeyPair'] ?? null; + if ($dpopKeyPair instanceof DPoPKeyPair) { + $this->dpopKeyPair = $dpopKeyPair; } - $this->setCodeChallengeMethod('S256'); - $this->setClientName(data_get($options, 'clientName', CLIENT_NAME)); - - // Only set client credentials if they are provided - $clientID = data_get($options, 'clientID'); - if ($clientID !== null) { - $this->setClientID($clientID); + + if (isset($options['providerUrl']) && is_string($options['providerUrl'])) { + $this->providerUrl = $options['providerUrl']; } - - $clientSecret = data_get($options, 'clientSecret'); - if ($clientSecret !== null) { - $this->setClientSecret($clientSecret); + + if ($this->identity instanceof SolidIdentity) { + $this->redirectUri = $this->identity->getRedirectUri(); } - // Restore client credentials if requested or if identity is provided without clientID - if (isset($options['restore']) || ($this->identity instanceof SolidIdentity && $clientID === null)) { + $this->clientName = self::stringOption($options, 'clientName') ?? CLIENT_NAME; + $this->clientId = self::stringOption($options, 'clientID'); + $this->clientSecret = self::stringOption($options, 'clientSecret'); + + // Restore on request, and also whenever we have an identity but no explicit + // client id — that is the ordinary "resume an existing registration" case. + if (isset($options['restore']) || ($this->identity instanceof SolidIdentity && $this->clientId === null)) { try { - $this->restoreClientCredentials(); - } catch (\Exception $e) { - // Credentials not yet saved, which is fine during initial registration - Log::debug('[OIDC] Client credentials not yet available for restoration', [ + $this->restoreClientCredentials($this->clientName); + } catch (\Throwable $e) { + // Nothing saved yet, which is normal before the first registration. + Log::debug('[Solid OIDC] No saved client credentials to restore.', [ 'identity_uuid' => $this->identity?->uuid, - 'error' => $e->getMessage(), ]); } } } - public static function create(array $options = []): OpenIDConnectClient + /** + * Build a client and load the provider's discovery document. + * + * @param array $options + * + * @throws OpenIDConnectClientException + */ + public static function create(array $options = []): self { - $client = new static($options); - $solid = data_get($options, 'solid'); - - // For OIDC discovery, use the configured OIDC issuer URL - // This allows using HTTPS for OIDC (through nginx) while using HTTP for API calls - if ($solid instanceof \Fleetbase\Solid\Client\SolidClient) { - $oidcIssuer = config('solid.oidc_issuer', $solid->getServerUrl()); - $client->setProviderURL($oidcIssuer); - Log::debug('[OIDC] Using provider URL for discovery', [ - 'provider_url' => $oidcIssuer, - 'config_value' => config('solid.oidc_issuer'), - 'fallback' => $solid->getServerUrl(), - ]); - } - - Log::debug('[OIDC] About to fetch configuration', [ - 'provider_url_before_fetch' => $client->getProviderURL(), - ]); - + $client = new self($options); + + $client->providerUrl = $client->resolveDiscoveryBase(); + $openIdConfig = $client->getOpenIdConfiguration(); - - Log::debug('[OIDC] Received configuration', [ - 'config' => $openIdConfig, - 'has_issuer' => isset($openIdConfig->issuer), - 'issuer' => $openIdConfig->issuer ?? 'NOT SET', - ]); - - if (!isset($openIdConfig->issuer)) { - throw new \Exception('OIDC configuration does not contain issuer property. Response: ' . json_encode($openIdConfig)); + + $issuer = $openIdConfig->issuer ?? null; + + if (!is_string($issuer) || $issuer === '') { + throw new OpenIDConnectClientException('The Solid provider discovery document carries no issuer.'); } - - $client->setProviderURL($openIdConfig->issuer); - $client->setIssuer($openIdConfig->issuer); - $client->providerConfigParam((array) $openIdConfig); + + // From here on the issuer is whatever the provider says it is, and every + // ID token must name exactly that. + $client->providerUrl = $issuer; + $client->issuer = $issuer; return $client; } - - /** - * Override parent fetchURL to disable SSL verification in development. - * - * The parent class uses cURL to make HTTP requests but doesn't disable - * SSL verification, which causes issues with self-signed certificates - * in local development. + * Register Fleetbase as a client with the provider (RFC 7591). + * + * @param array $options + * + * @throws OpenIDConnectClientException */ - protected function fetchURL(string $url, string $post_body = null, array $headers = []) + public function register(array $options = []): self { - $ch = curl_init(); - - if ($post_body !== null) { - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); - curl_setopt($ch, CURLOPT_POSTFIELDS, $post_body); - $content_type = is_object(json_decode($post_body, false)) ? 'application/json' : 'application/x-www-form-urlencoded'; - $headers[] = "Content-Type: $content_type"; - } - - if (count($headers) > 0) { - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - } - - // Disable SSL verification in development only (self-signed certificates) - if (app()->environment('local', 'development')) { - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - } - - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_USERAGENT, $this->getUserAgent()); - - $response = curl_exec($ch); - $error = curl_error($ch); - curl_close($ch); - - if ($error) { - throw new \Exception("Curl error: $error"); + $clientName = self::stringOption($options, 'clientName') ?? $this->clientName; + $requestParams = is_array($options['requestParams'] ?? null) ? $options['requestParams'] : []; + $redirectUri = self::stringOption($options, 'redirectUri') ?? $this->redirectUri ?? ''; + $saveCredentials = (bool) ($options['saveCredentials'] ?? false); + $withCredentials = $options['withCredentials'] ?? null; + + if ($redirectUri === '') { + throw new OpenIDConnectClientException('A redirect URI is required to register a Solid client.'); } - - return $response; - } - public function register(array $options = []): OpenIDConnectClient - { - // Get registration options - $clientName = (string) data_get($options, 'clientName', CLIENT_NAME); - $requestParams = (array) data_get($options, 'requestParams', []); - $requestOptions = (array) data_get($options, 'requestOptions', []); - $redirectUri = (string) data_get($options, 'redirectUri', $this->identity ? $this->identity->getRedirectUri() : null); - $saveCredentials = (bool) data_get($options, 'saveCredentials', false); - $withCredentials = data_get($options, 'withCredentials'); + $openIdConfig = $this->getOpenIdConfiguration(); - // Get OIDC Config and Registration URL - $openIdConfig = $this->getOpenIdConfiguration(); + $issuer = $openIdConfig->issuer ?? null; + + if (is_string($issuer) && $issuer !== '') { + $this->issuer = $issuer; + } - // Setup OIDC Client - $this->setIssuer($openIdConfig->issuer); - $this->providerConfigParam((array) $openIdConfig); - $this->setRedirectURL($redirectUri); + $this->clientName = $clientName; + $this->redirectUri = $redirectUri; - // Get Registration URL - $registrationUrl = $openIdConfig->registration_endpoint; + $registrationEndpoint = $this->stringProviderConfigValue('registration_endpoint'); - // Request registration for Client which should handle authentication - // Include scope in registration to ensure client is allowed to request these scopes - $registrationResponse = $this->solid->post($registrationUrl, [ - 'client_name' => $clientName, + $response = $this->transport->postJson($registrationEndpoint, array_merge([ + 'client_name' => $clientName, 'redirect_uris' => [$redirectUri], - 'scope' => 'openid webid offline_access', - ...$requestParams - ], $requestOptions); - if ($registrationResponse->successful()) { - $clientCredentials = (object) $registrationResponse->json(); - $this->setClientCredentials($clientName, $clientCredentials, $saveCredentials, $withCredentials); - } else { - Log::error('[CLIENT REGISTRATION FAILED]', [ - 'status' => $registrationResponse->status(), - 'body' => $registrationResponse->body(), - 'request_data' => [ - 'client_name' => $clientName, - 'redirect_uris' => [$redirectUri], - 'scope' => 'openid webid offline_access', - ], + 'scope' => implode(' ', $this->scopes), + // Declared explicitly because a provider that defaults `grant_types` to + // `authorization_code` alone will refuse the refresh grant later, which + // silently makes the requested `offline_access` scope useless. + 'grant_types' => ['authorization_code', 'refresh_token'], + 'response_types' => ['code'], + ], $requestParams)); + + if (!$response->successful()) { + Log::error('[Solid OIDC] Dynamic client registration was rejected.', [ + 'status' => $response->status, + 'endpoint' => $registrationEndpoint, ]); - throw new OpenIDConnectClientException('Error registering: Please contact the OpenID Connect provider and obtain a Client ID and Secret directly from them'); + + throw new OpenIDConnectClientException('The Solid provider refused client registration (HTTP ' . $response->status . ').'); + } + + $credentials = $response->object('the client registration endpoint'); + + if (!isset($credentials->client_id) || !is_string($credentials->client_id)) { + throw new OpenIDConnectClientException('The client registration response carried no client_id.'); + } + + $this->applyClientCredentials($credentials); + + if ($saveCredentials) { + $this->saveClientCredentials($clientName, $credentials); + } + + if (is_callable($withCredentials)) { + $withCredentials($credentials); } return $this; } - public function authenticate(): bool + /** + * Begin authorization: mint `state`, `nonce` and a PKCE verifier, then send the + * browser to the provider. + * + * Returns a redirect rather than performing one. Jumbojett's `authenticate()` + * ended in `header()` + `exit`, which bypassed Laravel's response pipeline and + * made the call untestable; the only in-tree caller already returned its result. + * + * @throws OpenIDConnectClientException + */ + public function authenticate(): RedirectResponse { - $this->setCodeChallengeMethod('S256'); - $this->addScope(['openid', 'webid', 'offline_access']); - - Log::info('[AUTHENTICATE]', [ - 'scopes' => $this->getScopes(), - 'note' => 'Scopes will be included in authorization redirect', - ]); - - return parent::authenticate(); + return new RedirectResponse($this->getAuthorizationUrl()); } /** - * Override requestTokens to inject DPoP header for token endpoint. - * - * Note: Per OIDC spec (and Inrupt implementation), scope is sent in the authorization - * request, NOT in the token request. CSS should remember the granted scope. + * The URL the browser must be sent to in order to start authorization. + * + * Has the side effect of storing the single-use `state`, `nonce` and PKCE + * verifier that the callback will be checked against. + * + * @throws OpenIDConnectClientException */ - protected function requestTokens(string $code, array $headers = []) + public function getAuthorizationUrl(): string { - $tokenEndpoint = $this->getProviderConfigValue('token_endpoint'); - $tokenEndpointAuthMethodsSupported = $this->getProviderConfigValue('token_endpoint_auth_methods_supported', ['client_secret_basic']); - - // Create DPoP proof for the token endpoint - $dpop = $this->createDPoP('POST', $tokenEndpoint, null); - $headers[] = 'DPoP: ' . $dpop; - - // Build token request parameters (scope NOT included per OIDC spec - it's in authorization request) - $tokenParams = [ - 'grant_type' => 'authorization_code', - 'code' => $code, - 'redirect_uri' => $this->getRedirectURL(), - 'client_id' => $this->getClientID(), - 'client_secret' => $this->getClientSecret(), - ]; - - Log::info('[REQUEST TOKENS WITH DPOP]', [ - 'token_endpoint' => $tokenEndpoint, - 'expected_scope' => implode(' ', $this->getScopes()), - 'dpop_length' => strlen($dpop), - 'note' => 'Scope sent in authorization request, not token request per OIDC spec', - ]); - - // Handle different authentication methods - $authorizationHeader = null; - if ($this->supportsAuthMethod('client_secret_basic', $tokenEndpointAuthMethodsSupported)) { - $authorizationHeader = 'Authorization: Basic ' . base64_encode(urlencode($this->getClientID()) . ':' . urlencode($this->getClientSecret())); - unset($tokenParams['client_secret'], $tokenParams['client_id']); - } - - // Add PKCE code verifier if using PKCE - $ccm = $this->getCodeChallengeMethod(); - $cv = $this->getCodeVerifier(); - if (!empty($ccm) && !empty($cv)) { - $cs = $this->getClientSecret(); - if (empty($cs)) { - $authorizationHeader = null; - unset($tokenParams['client_secret']); - } - $tokenParams = array_merge($tokenParams, [ - 'client_id' => $this->getClientID(), - 'code_verifier' => $this->getCodeVerifier() - ]); + if ($this->clientId === null || $this->clientId === '') { + throw new OpenIDConnectClientException('A registered client id is required before authorization.'); } - - // Convert token params to string format - $tokenParams = http_build_query($tokenParams, '', '&', $this->encType); - - if (null !== $authorizationHeader) { - $headers[] = $authorizationHeader; - } - - // Make the token request - $rawResponse = $this->fetchURL($tokenEndpoint, $tokenParams, $headers); - $this->tokenResponse = json_decode($rawResponse, false); - - Log::info('[TOKEN RESPONSE FROM CSS]', [ - 'has_access_token' => isset($this->tokenResponse->access_token), - 'has_id_token' => isset($this->tokenResponse->id_token), - 'has_refresh_token' => isset($this->tokenResponse->refresh_token), - 'token_type' => $this->tokenResponse->token_type ?? null, - 'scope_in_response' => $this->tokenResponse->scope ?? null, - 'raw_response_length' => strlen($rawResponse), + + if ($this->redirectUri === null || $this->redirectUri === '') { + throw new OpenIDConnectClientException('A redirect URI is required before authorization.'); + } + + $endpoint = $this->stringProviderConfigValue('authorization_endpoint'); + + $this->assertPkceSupported(); + + $ttl = self::intConfig('solid.oidc.state_ttl', 600); + + $state = self::randomToken(); + $nonce = self::randomToken(); + $codeVerifier = self::randomToken(48); + + $this->stateStore->put('state', $state, $ttl); + $this->stateStore->put('nonce', $nonce, $ttl); + $this->stateStore->put('code_verifier', $codeVerifier, $ttl); + + $params = [ + 'response_type' => 'code', + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUri, + 'scope' => implode(' ', $this->scopes), + 'state' => $state, + 'nonce' => $nonce, + 'code_challenge' => self::codeChallenge($codeVerifier), + 'code_challenge_method' => $this->codeChallengeMethod, + ]; + + Log::info('[Solid OIDC] Redirecting to the provider for authorization.', [ + 'identity_uuid' => $this->identity?->uuid, + 'scopes' => $this->scopes, ]); - - return $this->tokenResponse; + + return $endpoint . (str_contains($endpoint, '?') ? '&' : '?') . http_build_query($params, '', '&', PHP_QUERY_RFC3986); } - private function setClientCredentials(string $clientName = CLIENT_NAME, $clientCredentials, bool $save = false, ?\Closure $callback = null): OpenIDConnectClient + /** + * Complete the handshake: validate `state`, exchange the code, then verify the + * ID token before anything is persisted. + * + * `$state` is nominally optional so the signature stays source-compatible with + * the previous version, but a missing one is now an error: without it there is + * no CSRF protection on the callback at all. + * + * @throws OpenIDConnectClientException + */ + public function exchangeCodeForTokens(string $code, ?string $state = null): \stdClass { - $this->setClientID($clientCredentials->client_id); - $this->setClientName($clientCredentials->client_name); - $this->setClientSecret($clientCredentials->client_secret); + if ($code === '') { + throw new OpenIDConnectClientException('The callback carried no authorization code.'); + } + + $this->assertState($state); - // Save the client credentials - if ($save) { - $this->saveClientCredentials($clientName, $clientCredentials); + $codeVerifier = $this->stateStore->get('code_verifier'); + + if (!is_string($codeVerifier) || $codeVerifier === '') { + throw new OpenIDConnectClientException('The PKCE code verifier for this authorization request has expired.'); } - // Run callback if provided - if (is_callable($callback)) { - $callback($clientCredentials); + $expectedNonce = $this->stateStore->get('nonce'); + + // Always sent by getAuthorizationUrl(), so its absence must fail rather + // than fall through to the verifier, which reads a null expected nonce as + // "this token did not come from an authorization request". + if (!is_string($expectedNonce) || $expectedNonce === '') { + throw new OpenIDConnectClientException('The nonce for this authorization request has expired.'); } - return $this; - } + $this->code = $code; - private function saveClientCredentials(string $clientName = CLIENT_NAME, $clientCredentials): OpenIDConnectClient - { - $key = $this->getClientCredentialsKey($clientName); + $tokenResponse = $this->requestTokens([ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => (string) $this->redirectUri, + 'code_verifier' => $codeVerifier, + ]); - return $this->save($key, $clientCredentials); - } + // Single use, whatever happens next. + $this->stateStore->forget('code_verifier'); + $this->stateStore->forget('nonce'); - public function restoreClientCredentials(string $clientName = CLIENT_NAME, array $overwrite = []): OpenIDConnectClient - { - $key = $this->getClientCredentialsKey($clientName); - $savedClientCredentials = $this->retrieve($key); - if (!$savedClientCredentials) { - throw new \Exception('No saved client credentials to restore.'); + $idToken = $tokenResponse->id_token ?? null; + + if (!is_string($idToken) || $idToken === '') { + throw new OpenIDConnectClientException('The token response carried no id_token; the openid scope was not granted.'); } - $restoredClientCredentials = (object) array_merge((array) $savedClientCredentials, $overwrite); - $this->setClientCredentials($clientName, $restoredClientCredentials); + $this->verifiedClaims = $this->verifyIdToken($idToken, $expectedNonce); - return $this; + $this->captureTokens($tokenResponse, $idToken); + $this->assertTokenBoundToDPoPKey($this->accessToken); + + Log::info('[Solid OIDC] Authorization code exchanged and id_token verified.', [ + 'identity_uuid' => $this->identity?->uuid, + 'has_refresh_token' => $this->refreshToken !== null, + ]); + + return $tokenResponse; } - private function getClientCredentialsKey(string $clientName = CLIENT_NAME): string + /** + * Exchange a refresh token for a new access token. + * + * Kept under Jumbojett's method name so callers that used it keep working. + * There is no `nonce` to check here: an ID token returned from a refresh does + * not correspond to a fresh authorization request. + * + * @throws OpenIDConnectClientException + */ + public function refreshToken(string $refreshToken): \stdClass { - if ($this->identity instanceof SolidIdentity) { - return 'oidc:client_credentials:' . Str::slug($clientName) . ':' . $this->identity->uuid; + if ($refreshToken === '') { + throw new OpenIDConnectClientException('A refresh token is required.'); } - return 'oidc:client_credentials:' . Str::slug($clientName); - } + $tokenResponse = $this->requestTokens([ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'scope' => implode(' ', $this->scopes), + ]); - private function save(string $key, $value): OpenIDConnectClient - { - if (is_object($value) || is_array($value)) { - $value = json_encode($value); + $idToken = $tokenResponse->id_token ?? null; + + if (is_string($idToken) && $idToken !== '') { + $this->verifiedClaims = $this->verifyIdToken($idToken, null); } - Redis::set($key, $value); + $this->captureTokens($tokenResponse, is_string($idToken) ? $idToken : null); + $this->assertTokenBoundToDPoPKey($this->accessToken); - return $this; + return $tokenResponse; } - private function retrieve(string $key) + /** + * POST to the token endpoint with client authentication and a DPoP proof. + * + * Per RFC 9449 §8 a provider may reject the first attempt and hand back a + * nonce it wants included; that is retried once, and only once. + * + * @param array $fields + * + * @throws OpenIDConnectClientException + */ + private function requestTokens(array $fields): \stdClass { - $value = Redis::get($key); - if (Str::isJson($value)) { - $value = (object) json_decode($value); + $endpoint = $this->stringProviderConfigValue('token_endpoint'); + + $response = $this->sendTokenRequest($endpoint, $fields, null); + + $dpopNonce = $response->header('DPoP-Nonce'); + + if (!$response->successful() && $dpopNonce !== null && self::isDPoPNonceError($response)) { + $response = $this->sendTokenRequest($endpoint, $fields, $dpopNonce); } - return $value; - } + $tokenResponse = $response->object('the token endpoint'); - public function getOpenIdConfiguration(?string $key = null) - { - $openIdConfigResponse = $this->solid->get('.well-known/openid-configuration'); - if ($openIdConfigResponse instanceof Response) { - $openIdConfig = (object) $openIdConfigResponse->json(); - if ($key) { - return $openIdConfig->{$key}; - } + if (isset($tokenResponse->error)) { + $description = $tokenResponse->error_description ?? $tokenResponse->error; - $this->openIdConfig = $openIdConfig; + throw new OpenIDConnectClientException('The Solid provider rejected the token request: ' . (is_string($description) ? $description : 'unknown error')); + } - return $openIdConfig; + if (!$response->successful()) { + throw new OpenIDConnectClientException('The token endpoint returned HTTP ' . $response->status . '.'); } - return null; + return $tokenResponse; } - protected function getSessionKey(string $key) + /** + * @param array $fields + * + * @throws OpenIDConnectClientException + */ + private function sendTokenRequest(string $endpoint, array $fields, ?string $dpopNonce): OidcResponse { - $sessionKey = 'oidc:session:' . ($this->identity ? $this->identity->identifier : 'default') . ':' . Str::slug($key); - if (Redis::exists($sessionKey)) { - return $this->retrieve($sessionKey); + $headers = ['DPoP' => $this->dpopKeyPair()->proof('POST', $endpoint, null, $dpopNonce)]; + + $advertised = $this->getProviderConfigValue('token_endpoint_auth_methods_supported', ['client_secret_basic']); + $authMethods = array_values(array_filter(is_array($advertised) ? $advertised : [], 'is_string')); + + $clientId = (string) $this->clientId; + $clientSecret = (string) $this->clientSecret; + + if ($clientSecret !== '' && $this->supportsAuthMethod('client_secret_basic', $authMethods)) { + // RFC 6749 §2.3.1: both halves are form-urlencoded before the colon. + $headers['Authorization'] = 'Basic ' . base64_encode(urlencode($clientId) . ':' . urlencode($clientSecret)); + } else { + $fields['client_id'] = $clientId; + + if ($clientSecret !== '') { + $fields['client_secret'] = $clientSecret; + } + } + + // client_id is always sent alongside PKCE so a public client is identifiable. + if (!isset($fields['client_id']) && isset($fields['code_verifier'])) { + $fields['client_id'] = $clientId; } - return false; + return $this->transport->postForm($endpoint, $fields, $headers); } - protected function setSessionKey(string $key, $value) + private static function isDPoPNonceError(OidcResponse $response): bool { - $sessionKey = 'oidc:session:' . ($this->identity ? $this->identity->identifier : 'default') . ':' . Str::slug($key); - $this->save($sessionKey, $value); + $decoded = json_decode($response->body, false); + + return $decoded instanceof \stdClass && ($decoded->error ?? null) === 'use_dpop_nonce'; } - protected function unsetSessionKey(string $key) + /** + * @return array + * + * @throws OpenIDConnectClientException + */ + private function verifyIdToken(string $idToken, ?string $expectedNonce): array { - $sessionKey = 'oidc:session:' . ($this->identity ? $this->identity->identifier : 'default') . ':' . Str::slug($key); - Redis::del($sessionKey); + return $this->verifier->verify( + $idToken, + $this->stringProviderConfigValue('jwks_uri'), + (string) $this->issuer, + (string) $this->clientId, + $expectedNonce + ); } - protected function getAllSessionKeysWithValues() + /** + * Validate the callback `state` against the value stored when the + * authorization request was built, then consume it. + * + * @throws OpenIDConnectClientException + */ + private function assertState(?string $state): void { - $pattern = 'oidc:session*'; // Pattern to match keys - $keys = []; - $cursor = 0; + if ($state === null || $state === '') { + throw new OpenIDConnectClientException('The callback carried no state parameter.'); + } - do { - // Use the SCAN command to find keys matching the pattern - [$cursor, $results] = Redis::scan($cursor, 'MATCH', $pattern); + $expected = $this->stateStore->get('state'); - foreach ($results as $key) { - // Retrieve each value and add it to the keys array - $keys[$key] = Redis::get($key); - } - } while ($cursor); + if (!is_string($expected) || $expected === '') { + throw new OpenIDConnectClientException('There is no pending authorization request for this identity, or it has expired.'); + } + + // Consumed before the comparison so that a wrong state cannot be retried + // against the same pending request. + $this->stateStore->forget('state'); - return $keys; + if (!hash_equals($expected, $state)) { + // The whole request is now void, so the nonce and verifier go too. + $this->stateStore->forget('nonce'); + $this->stateStore->forget('code_verifier'); + + throw new OpenIDConnectClientException('The callback state does not match the pending authorization request.'); + } } - public function verifyJWTSignature(string $jwt): bool + /** + * Confirm the provider bound the access token to the DPoP key we hold. + * + * A Solid access token is a JWT carrying `cnf.jkt`, the thumbprint of the key + * it is bound to. If that is not our key, every resource request made with the + * token will be refused — better to fail here, with a reason, than to store a + * token that cannot be used. + * + * Opaque access tokens are permitted by OAuth, so a token that is not a JWT is + * skipped rather than rejected. + * + * @throws OpenIDConnectClientException + */ + private function assertTokenBoundToDPoPKey(?string $accessToken): void { - $jwks = json_decode($this->fetchURL($this->getProviderConfigValue('jwks_uri')), true); - if (!is_array($jwks)) { - throw new OpenIDConnectClientException('Error decoding JSON from jwks_uri'); + if (!is_string($accessToken) || $accessToken === '') { + return; } - try { - JWT::decode($jwt, JWK::parseKeySet($jwks)); - } catch (\Exception $e) { - throw new OpenIDConnectClientException('Error decoding JWT: ' . $e->getMessage(), $e->getCode(), $e); + $claims = self::decodeJwtSection($accessToken, 1); + + if (!is_array($claims)) { + return; } - return true; + $confirmation = $claims['cnf'] ?? null; + $thumbprint = is_array($confirmation) ? ($confirmation['jkt'] ?? null) : null; + + if (!is_string($thumbprint) || $thumbprint === '') { + Log::warning('[Solid OIDC] The access token is not DPoP-bound.', [ + 'identity_uuid' => $this->identity?->uuid, + ]); + + return; + } + + if (!hash_equals($this->dpopKeyPair()->thumbprint(), $thumbprint)) { + throw new OpenIDConnectClientException('The access token is bound to a different DPoP key than the one held for this identity.'); + } } /** - * Set the ID token. + * @throws OpenIDConnectClientException */ - public function setIdToken($idToken) + private function captureTokens(\stdClass $tokenResponse, ?string $idToken): void { - $this->idToken = $idToken; + $this->tokenResponse = $tokenResponse; + + if (is_string($idToken)) { + $this->idToken = $idToken; + } + + $accessToken = $tokenResponse->access_token ?? null; + + if (!is_string($accessToken) || $accessToken === '') { + throw new OpenIDConnectClientException('The token response carried no access_token.'); + } + + $this->accessToken = $accessToken; + + $refreshToken = $tokenResponse->refresh_token ?? null; + + if (is_string($refreshToken) && $refreshToken !== '') { + $this->refreshToken = $refreshToken; + } } + /* + |-------------------------------------------------------------------------- + | Discovery and provider configuration + |-------------------------------------------------------------------------- + */ + /** - * Public wrapper for decodeJWT. + * The provider's discovery document, or one value from it. + * + * Cached for the life of the client. It is also cached across requests when + * `solid.oidc.discovery_cache_ttl` is positive — without that, every + * `SolidClient` construction re-fetched it, which meant one extra HTTP round + * trip for every authenticated Solid request. + * + * @return ($key is null ? \stdClass : mixed) + * + * @throws OpenIDConnectClientException */ - public function decodeJWTPublic(string $jwt, int $section = 0) + public function getOpenIdConfiguration(?string $key = null) { - return $this->decodeJWT($jwt, $section); + if ($this->openIdConfig === null) { + $this->openIdConfig = $this->fetchOpenIdConfiguration(); + } + + if ($key !== null) { + if (!property_exists($this->openIdConfig, $key)) { + throw new OpenIDConnectClientException('The Solid provider discovery document has no "' . $key . '".'); + } + + return $this->openIdConfig->{$key}; + } + + return $this->openIdConfig; } - // /** - // * Get WebID from ID token. - // */ - // public function getWebIdFromIdToken(?string $idToken = null): ?string - // { - // $token = $idToken ?? $this->getIdToken(); + /** + * @throws OpenIDConnectClientException + */ + private function fetchOpenIdConfiguration(): \stdClass + { + $url = rtrim($this->resolveDiscoveryBase(), '/') . '/.well-known/openid-configuration'; + + // Memoised per process as well as per client. `SolidIdentity::request()` + // builds a fresh SolidClient — and therefore a fresh OIDC client — for every + // Solid call, so without this a single controller action that touches four + // resources performs four discovery round trips. + if (isset(self::$discoveryCache[$url])) { + return self::$discoveryCache[$url]; + } - // if (!$token) { - // return null; - // } + $decoded = self::cacheRemember( + 'solid:oidc:discovery:' . sha1($url), + self::intConfig('solid.oidc.discovery_cache_ttl', 300), + function () use ($url): array { + $response = $this->transport->get($url); - // try { - // $claims = $this->decodeJWT($token, 1); + if (!$response->successful()) { + throw new OpenIDConnectClientException('Solid provider discovery failed with HTTP ' . $response->status . '.'); + } - // return $claims->sub ?? $claims->webid ?? null; - // } catch (\Exception $e) { - // return null; - // } - // } + return $response->array('the provider discovery endpoint'); + } + ); + + return self::$discoveryCache[$url] = (object) $decoded; + } /** - * Get WebID from ID token with enhanced error handling. + * Where the `.well-known/openid-configuration` document is fetched from. + * + * Defaults to the Solid server URL, which is how this has always behaved. The + * config override exists for deployments that reach OIDC over a different host + * than the data API — for instance HTTPS through a proxy for the handshake + * while API calls stay on the internal HTTP address. + * + * @throws OpenIDConnectClientException */ - public function getWebIdFromIdToken(string $idToken): ?string + private function resolveDiscoveryBase(): string { - $token = $idToken ?? $this->getIdToken(); - if (!$token) { - return null; + $configured = self::config('solid.oidc.issuer') ?? self::config('solid.oidc_issuer'); + + if (is_string($configured) && $configured !== '') { + return $configured; } - try { - Log::info('[EXTRACTING WEBID FROM ID TOKEN]', [ - 'token_length' => strlen($idToken), - ]); + if ($this->providerUrl !== null && $this->providerUrl !== '') { + return $this->providerUrl; + } - $payload = $this->decodeJWT($idToken, 1); - $webId = $payload->webid ?? $payload->sub ?? null; + if ($this->solid instanceof SolidClient) { + return $this->solid->getServerUrl(); + } - Log::info('[WEBID EXTRACTED]', [ - 'webid' => $webId, - 'payload_keys' => array_keys((array) $payload), - ]); + throw new OpenIDConnectClientException('No Solid provider URL is configured.'); + } - return $webId; - } catch (\Throwable $e) { - Log::error('[WEBID EXTRACTION ERROR]', [ - 'error' => $e->getMessage(), - ]); + /** + * Merge explicit endpoint values over the discovery document. + * + * @param array $config + */ + public function providerConfigParam(array $config): self + { + $this->providerConfig = array_merge($this->providerConfig, $config); - return null; - } + return $this; } /** - * Get all claims from ID token. + * @throws OpenIDConnectClientException */ - public function getIdTokenClaims(?string $idToken = null): ?object + public function getProviderConfigValue(string $param, mixed $default = null): mixed { - $token = $idToken ?? $this->getIdToken(); + if (array_key_exists($param, $this->providerConfig)) { + return $this->providerConfig[$param]; + } - if (!$token) { - return null; + $config = $this->getOpenIdConfiguration(); + + if (property_exists($config, $param)) { + return $config->{$param}; } - try { - return $this->decodeJWT($token, 1); - } catch (\Exception $e) { - return null; + if ($default !== null) { + return $default; } + + throw new OpenIDConnectClientException('The Solid provider does not advertise "' . $param . '".'); } /** - * Create DPoP token with proper signing and debugging. + * Fail loudly rather than silently dropping PKCE. + * + * Solid-OIDC requires the extension, so a provider that advertises its + * supported methods and does not list S256 cannot be used. A provider that + * advertises nothing is given the benefit of the doubt: `code_challenge` is + * simply ignored by a server that does not implement PKCE. + * + * @throws OpenIDConnectClientException */ - public function createDPoP(string $method, string $url, ?string $accessToken = null): string + private function assertPkceSupported(): void { - try { - Log::info('[CREATING DPOP TOKEN]', [ - 'method' => strtolower($method), - 'url' => $url, - 'has_access_token' => $accessToken !== null, - ]); + $supported = $this->getProviderConfigValue('code_challenge_methods_supported', []); - // Load (or generate) keypair - $keyPair = $this->getDPoPKeyPair(); - - if (!$keyPair || empty($keyPair['private_key']) || empty($keyPair['public_jwk'])) { - throw new \Exception('DPoP key pair unavailable'); - } + if (is_array($supported) && $supported !== [] && !in_array($this->codeChallengeMethod, $supported, true)) { + throw new OpenIDConnectClientException('The Solid provider does not support the ' . $this->codeChallengeMethod . ' PKCE method.'); + } + } - $header = [ - 'typ' => 'dpop+jwt', - 'alg' => 'RS256', - 'jwk' => $keyPair['public_jwk'], - ]; - - $now = time(); - $jti = bin2hex(random_bytes(16)); - $htm = strtoupper($method); - $htu = $url; - - $payload = [ - 'jti' => $jti, - 'htm' => $htm, - 'htu' => $htu, - 'iat' => $now, - ]; - - // Only include 'ath' when we are binding a proof *for a resource request* or a refresh that already has an access token. - if (!empty($accessToken)) { - $payload['ath'] = self::generateAccessTokenHash($accessToken); - } + /* + |-------------------------------------------------------------------------- + | Client credentials + |-------------------------------------------------------------------------- + */ - // Sign (RS256) with our private key - $privateKey = openssl_pkey_get_private($keyPair['private_key']); - if (!$privateKey) { - throw new \Exception('Failed to load DPoP private key'); - } + private function applyClientCredentials(\stdClass $credentials): void + { + $this->clientId = (string) $credentials->client_id; - $encodedHeader = rtrim(strtr(base64_encode(json_encode($header)), '+/', '-_'), '='); - $encodedPayload = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '='); - $signingInput = $encodedHeader . '.' . $encodedPayload; + if (isset($credentials->client_name) && is_string($credentials->client_name)) { + $this->clientName = $credentials->client_name; + } - $signature = ''; - if (!openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { - throw new \Exception('Failed to sign DPoP proof'); - } + $this->clientSecret = isset($credentials->client_secret) && is_string($credentials->client_secret) + ? $credentials->client_secret + : null; + } - $encodedSignature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '='); - $jwt = $encodedHeader . '.' . $encodedPayload . '.' . $encodedSignature; + private function saveClientCredentials(string $clientName, \stdClass $credentials): void + { + // TTL 0 means "no expiry": a registration must outlive any single sign-in, + // and re-registering on every login would leak clients on the provider. + $this->stateStore->put( + $this->clientCredentialsKey($clientName), + (string) json_encode($credentials), + self::intConfig('solid.oidc.client_credentials_ttl', 0) + ); + } - Log::info('[DPOP TOKEN CREATED]', [ - 'token_length' => strlen($jwt), - 'token_preview' => substr($jwt, 0, 80), - 'has_placeholder'=> false, - ]); - - // Debug: Log full DPoP payload for diagnosis - Log::debug('[DPOP PAYLOAD]', [ - 'jti' => $jti, - 'htm' => $htm, - 'htu' => $htu, - 'iat' => $now, - 'ath' => $payload['ath'] ?? null, - 'has_ath' => isset($payload['ath']), - ]); + /** + * @param array $overwrite + * + * @throws OpenIDConnectClientException + */ + public function restoreClientCredentials(string $clientName = CLIENT_NAME, array $overwrite = []): self + { + $saved = $this->stateStore->get($this->clientCredentialsKey($clientName)); - return $jwt; - } catch (\Throwable $e) { - Log::error('[DPOP ERROR]', ['error' => $e->getMessage()]); - throw $e; + if (!is_string($saved) || $saved === '') { + throw new OpenIDConnectClientException('No saved Solid client credentials to restore.'); + } + + $decoded = json_decode($saved, true); + + if (!is_array($decoded) || !isset($decoded['client_id'])) { + throw new OpenIDConnectClientException('The saved Solid client credentials are unreadable.'); } + + $this->applyClientCredentials((object) array_merge($decoded, $overwrite)); + + return $this; + } + + private function clientCredentialsKey(string $clientName): string + { + return 'client_credentials:' . Str::slug($clientName); } /** - * Generate or load DPoP key pair. + * Discard the registration and the DPoP key for this identity. + * + * Both are dropped together on purpose: the access tokens the caller is + * abandoning are bound to that key, so keeping it serves no purpose, and + * keeping the registration without the key would produce tokens we could not + * prove possession for. */ - private function getDPoPKeyPair(): ?array + public function clearClientCredentials(): void { - $cacheKey = $this->getDPoPCacheKey(); - - if (isset(self::$dpopKeyPairCache[$cacheKey])) { - return self::$dpopKeyPairCache[$cacheKey]; - } + $this->stateStore->forget($this->clientCredentialsKey($this->clientName)); + $this->stateStore->forget('state'); + $this->stateStore->forget('nonce'); + $this->stateStore->forget('code_verifier'); + + $this->clientId = null; + $this->clientSecret = null; try { - // Try to load existing key pair - $keyPair = $this->loadDPoPKeyPair(); + $path = $this->dpopKeyPath(); + $disk = $this->dpopKeyDisk(); - if (!$keyPair) { - // Generate new key pair - $keyPair = self::generateDPoPKeyPair(); + if ($disk->exists($path)) { + $disk->delete($path); + } - if ($keyPair) { - $this->saveDPoPKeyPair($keyPair); - } + // Also any copy left on the default disk by an earlier version. + if (Storage::exists($path)) { + Storage::delete($path); } + } catch (\Throwable $e) { + Log::warning('[Solid OIDC] Unable to delete the stored DPoP key.', ['error' => $e->getMessage()]); + } - self::$dpopKeyPairCache[$cacheKey] = $keyPair; + unset(self::$dpopKeyPairCache[$this->dpopCacheKey()]); + $this->dpopKeyPair = null; - Log::info('[DPOP KEY PAIR LOADED]', [ - 'has_private_key' => isset($keyPair['private_key']), - 'has_public_jwk' => isset($keyPair['public_jwk']), - 'identity_uuid' => $this->identity?->uuid, - ]); + Log::info('[Solid OIDC] Client credentials and DPoP key cleared.', [ + 'identity_uuid' => $this->identity?->uuid, + ]); + } - return $keyPair; - } catch (\Throwable $e) { - Log::error('[DPOP KEY PAIR ERROR]', [ - 'error' => $e->getMessage(), - ]); + /* + |-------------------------------------------------------------------------- + | DPoP + |-------------------------------------------------------------------------- + */ - return null; - } + /** + * A DPoP proof for one request (RFC 9449). + * + * @throws OpenIDConnectClientException + */ + public function createDPoP(string $method, string $url, ?string $accessToken = null, ?string $nonce = null): string + { + return $this->dpopKeyPair()->proof($method, $url, $accessToken, $nonce); } /** - * Generate new DPoP key pair. + * The identity's DPoP key, loaded from storage or generated and persisted. + * + * @throws OpenIDConnectClientException */ - private static function generateDPoPKeyPair(): ?array + private function dpopKeyPair(): DPoPKeyPair { - try { - // Generate RSA key pair - $config = [ - 'digest_alg' => 'sha256', - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]; + if ($this->dpopKeyPair instanceof DPoPKeyPair) { + return $this->dpopKeyPair; + } - $resource = openssl_pkey_new($config); + $cacheKey = $this->dpopCacheKey(); - if (!$resource) { - throw new \Exception('Failed to generate RSA key pair'); - } + if (isset(self::$dpopKeyPairCache[$cacheKey])) { + return $this->dpopKeyPair = self::$dpopKeyPairCache[$cacheKey]; + } - // Export private key - openssl_pkey_export($resource, $privateKey); + $keyPair = $this->loadDPoPKeyPair(); - // Get public key details - $publicKeyDetails = openssl_pkey_get_details($resource); - $publicKey = $publicKeyDetails['key']; + if (!$keyPair instanceof DPoPKeyPair) { + $keyPair = DPoPKeyPair::generate(); + $this->saveDPoPKeyPair($keyPair); + } - // Create JWK for public key - $publicJwk = [ - 'kty' => 'RSA', - 'n' => rtrim(strtr(base64_encode($publicKeyDetails['rsa']['n']), '+/', '-_'), '='), - 'e' => rtrim(strtr(base64_encode($publicKeyDetails['rsa']['e']), '+/', '-_'), '='), - ]; + self::$dpopKeyPairCache[$cacheKey] = $keyPair; - Log::info('[DPOP KEY PAIR GENERATED]', [ - 'private_key_length' => strlen($privateKey), - 'public_key_length' => strlen($publicKey), - 'jwk_keys' => array_keys($publicJwk), - ]); + return $this->dpopKeyPair = $keyPair; + } - return [ - 'private_key' => $privateKey, - 'public_key' => $publicKey, - 'public_jwk' => $publicJwk, - ]; + private function loadDPoPKeyPair(): ?DPoPKeyPair + { + try { + $path = $this->dpopKeyPath(); + $disk = $this->dpopKeyDisk(); + + if ($disk->exists($path)) { + $decoded = json_decode((string) $disk->get($path), true); + + return DPoPKeyPair::fromArray(is_array($decoded) ? $decoded : null); + } + + return $this->migrateLegacyDPoPKeyPair($path); } catch (\Throwable $e) { - Log::error('[DPOP KEY GENERATION ERROR]', [ - 'error' => $e->getMessage(), - ]); + Log::warning('[Solid OIDC] Unable to read the stored DPoP key.', ['error' => $e->getMessage()]); return null; } } /** - * Load DPoP key pair from storage. + * Move a key written by an earlier version off the application's default disk. + * + * This used to be stored with a bare `Storage::put()`. In a Fleetbase install + * the default disk is `public` — rooted at `storage/app/public`, symlinked to + * `public/storage` and served over HTTP — or a cloud bucket, so the RSA + * *private key* was written somewhere readable from outside the application. + * + * Rather than abandon it, which would invalidate every access token bound to + * it and sign every Solid user out, the key is copied to the private disk and + * the exposed copy is deleted. */ - private function loadDPoPKeyPair(): ?array + private function migrateLegacyDPoPKeyPair(string $path): ?DPoPKeyPair { - try { - $keyPath = $this->getDPoPKeyPath(); - - if (!Storage::exists($keyPath)) { - return null; - } + if (!Storage::exists($path)) { + return null; + } - $keyData = json_decode(Storage::get($keyPath), true); + $decoded = json_decode((string) Storage::get($path), true); + $keyPair = DPoPKeyPair::fromArray(is_array($decoded) ? $decoded : null); - if (!$keyData || !isset($keyData['private_key'], $keyData['public_jwk'])) { - return null; - } + if (!$keyPair instanceof DPoPKeyPair) { + return null; + } - Log::info('[DPOP KEY PAIR LOADED FROM STORAGE]'); + $this->saveDPoPKeyPair($keyPair); - return $keyData; + try { + Storage::delete($path); } catch (\Throwable $e) { - Log::warning('[DPOP KEY LOAD ERROR]', [ + Log::error('[Solid OIDC] A DPoP key remains on the default storage disk and must be deleted by hand.', [ + 'path' => $path, 'error' => $e->getMessage(), ]); - - return null; } + + Log::info('[Solid OIDC] Moved a DPoP key off the default storage disk.', [ + 'identity_uuid' => $this->identity?->uuid, + ]); + + return $keyPair; } - /** - * Save DPoP key pair to storage. - */ - private function saveDPoPKeyPair(array $keyPair): void + private function saveDPoPKeyPair(DPoPKeyPair $keyPair): void { try { - $keyPath = $this->getDPoPKeyPath(); - - Storage::put($keyPath, json_encode([ - 'private_key' => $keyPair['private_key'], - 'public_key' => $keyPair['public_key'], - 'public_jwk' => $keyPair['public_jwk'], - 'created_at' => now()->toISOString(), + $this->dpopKeyDisk()->put($this->dpopKeyPath(), (string) json_encode($keyPair->toArray() + [ + 'created_at' => gmdate('c'), ])); - - Log::info('[DPOP KEY PAIR SAVED TO STORAGE]'); } catch (\Throwable $e) { - Log::warning('[DPOP KEY SAVE ERROR]', [ - 'error' => $e->getMessage(), - ]); + // Not fatal: the in-request key still works, it just will not be reused, + // which invalidates the tokens bound to it on the next request. + Log::error('[Solid OIDC] Unable to persist the DPoP key.', ['error' => $e->getMessage()]); } } /** - * Generate JTI (JWT ID). + * The disk DPoP private keys live on. + * + * Named explicitly rather than taken from `filesystems.default`, which in a + * Fleetbase install is the web-served `public` disk or a cloud bucket. A + * private signing key must not be reachable over HTTP. */ - private static function generateJti(): string + private function dpopKeyDisk(): Filesystem + { + $disk = self::config('solid.oidc.key_disk', 'local'); + + return Storage::disk(is_string($disk) && $disk !== '' ? $disk : 'local'); + } + + private function dpopKeyPath(): string { - return bin2hex(random_bytes(16)); + return $this->identity instanceof SolidIdentity + ? 'solid/dpop_keys_' . $this->identity->uuid . '.json' + : 'solid/dpop_keys.json'; } + private function dpopCacheKey(): string + { + return $this->identity instanceof SolidIdentity + ? 'identity_' . $this->identity->uuid + : 'global'; + } + + /* + |-------------------------------------------------------------------------- + | Tokens and claims + |-------------------------------------------------------------------------- + */ + /** - * Generate access token hash for DPoP. + * Verify a JWT's signature, issuer and audience against this provider. + * + * Kept for callers that used the previous method of the same name. It throws + * on failure and only ever returns true, which is how the old override behaved. + * + * @throws OpenIDConnectClientException */ - private static function generateAccessTokenHash(string $accessToken): string + public function verifyJWTSignature(string $jwt): bool { - return rtrim(strtr(base64_encode(hash('sha256', $accessToken, true)), '+/', '-_'), '='); + $this->verifyIdToken($jwt, null); + + return true; } /** - * Get DPoP key storage path for this identity. + * Decode one section of a JWT **without verifying it**. + * + * Section 0 is the header, 1 is the payload. Nothing security-relevant may be + * decided from the result; use the claims returned by `getVerifiedClaims()`. */ - private function getDPoPKeyPath(): string + public function decodeJWTPublic(string $jwt, int $section = 0): ?object { - if ($this->identity instanceof SolidIdentity) { - return 'solid/dpop_keys_' . $this->identity->uuid . '.json'; + $decoded = self::decodeJwtSection($jwt, $section); + + return is_array($decoded) ? (object) $decoded : null; + } + + /** + * The WebID for an ID token. + * + * When the token is the one this client just verified, the WebID comes from the + * verified claims. Otherwise the token is decoded **without verification** — + * that path exists because `PodService` reads ID tokens back out of the + * database long after they have expired, so they can no longer be verified. + * Prefer `SolidIdentity::getWebId()`, which reads the claims that were verified + * at sign-in. + */ + public function getWebIdFromIdToken(string $idToken): ?string + { + if ($idToken === '') { + return null; + } + + $claims = $this->idToken !== null && hash_equals($this->idToken, $idToken) && $this->verifiedClaims !== [] + ? $this->verifiedClaims + : self::decodeJwtSection($idToken, 1); + + if (!is_array($claims)) { + return null; } - - // Fallback to global key for non-identity contexts - return 'solid/dpop_keys.json'; + + $webId = $claims['webid'] ?? $claims['sub'] ?? null; + + return is_string($webId) && $webId !== '' ? $webId : null; } - + /** - * Get DPoP cache key for this identity. + * All claims from an ID token. Unverified unless the token is the one this + * client verified — see `getWebIdFromIdToken()`. */ - private function getDPoPCacheKey(): string + public function getIdTokenClaims(?string $idToken = null): ?object { - if ($this->identity instanceof SolidIdentity) { - return 'identity_' . $this->identity->uuid; + $token = $idToken ?? $this->idToken; + + if (!is_string($token) || $token === '') { + return null; + } + + if ($this->idToken !== null && hash_equals($this->idToken, $token) && $this->verifiedClaims !== []) { + return (object) $this->verifiedClaims; } - - return 'global'; + + return $this->decodeJWTPublic($token, 1); } /** - * Clear client credentials and DPoP keys. + * The claims of the last ID token this client actually verified. + * + * @return array */ - public function clearClientCredentials(): void + public function getVerifiedClaims(): array { - try { - // Clear stored DPoP keys for this identity - $keyPath = $this->getDPoPKeyPath(); - if (Storage::exists($keyPath)) { - Storage::delete($keyPath); - } + return $this->verifiedClaims; + } - $cacheKey = $this->getDPoPCacheKey(); - unset(self::$dpopKeyPairCache[$cacheKey]); + /** + * @return array|null + */ + private static function decodeJwtSection(string $jwt, int $section): ?array + { + $segments = explode('.', $jwt); - Log::info('[CLIENT CREDENTIALS CLEARED]'); - } catch (\Throwable $e) { - Log::warning('[CLEAR CREDENTIALS ERROR]', [ - 'error' => $e->getMessage(), - ]); + if (!isset($segments[$section])) { + return null; } + + $decoded = json_decode((string) base64_decode(strtr($segments[$section], '-_', '+/'), true), true); + + return is_array($decoded) ? $decoded : null; + } + + /* + |-------------------------------------------------------------------------- + | Accessors + |-------------------------------------------------------------------------- + */ + + public function setProviderURL(string $providerUrl): self + { + $this->providerUrl = $providerUrl; + + return $this; + } + + public function getProviderURL(): ?string + { + return $this->providerUrl; + } + + public function setIssuer(string $issuer): self + { + $this->issuer = $issuer; + + return $this; + } + + public function getIssuer(): ?string + { + return $this->issuer; + } + + public function setRedirectURL(?string $redirectUri): self + { + $this->redirectUri = $redirectUri; + + return $this; + } + + public function getRedirectURL(): ?string + { + return $this->redirectUri; + } + + public function setClientID(string $clientId): self + { + $this->clientId = $clientId; + + return $this; + } + + public function getClientID(): ?string + { + return $this->clientId; + } + + public function setClientSecret(string $clientSecret): self + { + $this->clientSecret = $clientSecret; + + return $this; + } + + public function getClientSecret(): ?string + { + return $this->clientSecret; + } + + public function setClientName(string $clientName): self + { + $this->clientName = $clientName; + + return $this; + } + + public function getClientName(): string + { + return $this->clientName; } /** - * Exchange authorization code for tokens with enhanced error handling. + * @param array $scopes */ - public function exchangeCodeForTokens(string $code, ?string $state = null): \stdClass + public function addScope(array $scopes): self { - try { - Log::info('[EXCHANGING CODE FOR TOKENS]', [ - 'code_length' => strlen($code), - 'state' => $state, - ]); + // Deduplicated: the previous version appended to a list that already held + // the same values, producing `openid webid offline_access openid`. + $this->scopes = array_values(array_unique(array_merge($this->scopes, $scopes))); - $this->setCode($code); - if ($state !== null) { - $this->setState($state); - } + return $this; + } - // Note: Scope is sent in authorization request, not token request (per OIDC spec) - // CSS should remember the granted scope from the authorization - $tokenResponse = $this->requestTokens($code); + /** + * @return array + */ + public function getScopes(): array + { + return $this->scopes; + } - Log::info('[TOKEN EXCHANGE SUCCESS]', [ - 'has_access_token' => isset($tokenResponse->access_token), - 'has_id_token' => isset($tokenResponse->id_token), - 'token_type' => $tokenResponse->token_type ?? 'unknown', - ]); + public function setCodeChallengeMethod(string $method): self + { + $this->codeChallengeMethod = $method; - return (object) $tokenResponse; - } catch (\Throwable $e) { - Log::error('[TOKEN EXCHANGE ERROR]', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - throw $e; - } + return $this; + } + + public function getCodeChallengeMethod(): string + { + return $this->codeChallengeMethod; + } + + /** + * The PKCE verifier for the pending authorization request, if any. + */ + public function getCodeVerifier(): ?string + { + return $this->stateStore->get('code_verifier'); } public function setCode(string $code): self @@ -856,33 +1304,175 @@ public function setCode(string $code): self return $this; } - private function sessionKey(string $key): string + public function getCode(): ?string + { + return $this->code; + } + + public function setIdToken(?string $idToken): self { - $prefix = 'oidc:session:' . ($this->identity ? $this->identity->identifier : 'default') . ':'; + $this->idToken = $idToken; + + // Claims verified for a different token must not be attributed to this one. + $this->verifiedClaims = []; - return $prefix . Str::slug($key); + return $this; } - // Full implementations – no placeholders - protected function loadFromStorage(string $key): ?string + public function getIdToken(): ?string { - try { - $value = Redis::get($this->sessionKey($key)); + return $this->idToken; + } - return $value === null ? null : (string) $value; - } catch (\Throwable $e) { - Log::warning('[OIDC STORAGE LOAD ERROR]', ['key' => $key, 'error' => $e->getMessage()]); + public function getAccessToken(): ?string + { + return $this->accessToken; + } - return null; + public function getRefreshToken(): ?string + { + return $this->refreshToken; + } + + public function getTokenResponse(): ?\stdClass + { + return $this->tokenResponse; + } + + /** + * @param array $supported + */ + public function supportsAuthMethod(string $method, array $supported): bool + { + return in_array($method, $supported, true); + } + + /* + |-------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------- + */ + + /** + * A provider endpoint, asserted to be a string. + * + * A discovery document is remote input: an endpoint that arrives as a number, + * an array or null must fail here rather than be stringified into a request. + * + * @throws OpenIDConnectClientException + */ + private function stringProviderConfigValue(string $param): string + { + $value = $this->getProviderConfigValue($param); + + if (!is_string($value) || $value === '') { + throw new OpenIDConnectClientException('The Solid provider advertises an unusable "' . $param . '".'); } + + return $value; } - protected function saveToStorage(string $key, string $value): void + /** + * A string option, or null when it is absent or of the wrong type. + * + * @param array $options + */ + private static function stringOption(array $options, string $key): ?string + { + $value = $options[$key] ?? null; + + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * RFC 7636 S256: base64url(sha256(verifier)), unpadded. + * + * Matches `Fleetbase\Auth\OAuth\Socialite\Concerns\ServerSidePkce::serverCodeChallenge()`. + */ + private static function codeChallenge(string $codeVerifier): string + { + return rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '='); + } + + /** + * @throws OpenIDConnectClientException + */ + private static function randomToken(int $bytes = 16): string { try { - Redis::set($this->sessionKey($key), $value); + return bin2hex(random_bytes(max(1, $bytes))); } catch (\Throwable $e) { - Log::warning('[OIDC STORAGE SAVE ERROR]', ['key' => $key, 'error' => $e->getMessage()]); + throw new OpenIDConnectClientException('No cryptographically secure randomness is available.', 0, $e); } } + + /** + * Whether TLS peer verification is on for the handshake. + * + * A single flag rather than a check on the app environment. The previous + * implementation turned verification off whenever the environment was `local` + * or `development`, which is the pattern Core API's `IdTokenVerifier` + * deliberately does not repeat — it also disables it in any environment an + * operator happens to name that way. + */ + public static function shouldVerifyTls(): bool + { + return (bool) self::config('solid.oidc.verify_tls', true); + } + + private static function timeout(): int + { + return self::intConfig('solid.oidc.timeout', 15); + } + + private static function intConfig(string $key, int $default): int + { + $value = self::config($key, $default); + + return is_numeric($value) ? (int) $value : $default; + } + + /** + * `Cache::remember()` when a cache is available and a positive TTL is + * configured, and a plain call otherwise — so a unit test with no container, + * or an operator who sets the TTL to 0, simply skips the cache. + * + * @template TValue + * + * @param \Closure(): TValue $resolve + * + * @return TValue + */ + private static function cacheRemember(string $key, int $ttl, \Closure $resolve): mixed + { + if ($ttl <= 0 || !self::containerHas('cache')) { + return $resolve(); + } + + return Cache::remember($key, $ttl, $resolve); + } + + /** + * Reads config without requiring a booted container, so the handshake can be + * exercised in a plain unit test. + */ + private static function config(string $key, mixed $default = null): mixed + { + if (!self::containerHas('config') || !function_exists('config')) { + return $default; + } + + return config($key, $default); + } + + /** + * Whether a service is bound on the container. + * + * `Container::getInstance()` creates an empty container rather than failing + * when the framework has not booted, so nothing here needs a try/catch. + */ + private static function containerHas(string $abstract): bool + { + return Container::getInstance()->bound($abstract); + } } diff --git a/server/src/Client/SolidClient.php b/server/src/Client/SolidClient.php index 286c34f..50a2adc 100644 --- a/server/src/Client/SolidClient.php +++ b/server/src/Client/SolidClient.php @@ -2,6 +2,7 @@ namespace Fleetbase\Solid\Client; +use Fleetbase\Models\Setting; use Fleetbase\Solid\Models\SolidIdentity; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -13,7 +14,15 @@ class SolidClient private string $host = 'localhost'; private int $port = 3000; private bool $secure = true; - public SolidIdentity $identity; + + /** + * Nullable because a client can legitimately be built without one — discovery + * and the unauthenticated requests do not need an identity. It was typed + * non-nullable while being assigned from an absent option, so `new SolidClient([])` + * raised a TypeError. + */ + public ?SolidIdentity $identity = null; + public OpenIDConnectClient $oidc; private const DEFAULT_MIME_TYPE = 'text/turtle'; private const LDP_BASIC_CONTAINER = 'http://www.w3.org/ns/ldp#BasicContainer'; @@ -29,13 +38,55 @@ class SolidClient */ public function __construct(array $options = []) { - $this->identity = data_get($options, 'identity'); - $this->host = config('solid.server.host', data_get($options, 'host')); - $this->port = (int) config('solid.server.port', data_get($options, 'port')); - $this->secure = (bool) config('solid.server.secure', data_get($options, 'secure')); + $identity = data_get($options, 'identity'); + $this->identity = $identity instanceof SolidIdentity ? $identity : null; + + $server = static::resolveServerConfig(); + $this->host = (string) (data_get($options, 'host') ?? $server['host']); + $this->port = (int) (data_get($options, 'port') ?? $server['port']); + $this->secure = (bool) (data_get($options, 'secure') ?? $server['secure']); + $this->oidc = OpenIDConnectClient::create(['solid' => $this, ...$options]); } + /** + * The Solid server to talk to: the administrator's saved setting first, then + * the env-backed config defaults. + * + * `SolidController::saveServerConfig()` writes `system.solid.server` and + * `getServerConfig()` reads it back, but nothing ever fed it to this client — + * so changing the host or port in the console had no effect on any request. + * + * Note the ordering against the constructor: an explicit option wins over the + * saved setting, which wins over config. The previous code read + * `config('solid.server.host', data_get($options, 'host'))`, where the second + * argument is `config()`'s *default* — and since the key is always defined, a + * caller-supplied host was silently discarded. + * + * @return array{host: string, port: int, secure: bool} + */ + public static function resolveServerConfig(): array + { + $defaults = (array) config('solid.server', []); + $saved = []; + + try { + $saved = (array) (Setting::system('solid.server') ?? []); + } catch (\Throwable $e) { + // Settings live in the database; a request that runs before migrations + // (or with the table missing) must fall back to config rather than fail. + Log::warning('[Solid] Unable to read the saved server configuration.', ['error' => $e->getMessage()]); + } + + $server = array_merge($defaults, array_filter($saved, static fn ($value): bool => $value !== null && $value !== '')); + + return [ + 'host' => (string) ($server['host'] ?? 'localhost'), + 'port' => (int) ($server['port'] ?? 3000), + 'secure' => (bool) ($server['secure'] ?? false), + ]; + } + /** * Factory method to create a new instance of the SolidClient. * @@ -55,10 +106,31 @@ public static function create(array $options = []): self */ public function getServerUrl(): string { - $protocol = $this->secure ? 'https' : 'http'; - $host = preg_replace('#^.*://#', '', $this->host); + return self::buildServerUrl($this->host, $this->port, $this->secure); + } - return "{$protocol}://{$host}:{$this->port}"; + /** + * The configured Solid server URL, without constructing a client. + * + * `Fleetbase\Solid\Support\Utils::getSolidServerUrl()` needs the same answer + * this client uses. Reading the config separately is what made the two + * disagree once the administrator's saved setting entered the picture. + */ + public static function serverUrl(): string + { + $server = static::resolveServerConfig(); + + return self::buildServerUrl($server['host'], $server['port'], $server['secure']); + } + + private static function buildServerUrl(string $host, int $port, bool $secure): string + { + $protocol = $secure ? 'https' : 'http'; + // The configured host may or may not carry a scheme; the protocol is + // decided by `secure`, so any scheme already on it is dropped. + $host = (string) preg_replace('#^.*://#', '', $host); + + return "{$protocol}://{$host}:{$port}"; } /** @@ -132,9 +204,9 @@ protected function request(string $method, string $uri, string|array $data = [], // Handle different data types if (is_string($data)) { return Http::withOptions($options)->withBody($data, $options['headers']['Content-Type'] ?? 'text/plain')->send($method, $url); - } else { - return Http::withOptions($options)->{$method}($url, $data); } + + return Http::withOptions($options)->{$method}($url, $data); } /** @@ -149,26 +221,7 @@ public function authenticatedRequest(string $method, string $uri, string|array $ $url = $this->createRequestUrl($uri); $accessToken = $this->identity->getAccessToken(); - // Debug: Log access token details if ($accessToken) { - try { - $tokenParts = explode('.', $accessToken); - if (count($tokenParts) === 3) { - $payload = json_decode(base64_decode(strtr($tokenParts[1], '-_', '+/')), true); - Log::debug('[ACCESS TOKEN PAYLOAD]', [ - 'webid' => $payload['webid'] ?? null, - 'sub' => $payload['sub'] ?? null, - 'client_id' => $payload['client_id'] ?? null, - 'scope' => $payload['scope'] ?? null, - 'iat' => $payload['iat'] ?? null, - 'exp' => $payload['exp'] ?? null, - 'cnf_jkt' => $payload['cnf']['jkt'] ?? null, - ]); - } - } catch (\Throwable $e) { - Log::warning('[ACCESS TOKEN DECODE FAILED]', ['error' => $e->getMessage()]); - } - $options['headers'] = isset($options['headers']) && is_array($options['headers']) ? $options['headers'] : []; $options['headers']['Authorization'] = 'DPoP ' . $accessToken; $options['headers']['DPoP'] = $this->oidc->createDPoP($method, $url, $accessToken); @@ -179,7 +232,13 @@ public function authenticatedRequest(string $method, string $uri, string|array $ $options['verify'] = false; } - Log::info('[SOLID REQUEST HEADERS]', ['headers' => $options['headers']]); + // Only the header names. The values carry the access token and the DPoP + // proof, and a log file is not a place either of them belongs. + Log::debug('[Solid] Sending an authenticated request.', [ + 'method' => $method, + 'url' => $url, + 'headers' => array_keys(is_array($options['headers'] ?? null) ? $options['headers'] : []), + ]); // Handle different data types if (is_string($data)) { @@ -195,34 +254,33 @@ public function authenticatedRequest(string $method, string $uri, string|array $ ]); $response = Http::withOptions($options)->withBody($data, $contentType)->send($method, $url); - - // Debug: Log response details - Log::debug('[SOLID RESPONSE]', [ - 'status' => $response->status(), - 'headers' => $response->headers(), - 'body' => $response->body(), - ]); - - return $response; - } else { - // For array data, use the original method - Log::info('[SENDING ARRAY DATA]', [ - 'method' => $method, - 'url' => $url, - 'data' => $data, - ]); - $response = Http::withOptions($options)->{$method}($url, $data); - // Debug: Log response details Log::debug('[SOLID RESPONSE]', [ - 'status' => $response->status(), + 'status' => $response->status(), 'headers' => $response->headers(), - 'body' => $response->body(), + 'body' => $response->body(), ]); - + return $response; } + // For array data, use the original method + Log::info('[SENDING ARRAY DATA]', [ + 'method' => $method, + 'url' => $url, + 'data' => $data, + ]); + + $response = Http::withOptions($options)->{$method}($url, $data); + + // Debug: Log response details + Log::debug('[SOLID RESPONSE]', [ + 'status' => $response->status(), + 'headers' => $response->headers(), + 'body' => $response->body(), + ]); + + return $response; } /** diff --git a/server/src/Exceptions/OpenIDConnectClientException.php b/server/src/Exceptions/OpenIDConnectClientException.php new file mode 100644 index 0000000..f937246 --- /dev/null +++ b/server/src/Exceptions/OpenIDConnectClientException.php @@ -0,0 +1,19 @@ +podService->getProfileData($identity); - $webId = $profile['webid']; - $podUrl = $this->podService->getPodUrlFromWebId($webId); - + $webId = $profile['webid']; + $podUrl = $this->podService->getPodUrlFromWebId($webId); + Log::info('[DATA INDEX]', [ - 'webid' => $webId, + 'webid' => $webId, 'pod_url' => $podUrl, ]); @@ -73,13 +73,13 @@ public function showFolder(Request $request, string $slug) return response()->json(['error' => 'Not authenticated'], 401); } - $profile = $this->podService->getProfileData($identity); - $webId = $profile['webid']; - $podUrl = $this->podService->getPodUrlFromWebId($webId); + $profile = $this->podService->getProfileData($identity); + $webId = $profile['webid']; + $podUrl = $this->podService->getPodUrlFromWebId($webId); $folderUrl = rtrim($podUrl, '/') . '/' . ltrim($slug, '/'); Log::info('[FOLDER SHOW]', [ - 'slug' => $slug, + 'slug' => $slug, 'folder_url' => $folderUrl, ]); @@ -120,12 +120,12 @@ public function createFolder(Request $request) ]); $folderName = $request->input('name'); - $path = $request->input('path', ''); + $path = $request->input('path', ''); $profile = $this->podService->getProfileData($identity); - $webId = $profile['webid']; - $podUrl = $this->podService->getPodUrlFromWebId($webId); - + $webId = $profile['webid']; + $podUrl = $this->podService->getPodUrlFromWebId($webId); + // Build parent URL (where to create the folder) $parentUrl = rtrim($podUrl, '/') . '/'; if (!empty($path)) { @@ -133,38 +133,39 @@ public function createFolder(Request $request) } Log::info('[FOLDER CREATE]', [ - 'name' => $folderName, - 'path' => $path, + 'name' => $folderName, + 'path' => $path, 'parent_url' => $parentUrl, ]); // Check if parent URL is writable before attempting folder creation $aclService = app(\Fleetbase\Solid\Services\AclService::class); - + if (!$aclService->isWritable($identity, $parentUrl)) { Log::warning('[FOLDER CREATE] Location not writable', [ 'parent_url' => $parentUrl, - 'webid' => $webId, + 'webid' => $webId, ]); // Find writable locations $writableLocations = $aclService->findWritableLocations($identity, $profile); - + if (!empty($writableLocations)) { $suggestion = array_values($writableLocations)[0]; + return response()->json([ - 'success' => false, - 'error' => 'Cannot create folder at specified location. You do not have write permissions.', - 'suggestion' => "Try creating the folder at: {$suggestion}", + 'success' => false, + 'error' => 'Cannot create folder at specified location. You do not have write permissions.', + 'suggestion' => "Try creating the folder at: {$suggestion}", 'writable_locations' => $writableLocations, ], 403); - } else { - return response()->json([ - 'success' => false, - 'error' => 'No writable locations found in your pod.', - 'help' => 'You may need to configure ACL permissions. See: https://docs.solidproject.org/managing-permissions', - ], 403); } + + return response()->json([ + 'success' => false, + 'error' => 'No writable locations found in your pod.', + 'help' => 'You may need to configure ACL permissions. See: https://docs.solidproject.org/managing-permissions', + ], 403); } // Use POST with Slug header (Solid Protocol standard) @@ -201,8 +202,8 @@ public function deleteItem(Request $request, string $type, string $slug) } $profile = $this->podService->getProfileData($identity); - $webId = $profile['webid']; - $podUrl = $this->podService->getPodUrlFromWebId($webId); + $webId = $profile['webid']; + $podUrl = $this->podService->getPodUrlFromWebId($webId); $itemUrl = rtrim($podUrl, '/') . '/' . ltrim($slug, '/'); // Add trailing slash for folders @@ -211,8 +212,8 @@ public function deleteItem(Request $request, string $type, string $slug) } Log::info('[ITEM DELETE]', [ - 'type' => $type, - 'slug' => $slug, + 'type' => $type, + 'slug' => $slug, 'item_url' => $itemUrl, ]); @@ -257,8 +258,8 @@ public function importResources(Request $request) // Use the authenticated user's pod (from their WebID) $profile = $this->podService->getProfileData($identity); - $webId = $profile['webid']; - $podUrl = $this->podService->getPodUrlFromWebId($webId); + $webId = $profile['webid']; + $podUrl = $this->podService->getPodUrlFromWebId($webId); Log::info('[IMPORTING RESOURCES]', [ 'pod_url' => $podUrl, @@ -268,30 +269,30 @@ public function importResources(Request $request) // Check if pod URL is writable before importing $aclService = app(\Fleetbase\Solid\Services\AclService::class); - + if (!$aclService->isWritable($identity, $podUrl)) { Log::warning('[IMPORT RESOURCES] Pod root not writable', [ 'pod_url' => $podUrl, - 'webid' => $webId, + 'webid' => $webId, ]); // Find writable locations $writableLocations = $aclService->findWritableLocations($identity, $profile); - + if (!empty($writableLocations)) { return response()->json([ - 'success' => false, - 'error' => 'Cannot import resources to pod root. You do not have write permissions.', + 'success' => false, + 'error' => 'Cannot import resources to pod root. You do not have write permissions.', 'writable_locations' => $writableLocations, - 'help' => 'Resources can only be imported to writable locations.', - ], 403); - } else { - return response()->json([ - 'success' => false, - 'error' => 'No writable locations found in your pod.', - 'help' => 'You may need to configure ACL permissions. See: https://docs.solidproject.org/managing-permissions', + 'help' => 'Resources can only be imported to writable locations.', ], 403); } + + return response()->json([ + 'success' => false, + 'error' => 'No writable locations found in your pod.', + 'help' => 'You may need to configure ACL permissions. See: https://docs.solidproject.org/managing-permissions', + ], 403); } $result = $this->resourceSyncService->importResources($identity, $podUrl, $resourceTypes); diff --git a/server/src/Http/Controllers/OIDCController.php b/server/src/Http/Controllers/OIDCController.php index c9338c8..f3dee35 100644 --- a/server/src/Http/Controllers/OIDCController.php +++ b/server/src/Http/Controllers/OIDCController.php @@ -6,61 +6,78 @@ use Fleetbase\Solid\Client\SolidClient; use Fleetbase\Solid\Models\SolidIdentity; use Fleetbase\Support\Utils; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; class OIDCController extends BaseController { - public function completeRegistration(string $identifier, Request $request) + /** + * The Solid identity provider's redirect back from the authorization endpoint. + * + * The `state` is now forwarded to the client and checked there, as is the + * `id_token`'s signature and claims. Neither used to happen: the callback + * simply exchanged whatever code it was handed. + */ + public function completeRegistration(string $identifier, Request $request): RedirectResponse { - Log::info('[OIDC COMPLETE REGISTRATION]', [ + Log::info('[Solid OIDC] Authorization callback received.', [ 'identifier' => $identifier, - 'request' => $request->all(), 'has_code' => $request->filled('code'), 'has_state' => $request->filled('state'), ]); - if ($request->filled('code') && $identifier) { - try { - $identity = SolidIdentity::where('identifier', $identifier)->first(); - - if (!$identity) { - throw new \Exception('Identity not found for identifier: ' . $identifier); - } - - Log::info('[OIDC IDENTITY FOUND]', ['identity_id' => $identity->id]); + try { + // The provider reports a refused or failed authorization here rather + // than with a code. Surfacing it is what turns "nothing happened" into + // an actionable message. + if ($request->filled('error')) { + throw new \Exception(self::stringInput($request, 'error_description') ?? self::stringInput($request, 'error') ?? 'The identity provider refused the authorization request.'); + } - $solid = SolidClient::create(['identity' => $identity, 'restore' => true]); + if (!$request->filled('code')) { + throw new \Exception('The authorization callback carried no code.'); + } - // Get the authorization code - $code = $request->input('code'); + $identity = SolidIdentity::where('identifier', $identifier)->first(); - // CRITICAL: Use requestTokens() instead of authenticate() - $tokenResponse = $solid->oidc->exchangeCodeForTokens($code); + if (!$identity) { + throw new \Exception('No Solid identity matches this authorization request.'); + } - if (isset($tokenResponse->error)) { - throw new \Exception($tokenResponse->error_description ?? $tokenResponse->error); - } + $solid = SolidClient::create(['identity' => $identity, 'restore' => true]); - Log::info('[OIDC TOKEN EXCHANGE SUCCESS]', ['has_access_token' => isset($tokenResponse->access_token)]); + $tokenResponse = $solid->oidc->exchangeCodeForTokens( + (string) self::stringInput($request, 'code'), + self::stringInput($request, 'state') + ); - // Save the token response - $identity->update(['token_response' => (array) $tokenResponse]); + // The verified claims are persisted alongside the tokens so that later + // reads of the WebID do not have to trust an unverified copy of an + // id_token that has since expired. + $identity->storeTokenResponse($tokenResponse, $solid->oidc->getVerifiedClaims()); - // Redirect to success page - return redirect(Utils::consoleUrl('solid-protocol', ['success' => 'authenticated'])); - } catch (\Throwable $e) { - Log::error('[OIDC ERROR]', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); + return redirect(Utils::consoleUrl('solid-protocol', ['success' => 'authenticated'])); + } catch (\Throwable $e) { + Log::error('[Solid OIDC] Authorization callback failed.', [ + 'identifier' => $identifier, + 'error' => $e->getMessage(), + ]); - return redirect(Utils::consoleUrl('solid-protocol', ['error' => $e->getMessage()])); - } + return redirect(Utils::consoleUrl('solid-protocol', ['error' => $e->getMessage()])); } + } - Log::warning('[OIDC MISSING PARAMS]', ['has_code' => $request->filled('code'), 'identifier' => $identifier]); + /** + * A request parameter, or null unless it arrived as a non-empty string. + * + * The callback query is attacker-influenced, so a parameter sent as an array + * must not be stringified into the handshake. + */ + private static function stringInput(Request $request, string $key): ?string + { + $value = $request->input($key); - return redirect(Utils::consoleUrl('solid-protocol', ['error' => 'Missing authorization code or identifier'])); + return is_string($value) && $value !== '' ? $value : null; } } diff --git a/server/src/Http/Controllers/SolidController.php b/server/src/Http/Controllers/SolidController.php index cb016b7..490daa3 100644 --- a/server/src/Http/Controllers/SolidController.php +++ b/server/src/Http/Controllers/SolidController.php @@ -5,6 +5,7 @@ use Fleetbase\Http\Controllers\Controller as BaseController; use Fleetbase\Http\Requests\AdminRequest; use Fleetbase\Models\Setting; +use Fleetbase\Solid\Client\OpenIDConnectClient; use Fleetbase\Solid\Client\SolidClient; use Fleetbase\Solid\Models\SolidIdentity; use Fleetbase\Solid\Services\PodService; @@ -50,6 +51,11 @@ public function saveServerConfig(AdminRequest $request) $config = array_merge($defaultConfig, $incomingConfig); Setting::configure('system.solid.server', $config); + // The OIDC provider is discovered from the server URL and memoised per + // process, so a long-lived worker would otherwise keep talking to the old + // provider until it restarted. + OpenIDConnectClient::flushProviderCaches(); + return response()->json($config); } @@ -255,10 +261,34 @@ private function parseContainers(string $content): array return $containers; } + /** + * The Solid server's account index for the current identity. + * + * Used to be a `dd()`, which halted the request and dumped the raw response + * to the browser on a live route. + */ public function getAccountIndex() { - $solidIdentity = SolidIdentity::current(); - $accountResponse = $solidIdentity->request('get', '.account'); - dd($accountResponse->json()); + try { + $identity = SolidIdentity::current(); + + if (!$identity->getAccessToken()) { + return response()->json(['error' => 'Not authenticated'], 401); + } + + $accountResponse = $identity->request('get', '.account'); + + if (!$accountResponse->successful()) { + return response()->json([ + 'error' => 'The Solid server did not return an account index.', + ], $accountResponse->status()); + } + + return response()->json($accountResponse->json()); + } catch (\Throwable $e) { + Log::error('[Solid] Unable to read the account index.', ['error' => $e->getMessage()]); + + return response()->json(['error' => $e->getMessage()], 500); + } } } diff --git a/server/src/Models/SolidIdentity.php b/server/src/Models/SolidIdentity.php index f43afab..53415d9 100644 --- a/server/src/Models/SolidIdentity.php +++ b/server/src/Models/SolidIdentity.php @@ -56,17 +56,83 @@ public function company() public function getAccessToken(): ?string { - // Use OIDC token only (proper Solid protocol) - $oidcToken = data_get($this, 'token_response.access_token'); - - if ($oidcToken) { - \Illuminate\Support\Facades\Log::info('[USING OIDC TOKEN]', ['has_token' => true]); - return $oidcToken; + $accessToken = data_get($this, 'token_response.access_token'); + + return is_string($accessToken) && $accessToken !== '' ? $accessToken : null; + } + + /** + * The refresh token from the last successful sign-in, if the provider issued one. + */ + public function getRefreshToken(): ?string + { + $refreshToken = data_get($this, 'token_response.refresh_token'); + + return is_string($refreshToken) && $refreshToken !== '' ? $refreshToken : null; + } + + /** + * Persist a token response together with the ID token claims that were + * verified when it was issued. + * + * The claims are kept because an ID token cannot be re-verified later — it is + * short-lived, and by the time anything reads the WebID back out of the + * database the token has expired. Storing what was verified at sign-in is what + * lets `getWebId()` avoid trusting an unverified decode. + * + * @param array $verifiedIdTokenClaims + */ + public function storeTokenResponse(object $tokenResponse, array $verifiedIdTokenClaims = []): self + { + $payload = (array) $tokenResponse; + + if ($verifiedIdTokenClaims !== []) { + $payload['id_token_claims'] = $verifiedIdTokenClaims; + } + + $this->update(['token_response' => $payload]); + + return $this; + } + + /** + * The user's WebID. + * + * Read from the claims verified at sign-in. Falls back to an *unverified* + * decode of the stored ID token for identities that authenticated before those + * claims were persisted, so an existing session is not signed out by the + * upgrade; that fallback can be removed once those have all been renewed. + */ + public function getWebId(): ?string + { + $webId = data_get($this, 'token_response.id_token_claims.webid') + ?? data_get($this, 'token_response.id_token_claims.sub'); + + if (is_string($webId) && $webId !== '') { + return $webId; } - - // No token available - \Illuminate\Support\Facades\Log::warning('[NO ACCESS TOKEN AVAILABLE]'); - return null; + + $idToken = data_get($this, 'token_response.id_token'); + + if (!is_string($idToken) || $idToken === '') { + return null; + } + + // Decoded in place rather than through the OIDC client: the client's + // constructor performs provider discovery, and a model accessor must not + // make a network call. Nothing is verified here — see the note above. + $segments = explode('.', $idToken); + $claims = isset($segments[1]) + ? json_decode((string) base64_decode(strtr($segments[1], '-_', '+/'), true), true) + : null; + + if (!is_array($claims)) { + return null; + } + + $webId = $claims['webid'] ?? $claims['sub'] ?? null; + + return is_string($webId) && $webId !== '' ? $webId : null; } public function getRedirectUri(array $query = [], int $port = 8000): string diff --git a/server/src/Services/AclService.php b/server/src/Services/AclService.php index a3ce529..6270f34 100644 --- a/server/src/Services/AclService.php +++ b/server/src/Services/AclService.php @@ -15,24 +15,25 @@ public function __construct(PodService $podService) } /** - * Check if the pod root has write/append permissions + * Check if the pod root has write/append permissions. */ public function hasWritePermissions(SolidIdentity $identity, string $podUrl): bool { try { $response = $identity->request('get', $podUrl); - + if (!$response->successful()) { Log::warning('[ACL CHECK FAILED]', [ 'pod_url' => $podUrl, - 'status' => $response->status(), + 'status' => $response->status(), ]); + return false; } $wacAllow = $response->header('WAC-Allow'); Log::info('[ACL CHECK]', [ - 'pod_url' => $podUrl, + 'pod_url' => $podUrl, 'wac_allow' => $wacAllow, ]); @@ -45,26 +46,27 @@ public function hasWritePermissions(SolidIdentity $identity, string $podUrl): bo } catch (\Exception $e) { Log::error('[ACL CHECK ERROR]', [ 'pod_url' => $podUrl, - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); + return false; } } /** - * Update the pod root ACL to grant write permissions + * Update the pod root ACL to grant write permissions. */ public function grantWritePermissions(SolidIdentity $identity, string $podUrl, string $webId): bool { try { $aclUrl = rtrim($podUrl, '/') . '/.acl'; - + // Generate ACL Turtle document $aclTurtle = $this->generateAclDocument($podUrl, $webId); - + Log::info('[ACL UPDATE]', [ 'acl_url' => $aclUrl, - 'webid' => $webId, + 'webid' => $webId, ]); // PUT the ACL document @@ -77,35 +79,37 @@ public function grantWritePermissions(SolidIdentity $identity, string $podUrl, s if ($response->successful()) { Log::info('[ACL UPDATED]', [ 'acl_url' => $aclUrl, - 'status' => $response->status(), + 'status' => $response->status(), ]); + return true; } Log::error('[ACL UPDATE FAILED]', [ 'acl_url' => $aclUrl, - 'status' => $response->status(), - 'body' => $response->body(), + 'status' => $response->status(), + 'body' => $response->body(), ]); - return false; + return false; } catch (\Exception $e) { Log::error('[ACL UPDATE ERROR]', [ 'pod_url' => $podUrl, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), ]); + return false; } } /** - * Generate ACL Turtle document + * Generate ACL Turtle document. */ protected function generateAclDocument(string $podUrl, string $webId): string { $podUrl = rtrim($podUrl, '/') . '/'; - + return <<. @@ -129,68 +133,65 @@ protected function generateAclDocument(string $podUrl, string $webId): string } /** - * Ensure pod has write permissions, update ACL if needed + * Ensure pod has write permissions, update ACL if needed. */ public function ensureWritePermissions(SolidIdentity $identity, string $podUrl, string $webId): bool { // Check if already has write permissions if ($this->hasWritePermissions($identity, $podUrl)) { Log::info('[ACL OK]', ['pod_url' => $podUrl]); + return true; } // Need to update ACL Log::info('[ACL NEEDS UPDATE]', ['pod_url' => $podUrl]); + return $this->grantWritePermissions($identity, $podUrl, $webId); } /** * Ensure a folder has proper ACL permissions after creation. * - * @param SolidIdentity $identity * @param string $folderUrl The folder URL (must end with /) - * @param string $webId The WebID to grant permissions to - * @return bool + * @param string $webId The WebID to grant permissions to */ public function ensureFolderPermissions(SolidIdentity $identity, string $folderUrl, string $webId): bool { try { // Ensure folder URL ends with / $folderUrl = rtrim($folderUrl, '/') . '/'; - $aclUrl = $folderUrl . '.acl'; + $aclUrl = $folderUrl . '.acl'; Log::info('[ACL] Ensuring folder permissions', [ 'folder_url' => $folderUrl, - 'acl_url' => $aclUrl, - 'webid' => $webId, + 'acl_url' => $aclUrl, + 'webid' => $webId, ]); // Check if ACL already exists and has write permissions if ($this->hasFolderWritePermissions($identity, $folderUrl, $webId)) { Log::info('[ACL] Folder already has write permissions', ['folder_url' => $folderUrl]); + return true; } // Create ACL with full permissions for the owner $aclContent = $this->generateFolderAcl($folderUrl, $webId); - + return $this->createFolderAcl($identity, $aclUrl, $aclContent); } catch (\Throwable $e) { Log::error('[ACL] Failed to ensure folder permissions', [ 'folder_url' => $folderUrl, - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); + return false; } } /** * Check if a folder has write permissions. - * - * @param SolidIdentity $identity - * @param string $folderUrl - * @param string $webId - * @return bool */ protected function hasFolderWritePermissions(SolidIdentity $identity, string $folderUrl, string $webId): bool { @@ -203,16 +204,17 @@ protected function hasFolderWritePermissions(SolidIdentity $identity, string $fo // Check WAC-Allow header $wacAllow = $response->header('WAC-Allow'); - + if ($wacAllow) { Log::debug('[ACL] WAC-Allow header', [ 'folder_url' => $folderUrl, - 'wac_allow' => $wacAllow, + 'wac_allow' => $wacAllow, ]); // Parse WAC-Allow header: user="read write", public="read" if (preg_match('/user="([^"]*)"/i', $wacAllow, $matches)) { $userModes = strtolower($matches[1]); + return str_contains($userModes, 'write') || str_contains($userModes, 'append'); } } @@ -221,19 +223,15 @@ protected function hasFolderWritePermissions(SolidIdentity $identity, string $fo } catch (\Throwable $e) { Log::debug('[ACL] Error checking folder permissions', [ 'folder_url' => $folderUrl, - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); + return false; } } /** * Create an ACL file for a folder. - * - * @param SolidIdentity $identity - * @param string $aclUrl - * @param string $aclContent - * @return bool */ protected function createFolderAcl(SolidIdentity $identity, string $aclUrl, string $aclContent): bool { @@ -247,23 +245,25 @@ protected function createFolderAcl(SolidIdentity $identity, string $aclUrl, stri if ($response->successful()) { Log::info('[ACL] Folder ACL created successfully', [ 'acl_url' => $aclUrl, - 'status' => $response->status(), + 'status' => $response->status(), ]); + return true; } Log::error('[ACL] Failed to create folder ACL', [ 'acl_url' => $aclUrl, - 'status' => $response->status(), - 'body' => $response->body(), + 'status' => $response->status(), + 'body' => $response->body(), ]); return false; } catch (\Throwable $e) { Log::error('[ACL] Error creating folder ACL', [ 'acl_url' => $aclUrl, - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); + return false; } } @@ -272,8 +272,7 @@ protected function createFolderAcl(SolidIdentity $identity, string $aclUrl, stri * Generate ACL content for a folder with full owner permissions. * * @param string $folderUrl The folder URL - * @param string $webId The WebID to grant permissions to - * @return string + * @param string $webId The WebID to grant permissions to */ protected function generateFolderAcl(string $folderUrl, string $webId): string { @@ -291,69 +290,62 @@ protected function generateFolderAcl(string $folderUrl, string $webId): string /** * Check if a specific URL is writable (has write or append permissions). - * - * @param SolidIdentity $identity - * @param string $url - * @return bool */ public function isWritable(SolidIdentity $identity, string $url): bool { try { $response = $identity->request('head', $url); - + if (!$response->successful()) { return false; } $wacAllow = $response->header('WAC-Allow'); - + if ($wacAllow && preg_match('/user="([^"]*)"/i', $wacAllow, $matches)) { - $modes = strtolower($matches[1]); + $modes = strtolower($matches[1]); $isWritable = str_contains($modes, 'write') || str_contains($modes, 'append'); - + Log::debug('[ACL] Writable check', [ - 'url' => $url, - 'wac_allow' => $wacAllow, + 'url' => $url, + 'wac_allow' => $wacAllow, 'is_writable' => $isWritable, ]); - + return $isWritable; } return false; } catch (\Throwable $e) { Log::debug('[ACL] Writable check failed', [ - 'url' => $url, + 'url' => $url, 'error' => $e->getMessage(), ]); + return false; } } /** * Find writable storage locations from user profile. - * - * @param SolidIdentity $identity - * @param array $profile - * @return array */ public function findWritableLocations(SolidIdentity $identity, array $profile): array { $writableLocations = []; - $webId = $profile['webid'] ?? null; - + $webId = $profile['webid'] ?? null; + if (!$webId) { return $writableLocations; } // Get pod URL from WebID $podUrl = $this->podService->getPodUrlFromWebId($webId); - + // Check common storage locations $commonLocations = [ - 'public' => rtrim($podUrl, '/') . '/public/', + 'public' => rtrim($podUrl, '/') . '/public/', 'private' => rtrim($podUrl, '/') . '/private/', - 'inbox' => rtrim($podUrl, '/') . '/inbox/', + 'inbox' => rtrim($podUrl, '/') . '/inbox/', ]; foreach ($commonLocations as $name => $url) { @@ -371,7 +363,7 @@ public function findWritableLocations(SolidIdentity $identity, array $profile): } Log::info('[ACL] Found writable locations', [ - 'count' => count($writableLocations), + 'count' => count($writableLocations), 'locations' => $writableLocations, ]); @@ -380,11 +372,6 @@ public function findWritableLocations(SolidIdentity $identity, array $profile): /** * Resolve a storage URL from profile data. - * - * @param string $storage - * @param string $webId - * @param string $podUrl - * @return string|null */ protected function resolveStorageUrl(string $storage, string $webId, string $podUrl): ?string { @@ -400,6 +387,7 @@ protected function resolveStorageUrl(string $storage, string $webId, string $pod // Handle relative paths $webIdBase = dirname($webId); + return rtrim($webIdBase, '/') . '/' . ltrim($storage, '/'); } } diff --git a/server/src/Services/PodService.php b/server/src/Services/PodService.php index 13357da..c608619 100644 --- a/server/src/Services/PodService.php +++ b/server/src/Services/PodService.php @@ -2,7 +2,6 @@ namespace Fleetbase\Solid\Services; -use Fleetbase\Solid\Client\SolidClient; use Fleetbase\Solid\Models\SolidIdentity; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -51,7 +50,12 @@ public function createPod(SolidIdentity $identity, string $name, ?string $descri */ private function getStorageUrlFromWebId(string $webId): string { - $parsed = parse_url($webId); + $parsed = parse_url($webId); + + if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) { + throw new \InvalidArgumentException("Invalid WebID format: {$webId}"); + } + $baseUrl = $parsed['scheme'] . '://' . $parsed['host']; if (isset($parsed['port'])) { @@ -117,114 +121,6 @@ private function createPodInStorage(SolidIdentity $identity, string $storageUrl, Log::info('[CREATING POD]', ['name' => $name, 'storage_url' => $storageUrl, 'pod_url' => $podUrl]); - // Check if identity has CSS credentials for account management API - $cssAccountService = app(CssAccountService::class); - - if ($cssAccountService->hasCredentials($identity)) { - Log::info('[USING CSS ACCOUNT MANAGEMENT API]'); - - try { - // Get WebID and extract issuer from it - $tokenResponse = $identity->token_response; - $idToken = data_get($tokenResponse, 'id_token'); - - if (!$idToken) { - throw new \Exception('No ID token available'); - } - - $solid = SolidClient::create(['identity' => $identity]); - $webId = $solid->oidc->getWebIdFromIdToken($idToken); - - if (!$webId) { - throw new \Exception('Could not extract WebID from ID token'); - } - - // Extract issuer from WebID URL - $parsed = parse_url($webId); - $issuer = $parsed['scheme'] . '://' . $parsed['host']; - if (isset($parsed['port'])) { - $issuer .= ':' . $parsed['port']; - } - - // Use email/password login to get CSS-Account-Token for pod management - $email = $identity->css_email; - $password = decrypt($identity->css_password); - - Log::info('[CSS POD CREATION] Logging in with email/password for pod management'); - - $authorization = $cssAccountService->login($issuer, $email, $password); - - if (!$authorization) { - throw new \Exception('Failed to login to CSS account for pod creation'); - } - - // Get the account API controls to find the pod creation endpoint - $controlsResponse = \Illuminate\Support\Facades\Http::withHeaders([ - 'Authorization' => "CSS-Account-Token {$authorization}", - ])->get("{$issuer}/.account/"); - - if (!$controlsResponse->successful()) { - throw new \Exception('Failed to get account controls'); - } - - $controls = $controlsResponse->json(); - - Log::info('[CSS ACCOUNT CONTROLS]', ['controls' => $controls]); - - $podControlUrl = data_get($controls, 'controls.account.pod'); - - if (!$podControlUrl) { - Log::warning('[POD CONTROL URL NOT FOUND]', ['controls_keys' => array_keys($controls)]); - // Fall back to legacy methods - throw new \Exception('Pod control URL not found - falling back to legacy methods'); - } - - Log::info('[CSS POD CONTROL URL]', ['url' => $podControlUrl]); - - // Use the account management API to create pod - $response = \Illuminate\Support\Facades\Http::withHeaders([ - 'Authorization' => "CSS-Account-Token {$authorization}", - 'Content-Type' => 'application/json', - ])->post($podControlUrl, [ - 'name' => $podSlug, - ]); - - Log::info('[CSS ACCOUNT API RESPONSE]', [ - 'status' => $response->status(), - 'body' => $response->body(), - ]); - - if ($response->successful()) { - $podData = $response->json(); - - return [ - 'id' => $podSlug, - 'name' => $name, - 'url' => $podData['pod'] ?? $podUrl, - 'description' => $description, - 'created_at' => now()->toISOString(), - 'type' => 'pod', - 'status' => 'created', - 'method' => 'css_account_api', - ]; - } - - Log::warning('[CSS ACCOUNT API FAILED]', [ - 'status' => $response->status(), - 'error' => $response->body(), - ]); - } catch (\Throwable $e) { - Log::error('[CSS ACCOUNT API ERROR]', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - // Fall through to legacy methods - } - } else { - Log::info('[NO CSS CREDENTIALS - USING LEGACY METHODS]'); - } - - // Legacy methods as fallback $metadata = $this->generatePodMetadata($name, $description); // ---- Method 1: POST to parent (recommended) ---- @@ -342,68 +238,8 @@ public function getUserPods(SolidIdentity $identity): array try { $profile = $this->getProfileData($identity); $storageUrl = $this->getStorageUrlFromWebId($profile['webid']); - $webId = $profile['webid']; $pods = []; - - // Try to get pods from CSS Account Management API first - $cssAccountService = app(CssAccountService::class); - if ($cssAccountService->hasCredentials($identity)) { - try { - // Extract issuer from WebID - $parsed = parse_url($webId); - $issuer = $parsed['scheme'] . '://' . $parsed['host']; - if (isset($parsed['port'])) { - $issuer .= ':' . $parsed['port']; - } - - $email = $identity->css_email; - $password = decrypt($identity->css_password); - - $authorization = $cssAccountService->login($issuer, $email, $password); - - if ($authorization) { - // Get account controls - $controlsResponse = \Illuminate\Support\Facades\Http::withHeaders([ - 'Authorization' => "CSS-Account-Token {$authorization}", - ])->get("{$issuer}/.account/"); - - if ($controlsResponse->successful()) { - $controls = $controlsResponse->json(); - $podControlUrl = data_get($controls, 'controls.account.pod'); - - if ($podControlUrl) { - // Get pods from account management API - $podsResponse = \Illuminate\Support\Facades\Http::withHeaders([ - 'Authorization' => "CSS-Account-Token {$authorization}", - ])->get($podControlUrl); - - if ($podsResponse->successful()) { - $podsData = $podsResponse->json(); - $accountPods = data_get($podsData, 'pods', []); - - Log::info('[CSS ACCOUNT PODS]', ['pods' => $accountPods]); - - foreach ($accountPods as $podUrl => $accountUrl) { - $podName = $this->extractPodName($podUrl); - $pods[] = [ - 'id' => Str::slug($podName), - 'name' => $podName, - 'url' => $podUrl, - 'type' => 'pod', - 'source' => 'css_account', - ]; - } - } - } - } - } - } catch (\Throwable $e) { - Log::warning('[CSS ACCOUNT PODS FETCH WARNING]', [ - 'error' => $e->getMessage(), - ]); - } - } // Get the main storage pod try { @@ -464,7 +300,7 @@ public function getPodContents(SolidIdentity $identity, string $podIdOrUrl): arr } catch (\Throwable $e) { Log::error('[GET POD CONTENTS ERROR]', [ 'pod_id_or_url' => $podIdOrUrl, - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); throw $e; } @@ -512,16 +348,9 @@ public function deletePod(SolidIdentity $identity, string $podId): bool */ public function getProfileData(SolidIdentity $identity): array { - $tokenResponse = $identity->token_response; - $idToken = data_get($tokenResponse, 'id_token'); - if (!$idToken) { - throw new \Exception('No ID token available'); - } - - $solid = SolidClient::create(['identity' => $identity]); - $webId = $solid->oidc->getWebIdFromIdToken($idToken); + $webId = $identity->getWebId(); if (!$webId) { - throw new \Exception('No WebID found'); + throw new \Exception('No WebID is available for this identity'); } // IMPORTANT: fetch the *document* (strip #me) @@ -720,10 +549,10 @@ private function parseContainerContents(string $content): array 'name' => $this->extractPodName($resourceUrl), 'type' => substr($resourceUrl, -1) === '/' ? 'container' : 'resource', ]; - + // Try to extract additional metadata for this resource $item = array_merge($item, $this->extractResourceMetadata($content, $resourceUrl)); - + $items[] = $item; } } @@ -737,30 +566,30 @@ private function parseContainerContents(string $content): array private function extractResourceMetadata(string $content, string $resourceUrl): array { $metadata = []; - + // Escape special regex characters in URL $escapedUrl = preg_quote($resourceUrl, '/'); - + // Extract resource type (e.g., ldp:BasicContainer, foaf:Document) if (preg_match('/<' . $escapedUrl . '>\s+a\s+([^;\s]+)/', $content, $matches)) { $metadata['rdf_type'] = trim($matches[1]); } - + // Extract dc:title if (preg_match('/<' . $escapedUrl . '>.*?dc:title\s+"([^"]+)"/', $content, $matches)) { $metadata['title'] = $matches[1]; } - + // Extract dc:modified or posix:mtime if (preg_match('/<' . $escapedUrl . '>.*?(?:dc:modified|posix:mtime)\s+(\d+)/', $content, $matches)) { - $metadata['modified'] = (int)$matches[1]; + $metadata['modified'] = (int) $matches[1]; } - + // Extract posix:size if (preg_match('/<' . $escapedUrl . '>.*?posix:size\s+(\d+)/', $content, $matches)) { - $metadata['size'] = (int)$matches[1]; + $metadata['size'] = (int) $matches[1]; } - + return $metadata; } @@ -788,28 +617,29 @@ private function generatePodMetadata(string $name, ?string $description = null): /** * Get pod URL from WebID. - * - * @param string $webId - * @return string */ public function getPodUrlFromWebId(string $webId): string { // Extract pod URL from WebID - // WebID format: http://solid:3000/test/profile/card#me - // Pod URL: http://solid:3000/test/ - + // WebID format: https://example-solid-server.com/username/profile/card#me + // Pod URL: https://example-solid-server.com/username/ + $parsed = parse_url($webId); + + if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) { + throw new \InvalidArgumentException("Invalid WebID format: {$webId}"); + } $path = $parsed['path'] ?? ''; - + // Remove /profile/card from the path $podPath = preg_replace('#/profile/card.*$#', '/', $path); - + $podUrl = $parsed['scheme'] . '://' . $parsed['host']; if (isset($parsed['port'])) { $podUrl .= ':' . $parsed['port']; } $podUrl .= $podPath; - + return $podUrl; } @@ -821,9 +651,9 @@ public function createFolder(SolidIdentity $identity, string $parentUrl, string try { // Ensure parent URL ends with / $parentUrl = rtrim($parentUrl, '/') . '/'; - + Log::info('[CREATE FOLDER]', [ - 'parent_url' => $parentUrl, + 'parent_url' => $parentUrl, 'folder_name' => $folderName, ]); @@ -831,8 +661,8 @@ public function createFolder(SolidIdentity $identity, string $parentUrl, string $response = $identity->request('post', $parentUrl, '', [ 'headers' => [ 'Content-Type' => 'text/turtle', - 'Link' => '; rel="type"', - 'Slug' => $folderName, + 'Link' => '; rel="type"', + 'Slug' => $folderName, ], ]); @@ -840,13 +670,13 @@ public function createFolder(SolidIdentity $identity, string $parentUrl, string $createdUrl = $response->header('Location') ?? $parentUrl . $folderName . '/'; Log::info('[FOLDER CREATED]', [ 'folder_url' => $createdUrl, - 'status' => $response->status(), + 'status' => $response->status(), ]); // Ensure the folder has proper ACL permissions $aclService = app(AclService::class); - $webId = $identity->webid; - + $webId = $identity->webid; + if ($webId) { $aclService->ensureFolderPermissions($identity, $createdUrl, $webId); } @@ -855,17 +685,17 @@ public function createFolder(SolidIdentity $identity, string $parentUrl, string } Log::error('[FOLDER CREATE FAILED]', [ - 'parent_url' => $parentUrl, + 'parent_url' => $parentUrl, 'folder_name' => $folderName, - 'status' => $response->status(), - 'body' => $response->body(), + 'status' => $response->status(), + 'body' => $response->body(), ]); return false; } catch (\Throwable $e) { Log::error('[FOLDER CREATE ERROR]', [ 'folder_url' => $folderUrl, - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); throw $e; } @@ -887,22 +717,23 @@ public function deleteResource(SolidIdentity $identity, string $resourceUrl): bo if ($response->successful()) { Log::info('[RESOURCE DELETED]', [ 'resource_url' => $resourceUrl, - 'status' => $response->status(), + 'status' => $response->status(), ]); + return true; } Log::error('[RESOURCE DELETE FAILED]', [ 'resource_url' => $resourceUrl, - 'status' => $response->status(), - 'body' => $response->body(), + 'status' => $response->status(), + 'body' => $response->body(), ]); return false; } catch (\Throwable $e) { Log::error('[RESOURCE DELETE ERROR]', [ 'resource_url' => $resourceUrl, - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); throw $e; } diff --git a/server/src/Services/ResourceSyncService.php b/server/src/Services/ResourceSyncService.php index d37e771..660b33f 100644 --- a/server/src/Services/ResourceSyncService.php +++ b/server/src/Services/ResourceSyncService.php @@ -3,40 +3,34 @@ namespace Fleetbase\Solid\Services; use Fleetbase\Solid\Models\SolidIdentity; -use Fleetbase\Solid\Client\SolidClient; use Illuminate\Support\Facades\Log; class ResourceSyncService { /** * Import resources into a pod. - * - * @param SolidIdentity $identity - * @param string $podUrl - * @param array $resourceTypes - * @return array */ public function importResources(SolidIdentity $identity, string $podUrl, array $resourceTypes): array { - $imported = []; - $errors = []; + $imported = []; + $errors = []; $totalCount = 0; foreach ($resourceTypes as $resourceType) { try { Log::info('[IMPORTING RESOURCE TYPE]', ['type' => $resourceType, 'pod_url' => $podUrl]); - - $result = $this->importResourceType($identity, $podUrl, $resourceType); + + $result = $this->importResourceType($identity, $podUrl, $resourceType); $imported[$resourceType] = $result['count']; $totalCount += $result['count']; - + Log::info('[RESOURCE TYPE IMPORTED]', [ - 'type' => $resourceType, + 'type' => $resourceType, 'count' => $result['count'], ]); } catch (\Throwable $e) { Log::error('[RESOURCE IMPORT ERROR]', [ - 'type' => $resourceType, + 'type' => $resourceType, 'error' => $e->getMessage(), ]); $errors[$resourceType] = $e->getMessage(); @@ -44,25 +38,20 @@ public function importResources(SolidIdentity $identity, string $podUrl, array $ } return [ - 'imported' => $imported, - 'errors' => $errors, + 'imported' => $imported, + 'errors' => $errors, 'total_count' => $totalCount, ]; } /** * Import a specific resource type. - * - * @param SolidIdentity $identity - * @param string $podUrl - * @param string $resourceType - * @return array */ protected function importResourceType(SolidIdentity $identity, string $podUrl, string $resourceType): array { // Get resources from Fleetops $resources = $this->getFleetopsResources($resourceType); - + if (empty($resources)) { return ['count' => 0]; } @@ -75,15 +64,15 @@ protected function importResourceType(SolidIdentity $identity, string $podUrl, s $count = 0; foreach ($resources as $resource) { try { - $turtle = $this->convertToRDF($resourceType, $resource); + $turtle = $this->convertToRDF($resourceType, $resource); $resourceUrl = $containerUrl . $resource->public_id . '.ttl'; - + $this->storeResource($identity, $resourceUrl, $turtle); $count++; } catch (\Throwable $e) { Log::warning('[RESOURCE IMPORT FAILED]', [ - 'type' => $resourceType, - 'id' => $resource->public_id ?? 'unknown', + 'type' => $resourceType, + 'id' => $resource->public_id ?? 'unknown', 'error' => $e->getMessage(), ]); } @@ -95,16 +84,15 @@ protected function importResourceType(SolidIdentity $identity, string $podUrl, s /** * Get Fleetops resources by type. * - * @param string $resourceType * @return \Illuminate\Support\Collection */ protected function getFleetopsResources(string $resourceType) { $modelMap = [ 'vehicles' => \Fleetbase\FleetOps\Models\Vehicle::class, - 'drivers' => \Fleetbase\FleetOps\Models\Driver::class, + 'drivers' => \Fleetbase\FleetOps\Models\Driver::class, 'contacts' => \Fleetbase\FleetOps\Models\Contact::class, - 'orders' => \Fleetbase\FleetOps\Models\Order::class, + 'orders' => \Fleetbase\FleetOps\Models\Order::class, ]; if (!isset($modelMap[$resourceType])) { @@ -112,16 +100,16 @@ protected function getFleetopsResources(string $resourceType) } $modelClass = $modelMap[$resourceType]; - + // Get current company's resources $companyId = session('company'); - + Log::info('[FETCHING FLEETOPS RESOURCES]', [ 'resource_type' => $resourceType, - 'model_class' => $modelClass, - 'company_id' => $companyId, + 'model_class' => $modelClass, + 'company_id' => $companyId, ]); - + if (!$companyId) { Log::warning('[NO COMPANY ID]', ['session' => session()->all()]); // Try to get from auth user @@ -131,36 +119,33 @@ protected function getFleetopsResources(string $resourceType) Log::info('[USING USER COMPANY]', ['company_id' => $companyId]); } } - + if (!$companyId) { Log::error('[CANNOT DETERMINE COMPANY]'); + return collect([]); } - + $query = $modelClass::where('company_uuid', $companyId) ->limit(100); // Limit for now to avoid overwhelming the pod - + $count = $query->count(); Log::info('[RESOURCE QUERY]', [ 'resource_type' => $resourceType, - 'count' => $count, - 'sql' => $query->toSql(), - 'bindings' => $query->getBindings(), + 'count' => $count, + 'sql' => $query->toSql(), + 'bindings' => $query->getBindings(), ]); - + return $query->get(); } /** * Convert a resource to RDF/Turtle format. - * - * @param string $resourceType - * @param mixed $resource - * @return string */ protected function convertToRDF(string $resourceType, $resource): string { - $baseUri = "http://fleetbase.io/ns/{$resourceType}/"; + $baseUri = "http://fleetbase.io/ns/{$resourceType}/"; $resourceUri = $baseUri . $resource->public_id; $turtle = "@prefix rdf: .\n"; @@ -189,9 +174,6 @@ protected function convertToRDF(string $resourceType, $resource): string /** * Get RDF class name for resource type. - * - * @param string $resourceType - * @return string */ protected function getResourceClass(string $resourceType): string { @@ -200,44 +182,40 @@ protected function getResourceClass(string $resourceType): string /** * Get properties for a resource type. - * - * @param string $resourceType - * @param mixed $resource - * @return array */ protected function getResourceProperties(string $resourceType, $resource): array { switch ($resourceType) { case 'vehicles': return [ - 'name' => $resource->name, - 'make' => $resource->make, - 'model' => $resource->model, - 'year' => $resource->year, - 'vin' => $resource->vin, + 'name' => $resource->name, + 'make' => $resource->make, + 'model' => $resource->model, + 'year' => $resource->year, + 'vin' => $resource->vin, 'plate_number' => $resource->plate_number, - 'status' => $resource->status, - 'created_at' => $resource->created_at?->toIso8601String(), - 'updated_at' => $resource->updated_at?->toIso8601String(), + 'status' => $resource->status, + 'created_at' => $resource->created_at?->toIso8601String(), + 'updated_at' => $resource->updated_at?->toIso8601String(), ]; case 'drivers': return [ - 'name' => $resource->name, - 'email' => $resource->email, - 'phone' => $resource->phone, + 'name' => $resource->name, + 'email' => $resource->email, + 'phone' => $resource->phone, 'license_number' => $resource->drivers_license_number, - 'status' => $resource->status, - 'created_at' => $resource->created_at?->toIso8601String(), - 'updated_at' => $resource->updated_at?->toIso8601String(), + 'status' => $resource->status, + 'created_at' => $resource->created_at?->toIso8601String(), + 'updated_at' => $resource->updated_at?->toIso8601String(), ]; case 'contacts': return [ - 'name' => $resource->name, - 'email' => $resource->email, - 'phone' => $resource->phone, - 'type' => $resource->type, + 'name' => $resource->name, + 'email' => $resource->email, + 'phone' => $resource->phone, + 'type' => $resource->type, 'created_at' => $resource->created_at?->toIso8601String(), 'updated_at' => $resource->updated_at?->toIso8601String(), ]; @@ -245,11 +223,11 @@ protected function getResourceProperties(string $resourceType, $resource): array case 'orders': return [ 'tracking_number' => $resource->public_id, - 'status' => $resource->status, - 'type' => $resource->type, - 'scheduled_at' => $resource->scheduled_at?->toIso8601String(), - 'created_at' => $resource->created_at?->toIso8601String(), - 'updated_at' => $resource->updated_at?->toIso8601String(), + 'status' => $resource->status, + 'type' => $resource->type, + 'scheduled_at' => $resource->scheduled_at?->toIso8601String(), + 'created_at' => $resource->created_at?->toIso8601String(), + 'updated_at' => $resource->updated_at?->toIso8601String(), ]; default: @@ -259,9 +237,6 @@ protected function getResourceProperties(string $resourceType, $resource): array /** * Format a value for RDF. - * - * @param mixed $value - * @return string */ protected function formatRDFValue($value): string { @@ -274,16 +249,13 @@ protected function formatRDFValue($value): string } // Escape quotes and special characters - $escaped = str_replace(['"', '\\'], ['\\"', '\\\\'], (string)$value); + $escaped = str_replace(['"', '\\'], ['\\"', '\\\\'], (string) $value); + return "\"{$escaped}\""; } /** * Create a container in the pod. - * - * @param SolidIdentity $identity - * @param string $containerUrl - * @return void */ protected function createContainer(SolidIdentity $identity, string $containerUrl): void { @@ -291,26 +263,26 @@ protected function createContainer(SolidIdentity $identity, string $containerUrl $response = $identity->request('put', $containerUrl, '', [ 'headers' => [ 'Content-Type' => 'text/turtle', - 'Link' => '; rel="type"', + 'Link' => '; rel="type"', ], ]); Log::info('[CONTAINER CREATED]', [ - 'url' => $containerUrl, + 'url' => $containerUrl, 'status' => $response->status(), ]); // Ensure the container has proper ACL permissions $aclService = app(AclService::class); - $webId = $identity->webid; - + $webId = $identity->webid; + if ($webId) { $aclService->ensureFolderPermissions($identity, $containerUrl, $webId); } } catch (\Throwable $e) { // Container might already exist, that's okay Log::debug('[CONTAINER CREATION SKIPPED]', [ - 'url' => $containerUrl, + 'url' => $containerUrl, 'reason' => $e->getMessage(), ]); } @@ -318,11 +290,6 @@ protected function createContainer(SolidIdentity $identity, string $containerUrl /** * Store a resource in the pod. - * - * @param SolidIdentity $identity - * @param string $resourceUrl - * @param string $turtle - * @return void */ protected function storeResource(SolidIdentity $identity, string $resourceUrl, string $turtle): void { @@ -337,7 +304,7 @@ protected function storeResource(SolidIdentity $identity, string $resourceUrl, s } Log::debug('[RESOURCE STORED]', [ - 'url' => $resourceUrl, + 'url' => $resourceUrl, 'status' => $response->status(), ]); } diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php index ebaa4e2..fb617b8 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -2,6 +2,7 @@ namespace Fleetbase\Solid\Support; +use Fleetbase\Solid\Client\SolidClient; use Fleetbase\Support\Utils as FleetbaseUtils; use Illuminate\Support\Str; @@ -46,12 +47,15 @@ public static function searchPods(array $data = [], string $key, string $value, } /** - * Get the Solid server URL from configuration. + * Get the Solid server URL. * - * @return string + * Delegates to SolidClient so there is exactly one answer. Building it here + * from `config('solid.server.*')` — as this did — ignores the host and port + * an administrator saved through the console, which SolidClient honours, so + * the two would report different servers. */ public static function getSolidServerUrl(): string { - return config('solid.server.url', 'http://localhost:3000'); + return SolidClient::serverUrl(); } } diff --git a/server/src/routes.php b/server/src/routes.php index c66b9e3..65516ee 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -24,21 +24,18 @@ function ($router) { */ $router->prefix(config('solid.api.routing.internal_prefix', 'int'))->group( function ($router) { - $router->get('test', 'SolidController@play'); $router->group( ['prefix' => 'v1'], function ($router) { $router->get('authenticate/{identifier}', 'SolidController@authenticate'); $router->group(['middleware' => ['fleetbase.protected']], function ($router) { // Authentication status and management - $router->get('account', 'SolidController@getAccountIndex'); $router->get('request-authentication', 'SolidController@requestAuthentication'); $router->get('authentication-status', 'SolidController@getAuthenticationStatus'); $router->post('logout', 'SolidController@logout'); - // Account and profile + // Account $router->get('account', 'SolidController@getAccountIndex'); - $router->get('profile', 'SolidController@getProfileData'); // Data management routes (single-pod architecture) $router->get('data', 'DataController@index'); @@ -47,15 +44,6 @@ function ($router) { $router->delete('data/{type}/{slug}', 'DataController@deleteItem'); $router->post('data/import', 'DataController@importResources'); - - - // Resource sync endpoints - $router->get('sync-status', 'SolidController@getSyncStatus'); - $router->post('sync-vehicles', 'SolidController@syncVehicles'); - $router->post('sync-drivers', 'SolidController@syncDrivers'); - $router->post('sync-orders', 'SolidController@syncOrders'); - $router->post('sync-all', 'SolidController@syncAll'); - // Server configuration $router->get('server-config', 'SolidController@getServerConfig'); $router->post('server-config', 'SolidController@saveServerConfig'); diff --git a/server/tests/Feature.php b/server/tests/Feature.php deleted file mode 100644 index 61cd84c..0000000 --- a/server/tests/Feature.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/server/tests/Support/ArrayJwksResolver.php b/server/tests/Support/ArrayJwksResolver.php new file mode 100644 index 0000000..7fc7a32 --- /dev/null +++ b/server/tests/Support/ArrayJwksResolver.php @@ -0,0 +1,43 @@ + $cached + * @param array|null $afterRefresh + */ + public function __construct( + private readonly array $cached, + private readonly ?array $afterRefresh = null, + ) { + } + + public function resolve(string $jwksUri, bool $forceRefresh = false): array + { + $this->resolveCount++; + + if ($forceRefresh) { + $this->refreshCount++; + + return $this->afterRefresh ?? $this->cached; + } + + return $this->cached; + } +} diff --git a/server/tests/Support/ArrayOidcTransport.php b/server/tests/Support/ArrayOidcTransport.php new file mode 100644 index 0000000..acf3bd9 --- /dev/null +++ b/server/tests/Support/ArrayOidcTransport.php @@ -0,0 +1,108 @@ +> + */ + private array $queues = []; + + /** + * @var array}> + */ + public array $requests = []; + + /** + * Queue a response for a URL. Call it more than once to script a sequence. + * + * @param array $body + * @param array $headers + */ + public function queue(string $url, array $body, int $status = 200, array $headers = []): self + { + $this->queues[$url][] = new OidcResponse($status, (string) json_encode($body), $headers); + + return $this; + } + + /** + * Queue a response with a literal body, for the cases where the body is not + * valid JSON. + * + * @param array $headers + */ + public function queueRaw(string $url, string $body, int $status = 200, array $headers = []): self + { + $this->queues[$url][] = new OidcResponse($status, $body, $headers); + + return $this; + } + + public function get(string $url, array $headers = []): OidcResponse + { + return $this->record('GET', $url, null, $headers); + } + + public function postJson(string $url, array $body, array $headers = []): OidcResponse + { + return $this->record('POST', $url, $body, $headers); + } + + public function postForm(string $url, array $fields, array $headers = []): OidcResponse + { + return $this->record('POST', $url, $fields, $headers); + } + + /** + * The last request sent to a URL, or null if there was none. + * + * @return array{method: string, url: string, body: mixed, headers: array}|null + */ + public function lastRequestTo(string $url): ?array + { + foreach (array_reverse($this->requests) as $request) { + if ($request['url'] === $url) { + return $request; + } + } + + return null; + } + + public function countRequestsTo(string $url): int + { + return count(array_filter($this->requests, static fn (array $request): bool => $request['url'] === $url)); + } + + /** + * @param array $headers + */ + private function record(string $method, string $url, mixed $body, array $headers): OidcResponse + { + $this->requests[] = compact('method', 'url', 'body', 'headers'); + + if (empty($this->queues[$url])) { + throw new OpenIDConnectClientException('No response was queued for ' . $method . ' ' . $url . '.'); + } + + // The final queued response is reused, so a test only has to script the + // calls whose answers actually change. + return count($this->queues[$url]) === 1 + ? $this->queues[$url][0] + : array_shift($this->queues[$url]); + } +} diff --git a/server/tests/Support/ArrayStateStore.php b/server/tests/Support/ArrayStateStore.php new file mode 100644 index 0000000..9e8649c --- /dev/null +++ b/server/tests/Support/ArrayStateStore.php @@ -0,0 +1,53 @@ + + */ + private array $entries = []; + + public function get(string $key): ?string + { + return $this->entries[$key]['value'] ?? null; + } + + public function put(string $key, string $value, int $ttlSeconds): void + { + $this->entries[$key] = ['value' => $value, 'ttl' => $ttlSeconds]; + } + + public function forget(string $key): void + { + unset($this->entries[$key]); + } + + public function ttl(string $key): ?int + { + return $this->entries[$key]['ttl'] ?? null; + } + + public function has(string $key): bool + { + return isset($this->entries[$key]); + } + + /** + * @return array + */ + public function keys(): array + { + return array_keys($this->entries); + } +} diff --git a/server/tests/Support/OidcTestCase.php b/server/tests/Support/OidcTestCase.php new file mode 100644 index 0000000..d99419e --- /dev/null +++ b/server/tests/Support/OidcTestCase.php @@ -0,0 +1,43 @@ +instance('log', new NullLogger()); + + Container::setInstance($container); + Facade::setFacadeApplication($container); + + // The memos are static, so one test's provider must not leak into the next. + OpenIDConnectClient::flushProviderCaches(); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + Container::setInstance(null); + + parent::tearDown(); + } +} diff --git a/server/tests/Support/TestSigner.php b/server/tests/Support/TestSigner.php new file mode 100644 index 0000000..736e48d --- /dev/null +++ b/server/tests/Support/TestSigner.php @@ -0,0 +1,172 @@ +}> + */ + private static array $keys = []; + + /** + * An RSA or EC key for a key id, generated on first use. + * + * @param 'RS256'|'ES256' $algorithm + * + * @return array{private: string, jwk: array} + */ + public static function key(string $kid, string $algorithm = 'RS256'): array + { + $cacheKey = $kid . ':' . $algorithm; + + if (isset(self::$keys[$cacheKey])) { + return self::$keys[$cacheKey]; + } + + $resource = openssl_pkey_new($algorithm === 'ES256' + ? ['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1'] + : ['private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]); + + if ($resource === false) { + throw new \RuntimeException('Unable to generate a test key: ' . openssl_error_string()); + } + + $privateKey = ''; + openssl_pkey_export($resource, $privateKey); + + $details = openssl_pkey_get_details($resource); + + if (!is_array($details)) { + throw new \RuntimeException('Unable to read the generated test key.'); + } + + $jwk = $algorithm === 'ES256' + ? [ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => self::b64(str_pad((string) $details['ec']['x'], 32, "\0", STR_PAD_LEFT)), + 'y' => self::b64(str_pad((string) $details['ec']['y'], 32, "\0", STR_PAD_LEFT)), + ] + : [ + 'kty' => 'RSA', + 'n' => self::b64((string) $details['rsa']['n']), + 'e' => self::b64((string) $details['rsa']['e']), + ]; + + return self::$keys[$cacheKey] = [ + 'private' => $privateKey, + 'jwk' => $jwk + ['kid' => $kid, 'alg' => $algorithm, 'use' => 'sig'], + ]; + } + + /** + * A JWKS document containing the named keys. + * + * @param array $keys pairs of [kid, algorithm] + * + * @return array{keys: array>} + */ + public static function jwks(array $keys): array + { + return [ + 'keys' => array_map( + static fn (array $key): array => self::key($key[0], $key[1] ?? 'RS256')['jwk'], + $keys + ), + ]; + } + + /** + * Sign a token with a named key. + * + * @param array $claims + * @param 'RS256'|'ES256' $algorithm + */ + public static function sign(array $claims, string $kid = 'key-1', string $algorithm = 'RS256'): string + { + return JWT::encode($claims, self::key($kid, $algorithm)['private'], $algorithm, $kid); + } + + /** + * Sign with one key while claiming to be another, so the signature is valid + * but does not match the key the verifier will resolve. + * + * @param array $claims + */ + public static function signWithWrongKey(array $claims, string $claimedKid, string $actualKid): string + { + $jwt = JWT::encode($claims, self::key($actualKid)['private'], 'RS256', $actualKid); + + // Rewrite only the `kid` so the header still says RS256 and the verifier + // fetches the wrong public key. + [, $payload, $signature] = explode('.', $jwt); + + $header = self::b64((string) json_encode(['typ' => 'JWT', 'alg' => 'RS256', 'kid' => $claimedKid])); + + return $header . '.' . $payload . '.' . $signature; + } + + /** + * A token with a header and payload but no real signature, for the `alg: none` + * and algorithm-confusion cases. + * + * @param array $header + * @param array $claims + */ + public static function unsigned(array $header, array $claims, string $signature = ''): string + { + return self::b64((string) json_encode($header)) + . '.' . self::b64((string) json_encode($claims)) + . '.' . $signature; + } + + /** + * An HMAC-signed token whose secret is the RSA public key — the classic + * algorithm-confusion attack. + * + * @param array $claims + */ + public static function hmacSignedWithPublicKey(array $claims, string $kid = 'key-1'): string + { + $details = openssl_pkey_get_details(openssl_pkey_get_private(self::key($kid)['private'])); + $publicPem = is_array($details) ? (string) $details['key'] : ''; + + return JWT::encode($claims, $publicPem, 'HS256', $kid); + } + + /** + * Claims for a token that should pass every check. + * + * @param array $overrides + * + * @return array + */ + public static function claims(array $overrides = []): array + { + return array_merge([ + 'iss' => 'https://pod.example.test', + 'aud' => 'client-abc', + 'sub' => 'https://pod.example.test/alice/profile/card#me', + 'webid' => 'https://pod.example.test/alice/profile/card#me', + 'nonce' => 'nonce-value', + 'iat' => time() - 5, + 'exp' => time() + 600, + ], $overrides); + } + + private static function b64(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/server/tests/Unit/CachedJwksResolverTest.php b/server/tests/Unit/CachedJwksResolverTest.php new file mode 100644 index 0000000..fd47c18 --- /dev/null +++ b/server/tests/Unit/CachedJwksResolverTest.php @@ -0,0 +1,120 @@ +instance('cache', new Repository(new ArrayStore())); + + Container::setInstance($container); + Facade::setFacadeApplication($container); + + $this->transport = new ArrayOidcTransport(); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + Container::setInstance(null); + + parent::tearDown(); + } + + public function testItFetchesAndReturnsTheJwks(): void + { + $this->transport->queue(self::JWKS_URI, ['keys' => [['kid' => 'key-1']]]); + + $jwks = (new CachedJwksResolver($this->transport))->resolve(self::JWKS_URI); + + $this->assertSame([['kid' => 'key-1']], $jwks['keys']); + } + + public function testItServesASecondCallFromTheCache(): void + { + $this->transport->queue(self::JWKS_URI, ['keys' => []]); + + $resolver = new CachedJwksResolver($this->transport); + $resolver->resolve(self::JWKS_URI); + $resolver->resolve(self::JWKS_URI); + + $this->assertSame(1, $this->transport->countRequestsTo(self::JWKS_URI)); + } + + public function testAForcedRefreshBypassesTheCache(): void + { + // This is what makes a rotated signing key usable before the TTL lapses. + $this->transport->queue(self::JWKS_URI, ['keys' => [['kid' => 'key-1']]]); + $this->transport->queue(self::JWKS_URI, ['keys' => [['kid' => 'key-2']]]); + + $resolver = new CachedJwksResolver($this->transport); + + $this->assertSame([['kid' => 'key-1']], $resolver->resolve(self::JWKS_URI)['keys']); + $this->assertSame([['kid' => 'key-2']], $resolver->resolve(self::JWKS_URI, true)['keys']); + $this->assertSame(2, $this->transport->countRequestsTo(self::JWKS_URI)); + } + + public function testARefreshedDocumentReplacesTheCachedOne(): void + { + $this->transport->queue(self::JWKS_URI, ['keys' => [['kid' => 'key-1']]]); + $this->transport->queue(self::JWKS_URI, ['keys' => [['kid' => 'key-2']]]); + + $resolver = new CachedJwksResolver($this->transport); + $resolver->resolve(self::JWKS_URI); + $resolver->resolve(self::JWKS_URI, true); + + $this->assertSame([['kid' => 'key-2']], $resolver->resolve(self::JWKS_URI)['keys']); + } + + public function testDocumentsForDifferentProvidersDoNotShareACacheEntry(): void + { + $other = 'https://other.example.test/.oidc/jwks'; + + $this->transport->queue(self::JWKS_URI, ['keys' => [['kid' => 'mine']]]); + $this->transport->queue($other, ['keys' => [['kid' => 'theirs']]]); + + $resolver = new CachedJwksResolver($this->transport); + + $this->assertSame([['kid' => 'mine']], $resolver->resolve(self::JWKS_URI)['keys']); + $this->assertSame([['kid' => 'theirs']], $resolver->resolve($other)['keys']); + } + + public function testTheDefaultTtlIsShortEnoughToSurviveAKeyRotation(): void + { + // Matches Fleetbase\Auth\OAuth\IdTokenVerifier::JWKS_CACHE_SECONDS. + $this->assertSame(300, CachedJwksResolver::DEFAULT_TTL_SECONDS); + } + + public function testItRejectsAJwksDocumentThatIsNotJson(): void + { + $this->transport->queueRaw(self::JWKS_URI, 'not json'); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/Expected a JSON object/'); + + (new CachedJwksResolver($this->transport))->resolve(self::JWKS_URI); + } +} diff --git a/server/tests/Unit/DPoPKeyPairTest.php b/server/tests/Unit/DPoPKeyPairTest.php new file mode 100644 index 0000000..e87205c --- /dev/null +++ b/server/tests/Unit/DPoPKeyPairTest.php @@ -0,0 +1,187 @@ + + */ + private function segment(string $jwt, int $index): array + { + $parts = explode('.', $jwt); + + return (array) json_decode((string) base64_decode(strtr($parts[$index], '-_', '+/'), true), true); + } + + public function testItGeneratesAnRsaPublicJwk(): void + { + $jwk = self::$keyPair->publicJwk(); + + $this->assertSame('RSA', $jwk['kty']); + $this->assertNotSame('', $jwk['n']); + $this->assertSame('AQAB', $jwk['e']); + $this->assertStringNotContainsString('=', $jwk['n'], 'JWK members must be unpadded base64url.'); + $this->assertStringNotContainsString('+', $jwk['n']); + $this->assertStringNotContainsString('/', $jwk['n']); + } + + public function testItRoundTripsThroughItsPersistableForm(): void + { + $restored = DPoPKeyPair::fromArray(self::$keyPair->toArray()); + + $this->assertInstanceOf(DPoPKeyPair::class, $restored); + // The same key must produce the same binding, or every token bound to it + // would be rejected after a restart. + $this->assertSame(self::$keyPair->thumbprint(), $restored->thumbprint()); + } + + public function testItDeclinesToRestoreAnIncompleteStoredKey(): void + { + // A truncated record must produce a fresh key, not a broken one. + $this->assertNull(DPoPKeyPair::fromArray(null)); + $this->assertNull(DPoPKeyPair::fromArray([])); + $this->assertNull(DPoPKeyPair::fromArray(['private_key' => 'x'])); + $this->assertNull(DPoPKeyPair::fromArray(['private_key' => 'x', 'public_jwk' => ['kty' => 'RSA']])); + } + + public function testTheThumbprintIsTheRfc7638HashOfTheCanonicalJwk(): void + { + $jwk = self::$keyPair->publicJwk(); + + // RFC 7638: required members only, lexicographic order, no whitespace. + $expected = rtrim(strtr(base64_encode(hash('sha256', (string) json_encode([ + 'e' => $jwk['e'], + 'kty' => $jwk['kty'], + 'n' => $jwk['n'], + ], JSON_UNESCAPED_SLASHES), true)), '+/', '-_'), '='); + + $this->assertSame($expected, self::$keyPair->thumbprint()); + } + + public function testItSignsAProofWithTheHeadersRfc9449Requires(): void + { + $proof = self::$keyPair->proof('post', 'https://pod.example.test/token'); + $header = $this->segment($proof, 0); + + $this->assertSame('dpop+jwt', $header['typ']); + $this->assertSame('RS256', $header['alg']); + $this->assertSame(self::$keyPair->publicJwk(), $header['jwk']); + } + + public function testItBindsTheProofToTheMethodAndUri(): void + { + $claims = $this->segment(self::$keyPair->proof('post', 'https://pod.example.test/token'), 1); + + $this->assertSame('POST', $claims['htm'], 'htm is upper-case regardless of how the method was given.'); + $this->assertSame('https://pod.example.test/token', $claims['htu']); + $this->assertLessThanOrEqual(time(), $claims['iat']); + $this->assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $claims['jti']); + } + + public function testItStripsTheQueryAndFragmentFromHtu(): void + { + // RFC 9449 4.2: htu carries no query or fragment. Leaving them in makes + // the proof unusable against a server that compares htu strictly. + $claims = $this->segment( + self::$keyPair->proof('GET', 'https://pod.example.test/alice/card?a=1&b=2#me'), + 1 + ); + + $this->assertSame('https://pod.example.test/alice/card', $claims['htu']); + } + + public function testItOmitsAthWhenThereIsNoAccessToken(): void + { + // The token endpoint is called before any access token exists. + $this->assertArrayNotHasKey('ath', $this->segment(self::$keyPair->proof('POST', 'https://pod.example.test/token'), 1)); + } + + public function testItAddsAthAsTheHashOfTheAccessToken(): void + { + $accessToken = 'an-access-token'; + $claims = $this->segment(self::$keyPair->proof('GET', 'https://pod.example.test/alice/card', $accessToken), 1); + + $this->assertSame( + rtrim(strtr(base64_encode(hash('sha256', $accessToken, true)), '+/', '-_'), '='), + $claims['ath'] + ); + } + + public function testItEchoesAServerSuppliedNonce(): void + { + $claims = $this->segment(self::$keyPair->proof('POST', 'https://pod.example.test/token', null, 'server-nonce'), 1); + + $this->assertSame('server-nonce', $claims['nonce']); + } + + public function testEachProofCarriesAFreshJti(): void + { + // Replay protection depends on this. + $first = $this->segment(self::$keyPair->proof('GET', 'https://pod.example.test/a'), 1); + $second = $this->segment(self::$keyPair->proof('GET', 'https://pod.example.test/a'), 1); + + $this->assertNotSame($first['jti'], $second['jti']); + } + + public function testTheProofSignatureVerifiesAgainstThePublicKey(): void + { + $proof = self::$keyPair->proof('POST', 'https://pod.example.test/token'); + + [$header, $payload, $signature] = explode('.', $proof); + + $publicKey = openssl_pkey_get_details(openssl_pkey_get_private(self::$keyPair->toArray()['private_key']))['key']; + + $this->assertSame(1, openssl_verify( + $header . '.' . $payload, + (string) base64_decode(strtr($signature, '-_', '+/'), true), + $publicKey, + OPENSSL_ALGO_SHA256 + )); + } + + public function testATamperedProofDoesNotVerify(): void + { + $proof = self::$keyPair->proof('GET', 'https://pod.example.test/alice/card'); + + [$header, , $signature] = explode('.', $proof); + + // Swap the payload for one naming a different URI. + $forged = rtrim(strtr(base64_encode((string) json_encode([ + 'jti' => bin2hex(random_bytes(16)), + 'htm' => 'GET', + 'htu' => 'https://attacker.example.test/', + 'iat' => time(), + ])), '+/', '-_'), '='); + + $publicKey = openssl_pkey_get_details(openssl_pkey_get_private(self::$keyPair->toArray()['private_key']))['key']; + + $this->assertSame(0, openssl_verify( + $header . '.' . $forged, + (string) base64_decode(strtr($signature, '-_', '+/'), true), + $publicKey, + OPENSSL_ALGO_SHA256 + )); + } + + public function testDistinctKeyPairsHaveDistinctThumbprints(): void + { + $this->assertNotSame(self::$keyPair->thumbprint(), DPoPKeyPair::generate()->thumbprint()); + } +} diff --git a/server/tests/Unit/DPoPKeyStorageTest.php b/server/tests/Unit/DPoPKeyStorageTest.php new file mode 100644 index 0000000..174adf5 --- /dev/null +++ b/server/tests/Unit/DPoPKeyStorageTest.php @@ -0,0 +1,168 @@ +root = sys_get_temp_dir() . '/solid-dpop-' . bin2hex(random_bytes(6)); + + $container = new Container(); + $container->instance('log', new NullLogger()); + $container->instance('config', new Repository([ + 'filesystems' => [ + // Mirrors the Fleetbase application: the default disk is the + // web-served one. + 'default' => 'public', + 'disks' => [ + 'public' => ['driver' => 'local', 'root' => $this->root . '/app/public'], + 'local' => ['driver' => 'local', 'root' => $this->root . '/app'], + ], + ], + ])); + $container->singleton('filesystem', fn ($app) => new FilesystemManager($app)); + + Container::setInstance($container); + Facade::setFacadeApplication($container); + + OpenIDConnectClient::flushProviderCaches(); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + Container::setInstance(null); + + exec('rm -rf ' . escapeshellarg($this->root)); + + parent::tearDown(); + } + + private function client(): OpenIDConnectClient + { + $transport = (new ArrayOidcTransport())->queue(self::DISCOVERY, [ + 'issuer' => self::ISSUER, + 'authorization_endpoint' => self::ISSUER . '/.oidc/auth', + 'token_endpoint' => self::ISSUER . '/.oidc/token', + 'jwks_uri' => self::ISSUER . '/.oidc/jwks', + ]); + + return OpenIDConnectClient::create([ + 'transport' => $transport, + 'stateStore' => new ArrayStateStore(), + 'verifier' => new SolidIdTokenVerifier(new ArrayJwksResolver(TestSigner::jwks([['key-1']]))), + 'providerUrl' => self::ISSUER, + 'clientID' => 'client-abc', + ]); + } + + private function privatePath(): string + { + return $this->root . '/app/' . self::KEY_PATH; + } + + private function publicPath(): string + { + return $this->root . '/app/public/' . self::KEY_PATH; + } + + public function testItWritesANewKeyToThePrivateDiskOnly(): void + { + $this->client()->createDPoP('POST', self::ISSUER . '/.oidc/token'); + + $this->assertFileExists($this->privatePath()); + $this->assertFileDoesNotExist($this->publicPath(), 'A DPoP private key must never reach the web-served disk.'); + } + + public function testTheStoredKeyIsReusedRatherThanRegenerated(): void + { + // Regenerating would invalidate every access token already bound to it. + $first = $this->client(); + $first->createDPoP('POST', self::ISSUER . '/.oidc/token'); + + $stored = (string) file_get_contents($this->privatePath()); + + OpenIDConnectClient::flushProviderCaches(); + $second = $this->client(); + $second->createDPoP('POST', self::ISSUER . '/.oidc/token'); + + $this->assertSame($stored, (string) file_get_contents($this->privatePath())); + } + + public function testItMovesAKeyLeftOnTheDefaultDiskByAnEarlierVersion(): void + { + $keyPair = DPoPKeyPair::generate(); + + @mkdir(dirname($this->publicPath()), 0777, true); + file_put_contents($this->publicPath(), (string) json_encode($keyPair->toArray())); + + $client = $this->client(); + $proof = $client->createDPoP('POST', self::ISSUER . '/.oidc/token'); + + // Same key: the migration must not sign existing users out. + $header = (array) json_decode( + (string) base64_decode(strtr(explode('.', $proof)[0], '-_', '+/'), true), + true + ); + $this->assertSame($keyPair->publicJwk(), $header['jwk']); + + $this->assertFileExists($this->privatePath()); + $this->assertFileDoesNotExist($this->publicPath(), 'The exposed copy must be deleted.'); + } + + public function testItIgnoresAnUnreadableLegacyKeyAndGeneratesAFreshOne(): void + { + @mkdir(dirname($this->publicPath()), 0777, true); + file_put_contents($this->publicPath(), 'not json'); + + $this->client()->createDPoP('POST', self::ISSUER . '/.oidc/token'); + + $this->assertFileExists($this->privatePath()); + } + + public function testClearingCredentialsRemovesTheKeyFromBothDisks(): void + { + @mkdir(dirname($this->publicPath()), 0777, true); + file_put_contents($this->publicPath(), (string) json_encode(DPoPKeyPair::generate()->toArray())); + + $client = $this->client(); + $client->createDPoP('POST', self::ISSUER . '/.oidc/token'); + $client->clearClientCredentials(); + + $this->assertFileDoesNotExist($this->privatePath()); + $this->assertFileDoesNotExist($this->publicPath()); + } +} diff --git a/server/tests/Unit/OpenIDConnectClientTest.php b/server/tests/Unit/OpenIDConnectClientTest.php new file mode 100644 index 0000000..6d1d436 --- /dev/null +++ b/server/tests/Unit/OpenIDConnectClientTest.php @@ -0,0 +1,792 @@ +transport = new ArrayOidcTransport(); + $this->stateStore = new ArrayStateStore(); + $this->keyPair = DPoPKeyPair::generate(); + + $this->transport->queue(self::DISCOVERY, [ + 'issuer' => self::ISSUER, + 'authorization_endpoint' => self::AUTHORIZE, + 'token_endpoint' => self::TOKEN, + 'registration_endpoint' => self::REGISTER, + 'jwks_uri' => self::JWKS, + 'code_challenge_methods_supported' => ['S256'], + 'token_endpoint_auth_methods_supported' => ['client_secret_basic'], + ]); + } + + /** + * @param array $options + */ + private function client(array $options = []): OpenIDConnectClient + { + return OpenIDConnectClient::create(array_merge([ + 'transport' => $this->transport, + 'stateStore' => $this->stateStore, + 'dpopKeyPair' => $this->keyPair, + 'verifier' => new SolidIdTokenVerifier(new ArrayJwksResolver(TestSigner::jwks([['key-1']]))), + 'clientID' => self::CLIENT_ID, + 'providerUrl' => self::ISSUER, + ], $options))->setRedirectURL(self::REDIRECT_URI); + } + + /** + * Drive a client through authorization so that state, nonce and the PKCE + * verifier are all stored, then return the state to hand back on the callback. + */ + private function startAuthorization(OpenIDConnectClient $client): string + { + parse_str((string) parse_url($client->getAuthorizationUrl(), PHP_URL_QUERY), $query); + + return (string) $query['state']; + } + + /** + * @param array $overrides + * @param array $claimOverrides + */ + private function queueTokenResponse(array $overrides = [], array $claimOverrides = [], int $status = 200): void + { + $nonce = $this->stateStore->get('nonce'); + + $this->transport->queue(self::TOKEN, array_merge([ + 'access_token' => TestSigner::sign(['cnf' => ['jkt' => $this->keyPair->thumbprint()], 'exp' => time() + 600]), + 'id_token' => TestSigner::sign(TestSigner::claims(array_merge([ + 'iss' => self::ISSUER, + 'aud' => self::CLIENT_ID, + 'nonce' => $nonce, + ], $claimOverrides))), + 'refresh_token' => 'a-refresh-token', + 'token_type' => 'DPoP', + ], $overrides), $status); + } + + /* + |-------------------------------------------------------------------------- + | Discovery + |-------------------------------------------------------------------------- + */ + + public function testItTakesTheIssuerFromTheDiscoveryDocument(): void + { + $this->assertSame(self::ISSUER, $this->client()->getIssuer()); + } + + public function testItRefusesADiscoveryDocumentWithNoIssuer(): void + { + $transport = (new ArrayOidcTransport())->queue(self::DISCOVERY, ['token_endpoint' => self::TOKEN]); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/carries no issuer/'); + + OpenIDConnectClient::create([ + 'transport' => $transport, + 'stateStore' => $this->stateStore, + 'providerUrl' => self::ISSUER, + ]); + } + + public function testItFetchesDiscoveryOncePerProcess(): void + { + // SolidIdentity::request() builds a client per Solid call; without this the + // discovery document would be refetched on every one of them. + $this->client(); + $this->client(); + + $this->assertSame(1, $this->transport->countRequestsTo(self::DISCOVERY)); + } + + /* + |-------------------------------------------------------------------------- + | Authorization request + |-------------------------------------------------------------------------- + */ + + public function testTheAuthorizationUrlCarriesTheParametersSolidOidcRequires(): void + { + parse_str((string) parse_url($this->client()->getAuthorizationUrl(), PHP_URL_QUERY), $query); + + $this->assertSame('code', $query['response_type']); + $this->assertSame(self::CLIENT_ID, $query['client_id']); + $this->assertSame(self::REDIRECT_URI, $query['redirect_uri']); + $this->assertSame('openid webid offline_access', $query['scope']); + $this->assertSame('S256', $query['code_challenge_method']); + $this->assertNotEmpty($query['state']); + $this->assertNotEmpty($query['nonce']); + } + + public function testTheCodeChallengeIsTheS256HashOfTheStoredVerifier(): void + { + parse_str((string) parse_url($this->client()->getAuthorizationUrl(), PHP_URL_QUERY), $query); + + $verifier = (string) $this->stateStore->get('code_verifier'); + + $this->assertSame( + rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='), + $query['code_challenge'] + ); + } + + public function testStateNonceAndVerifierAreAllStoredWithATtl(): void + { + // Written with no expiry before, which left an abandoned sign-in readable + // in Redis indefinitely. + $this->client()->getAuthorizationUrl(); + + foreach (['state', 'nonce', 'code_verifier'] as $key) { + $this->assertGreaterThan(0, $this->stateStore->ttl($key), $key . ' must expire on its own.'); + } + } + + public function testEachAuthorizationRequestGetsFreshValues(): void + { + $first = $this->startAuthorization($this->client()); + $second = $this->startAuthorization($this->client()); + + $this->assertNotSame($first, $second); + } + + public function testAuthenticateReturnsARedirectToTheAuthorizationUrl(): void + { + // It used to call header() + exit, which bypassed Laravel's response + // pipeline entirely and could not be asserted on. + $response = $this->client()->authenticate(); + + parse_str((string) parse_url($response->getTargetUrl(), PHP_URL_QUERY), $query); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertStringStartsWith(self::AUTHORIZE, $response->getTargetUrl()); + $this->assertSame((string) $this->stateStore->get('state'), $query['state']); + } + + public function testItWillNotStartAuthorizationWithoutAClientId(): void + { + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/registered client id/'); + + OpenIDConnectClient::create([ + 'transport' => $this->transport, + 'stateStore' => $this->stateStore, + 'providerUrl' => self::ISSUER, + ])->setRedirectURL(self::REDIRECT_URI)->getAuthorizationUrl(); + } + + public function testItRefusesAProviderThatDoesNotAdvertiseS256Pkce(): void + { + // Solid-OIDC requires PKCE, so this fails loudly instead of silently + // downgrading to an authorization request without a challenge. + $transport = (new ArrayOidcTransport())->queue(self::DISCOVERY, [ + 'issuer' => self::ISSUER, + 'authorization_endpoint' => self::AUTHORIZE, + 'token_endpoint' => self::TOKEN, + 'jwks_uri' => self::JWKS, + 'code_challenge_methods_supported' => ['plain'], + ]); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/does not support the S256 PKCE method/'); + + $this->client(['transport' => $transport])->getAuthorizationUrl(); + } + + /* + |-------------------------------------------------------------------------- + | Callback + |-------------------------------------------------------------------------- + */ + + public function testItExchangesTheCodeAndReturnsTheVerifiedTokenResponse(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse(); + + $tokenResponse = $client->exchangeCodeForTokens('an-auth-code', $state); + + $this->assertNotEmpty($tokenResponse->access_token); + $this->assertSame('a-refresh-token', $client->getRefreshToken()); + $this->assertSame( + 'https://pod.example.test/alice/profile/card#me', + $client->getVerifiedClaims()['webid'] + ); + } + + public function testTheTokenRequestSendsThePkceVerifierAndADpopProof(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $verifier = (string) $this->stateStore->get('code_verifier'); + $this->queueTokenResponse(); + + $client->exchangeCodeForTokens('an-auth-code', $state); + + $request = (array) $this->transport->lastRequestTo(self::TOKEN); + + $this->assertSame('authorization_code', $request['body']['grant_type']); + $this->assertSame('an-auth-code', $request['body']['code']); + $this->assertSame(self::REDIRECT_URI, $request['body']['redirect_uri']); + $this->assertSame($verifier, $request['body']['code_verifier']); + $this->assertArrayHasKey('DPoP', $request['headers']); + } + + public function testTheTokenRequestUsesBasicAuthWhenTheProviderSupportsIt(): void + { + $client = $this->client(['clientSecret' => 's3cret']); + $state = $this->startAuthorization($client); + $this->queueTokenResponse(); + + $client->exchangeCodeForTokens('an-auth-code', $state); + + $request = (array) $this->transport->lastRequestTo(self::TOKEN); + + $this->assertSame( + 'Basic ' . base64_encode(urlencode(self::CLIENT_ID) . ':' . urlencode('s3cret')), + $request['headers']['Authorization'] + ); + $this->assertArrayNotHasKey('client_secret', $request['body'], 'The secret must not be sent twice.'); + } + + public function testAPublicClientSendsItsClientIdInTheBody(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse(); + + $client->exchangeCodeForTokens('an-auth-code', $state); + + $request = (array) $this->transport->lastRequestTo(self::TOKEN); + + $this->assertSame(self::CLIENT_ID, $request['body']['client_id']); + $this->assertArrayNotHasKey('Authorization', $request['headers']); + } + + public function testItRejectsACallbackWithNoState(): void + { + $client = $this->client(); + $this->startAuthorization($client); + $this->queueTokenResponse(); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/no state parameter/'); + + $client->exchangeCodeForTokens('an-auth-code', null); + } + + public function testItRejectsACallbackWhoseStateDoesNotMatch(): void + { + $client = $this->client(); + $this->startAuthorization($client); + $this->queueTokenResponse(); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/state does not match/'); + + $client->exchangeCodeForTokens('an-auth-code', 'a-forged-state'); + } + + public function testItRejectsACallbackWithNoPendingAuthorizationRequest(): void + { + $this->queueTokenResponse(); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/no pending authorization request/'); + + $this->client()->exchangeCodeForTokens('an-auth-code', 'some-state'); + } + + public function testStateCannotBeReplayed(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse(); + + $client->exchangeCodeForTokens('an-auth-code', $state); + + $this->assertFalse($this->stateStore->has('state')); + $this->assertFalse($this->stateStore->has('nonce')); + $this->assertFalse($this->stateStore->has('code_verifier'), 'The PKCE verifier must be single use.'); + + $this->expectException(OpenIDConnectClientException::class); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + public function testAWrongStateConsumesThePendingRequest(): void + { + // So a forged callback cannot be retried against the same request. + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse(); + + try { + $client->exchangeCodeForTokens('an-auth-code', 'a-forged-state'); + } catch (OpenIDConnectClientException) { + // expected + } + + $this->assertFalse($this->stateStore->has('state')); + + $this->expectException(OpenIDConnectClientException::class); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + public function testItRejectsACallbackWhosePkceVerifierHasExpired(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse(); + + $this->stateStore->forget('code_verifier'); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/code verifier for this authorization request has expired/'); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + public function testItRejectsACallbackWhoseNonceHasExpired(): void + { + // A null expected nonce reads to the verifier as "no nonce was sent", so + // letting this through would silently skip the nonce check entirely. + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse(); + + $this->stateStore->forget('nonce'); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/nonce for this authorization request has expired/'); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + public function testAWrongStateAlsoDiscardsTheNonceAndTheVerifier(): void + { + $client = $this->client(); + $this->startAuthorization($client); + $this->queueTokenResponse(); + + try { + $client->exchangeCodeForTokens('an-auth-code', 'a-forged-state'); + } catch (OpenIDConnectClientException) { + // expected + } + + $this->assertSame([], $this->stateStore->keys(), 'A rejected callback must void the whole request.'); + } + + public function testItRejectsAnEmptyAuthorizationCode(): void + { + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/no authorization code/'); + + $this->client()->exchangeCodeForTokens('', 'some-state'); + } + + public function testItSurfacesAnErrorFromTheTokenEndpoint(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + + $this->transport->queue(self::TOKEN, [ + 'error' => 'invalid_grant', + 'error_description' => 'The authorization code has expired.', + ], 400); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/authorization code has expired/'); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + public function testItRejectsATokenResponseWithNoIdToken(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse(['id_token' => null]); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/no id_token/'); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + /* + |-------------------------------------------------------------------------- + | ID token verification, reached through the handshake + |-------------------------------------------------------------------------- + */ + + public function testItRejectsAnIdTokenFromTheWrongIssuer(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse([], ['iss' => 'https://attacker.example.test']); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/unexpected issuer/'); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + public function testItRejectsAnIdTokenForAnotherAudience(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse([], ['aud' => 'another-client']); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/not addressed to this client/'); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + public function testItRejectsAnIdTokenWhoseNonceIsFromAnotherRequest(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse([], ['nonce' => 'a-nonce-from-another-request']); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/nonce does not match/'); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + public function testItRejectsAnExpiredIdToken(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse([], ['exp' => time() - 3600, 'iat' => time() - 7200]); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/failed verification/'); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + public function testItRejectsAnIdTokenSignedByAnUnpublishedKey(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $nonce = $this->stateStore->get('nonce'); + + $this->transport->queue(self::TOKEN, [ + 'access_token' => 'an-opaque-token', + 'id_token' => TestSigner::sign( + TestSigner::claims(['iss' => self::ISSUER, 'aud' => self::CLIENT_ID, 'nonce' => $nonce]), + 'an-unpublished-key' + ), + ]); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/does not publish/'); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + /* + |-------------------------------------------------------------------------- + | DPoP binding + |-------------------------------------------------------------------------- + */ + + public function testItRejectsAnAccessTokenBoundToADifferentDpopKey(): void + { + // Such a token would be refused by every resource request, so failing here + // — with a reason — beats storing it. + $client = $this->client(); + $state = $this->startAuthorization($client); + + $this->queueTokenResponse([ + 'access_token' => TestSigner::sign([ + 'cnf' => ['jkt' => DPoPKeyPair::generate()->thumbprint()], + 'exp' => time() + 600, + ]), + ]); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/bound to a different DPoP key/'); + + $client->exchangeCodeForTokens('an-auth-code', $state); + } + + public function testItAcceptsAnOpaqueAccessToken(): void + { + // OAuth permits one, so a token that is not a JWT is not rejected for + // lacking a cnf claim. + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse(['access_token' => 'an-opaque-token']); + + $this->assertSame('an-opaque-token', $client->exchangeCodeForTokens('an-auth-code', $state)->access_token); + } + + public function testItRetriesTheTokenRequestOnceWithAServerSuppliedDpopNonce(): void + { + // RFC 9449 section 8. + $client = $this->client(); + $state = $this->startAuthorization($client); + + $this->transport->queue(self::TOKEN, ['error' => 'use_dpop_nonce'], 400, ['DPoP-Nonce' => 'server-nonce']); + $this->queueTokenResponse(); + + $client->exchangeCodeForTokens('an-auth-code', $state); + + $this->assertSame(2, $this->transport->countRequestsTo(self::TOKEN)); + + $proof = (string) ((array) $this->transport->lastRequestTo(self::TOKEN))['headers']['DPoP']; + $claims = (array) json_decode( + (string) base64_decode(strtr(explode('.', $proof)[1], '-_', '+/'), true), + true + ); + + $this->assertSame('server-nonce', $claims['nonce']); + } + + /* + |-------------------------------------------------------------------------- + | Refresh + |-------------------------------------------------------------------------- + */ + + public function testItRefreshesAnAccessToken(): void + { + $client = $this->client(); + + $this->transport->queue(self::TOKEN, [ + 'access_token' => TestSigner::sign(['cnf' => ['jkt' => $this->keyPair->thumbprint()], 'exp' => time() + 600]), + 'token_type' => 'DPoP', + ]); + + $client->refreshToken('a-refresh-token'); + + $request = (array) $this->transport->lastRequestTo(self::TOKEN); + + $this->assertSame('refresh_token', $request['body']['grant_type']); + $this->assertSame('a-refresh-token', $request['body']['refresh_token']); + $this->assertNotNull($client->getAccessToken()); + } + + public function testItVerifiesAnIdTokenReturnedFromARefresh(): void + { + // No nonce is expected here: a refresh does not correspond to an + // authorization request. Everything else still has to hold. + $client = $this->client(); + + $this->transport->queue(self::TOKEN, [ + 'access_token' => 'an-opaque-token', + 'id_token' => TestSigner::sign(TestSigner::claims([ + 'iss' => 'https://attacker.example.test', + 'aud' => self::CLIENT_ID, + ])), + ]); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/unexpected issuer/'); + + $client->refreshToken('a-refresh-token'); + } + + public function testItRequiresARefreshToken(): void + { + $this->expectException(OpenIDConnectClientException::class); + + $this->client()->refreshToken(''); + } + + /* + |-------------------------------------------------------------------------- + | Dynamic client registration + |-------------------------------------------------------------------------- + */ + + public function testItRegistersAClientAndKeepsTheReturnedCredentials(): void + { + $this->transport->queue(self::REGISTER, [ + 'client_id' => 'newly-registered', + 'client_secret' => 'newly-issued-secret', + 'client_name' => 'Fleetbase-v2', + ], 201); + + $client = $this->client(['clientID' => null])->register(['redirectUri' => self::REDIRECT_URI]); + + $this->assertSame('newly-registered', $client->getClientID()); + $this->assertSame('newly-issued-secret', $client->getClientSecret()); + + $body = (array) ((array) $this->transport->lastRequestTo(self::REGISTER))['body']; + + $this->assertSame([self::REDIRECT_URI], $body['redirect_uris']); + $this->assertSame('openid webid offline_access', $body['scope']); + // Declared so the provider does not default to authorization_code only, + // which would make the requested offline_access scope unusable. + $this->assertSame(['authorization_code', 'refresh_token'], $body['grant_types']); + $this->assertSame(['code'], $body['response_types']); + } + + public function testItSavesAndRestoresClientCredentialsWhenAsked(): void + { + $this->transport->queue(self::REGISTER, ['client_id' => 'saved-client', 'client_secret' => 'saved-secret'], 201); + + $this->client()->register(['redirectUri' => self::REDIRECT_URI, 'saveCredentials' => true]); + + $restored = $this->client(['clientID' => null])->restoreClientCredentials(); + + $this->assertSame('saved-client', $restored->getClientID()); + $this->assertSame('saved-secret', $restored->getClientSecret()); + } + + public function testARegistrationIsStoredWithoutAnExpiryByDefault(): void + { + // It has to outlive any single sign-in. + $this->transport->queue(self::REGISTER, ['client_id' => 'saved-client'], 201); + + $this->client()->register(['redirectUri' => self::REDIRECT_URI, 'saveCredentials' => true]); + + $this->assertSame(0, $this->stateStore->ttl('client_credentials:fleetbase-v2')); + } + + public function testItSurfacesARejectedRegistration(): void + { + $this->transport->queue(self::REGISTER, ['error' => 'invalid_redirect_uri'], 400); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/refused client registration/'); + + $this->client()->register(['redirectUri' => self::REDIRECT_URI]); + } + + public function testItRejectsARegistrationResponseWithNoClientId(): void + { + $this->transport->queue(self::REGISTER, ['client_name' => 'Fleetbase-v2'], 201); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/no client_id/'); + + $this->client()->register(['redirectUri' => self::REDIRECT_URI]); + } + + public function testItWillNotRegisterWithoutARedirectUri(): void + { + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/redirect URI is required/'); + + $this->client()->setRedirectURL(null)->register(); + } + + public function testItRestoresNothingWhenNoCredentialsWereSaved(): void + { + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/No saved Solid client credentials/'); + + $this->client()->restoreClientCredentials(); + } + + /* + |-------------------------------------------------------------------------- + | Claims and unverified decoding + |-------------------------------------------------------------------------- + */ + + public function testItReadsTheWebidFromVerifiedClaimsAfterASuccessfulHandshake(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse(); + + $tokenResponse = $client->exchangeCodeForTokens('an-auth-code', $state); + + $this->assertSame( + 'https://pod.example.test/alice/profile/card#me', + $client->getWebIdFromIdToken((string) $tokenResponse->id_token) + ); + } + + public function testItFallsBackToTheSubjectWhenThereIsNoWebidClaim(): void + { + $claims = TestSigner::claims(); + unset($claims['webid']); + + $this->assertSame($claims['sub'], $this->client()->getWebIdFromIdToken(TestSigner::sign($claims))); + } + + public function testItReturnsNoWebidForATokenItCannotDecode(): void + { + $this->assertNull($this->client()->getWebIdFromIdToken('not-a-jwt')); + $this->assertNull($this->client()->getWebIdFromIdToken('')); + } + + public function testSettingAnIdTokenDiscardsClaimsVerifiedForAnotherOne(): void + { + $client = $this->client(); + $state = $this->startAuthorization($client); + $this->queueTokenResponse(); + $client->exchangeCodeForTokens('an-auth-code', $state); + + $this->assertNotEmpty($client->getVerifiedClaims()); + + $client->setIdToken(TestSigner::sign(TestSigner::claims(['sub' => 'https://elsewhere.test/#me']))); + + $this->assertSame([], $client->getVerifiedClaims()); + } + + public function testAddScopeDoesNotDuplicateExistingScopes(): void + { + $client = $this->client()->addScope(['openid', 'webid', 'profile']); + + $this->assertSame(['openid', 'webid', 'offline_access', 'profile'], $client->getScopes()); + } + + public function testAMissingProviderEndpointIsAnErrorRatherThanNull(): void + { + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/does not advertise "end_session_endpoint"/'); + + $this->client()->getProviderConfigValue('end_session_endpoint'); + } + + public function testProviderConfigOverridesTakePrecedenceOverDiscovery(): void + { + $client = $this->client()->providerConfigParam(['token_endpoint' => 'https://override.example.test/token']); + + $this->assertSame('https://override.example.test/token', $client->getProviderConfigValue('token_endpoint')); + } +} diff --git a/server/tests/Unit/RedisStateStoreTest.php b/server/tests/Unit/RedisStateStoreTest.php new file mode 100644 index 0000000..3a956ff --- /dev/null +++ b/server/tests/Unit/RedisStateStoreTest.php @@ -0,0 +1,126 @@ +}> + */ + private array $calls = []; + + private RedisStateStore $store; + + protected function setUp(): void + { + parent::setUp(); + + $calls = &$this->calls; + + $redis = new class($calls) { + /** + * @param array}> $calls + */ + public function __construct(private array &$calls) + { + } + + /** + * @param array $arguments + */ + public function __call(string $method, array $arguments): mixed + { + $this->calls[] = [$method, $arguments]; + + return $method === 'get' ? 'a-stored-value' : true; + } + }; + + $container = new Container(); + $container->instance('redis', $redis); + + Container::setInstance($container); + Facade::setFacadeApplication($container); + + $this->store = new RedisStateStore('oidc:an-identity-uuid:'); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + Container::setInstance(null); + + parent::tearDown(); + } + + public function testItNamespacesEveryKeyWithItsPrefix(): void + { + // One identity must not be able to read another's pending authorization. + $this->store->get('state'); + + $this->assertSame(['get', ['oidc:an-identity-uuid:state']], $this->calls[0]); + } + + public function testItWritesAPositiveTtlWithSetex(): void + { + $this->store->put('state', 'a-state', 600); + + $this->assertSame(['setex', ['oidc:an-identity-uuid:state', 600, 'a-state']], $this->calls[0]); + } + + public function testAZeroTtlWritesWithoutAnExpiry(): void + { + $this->store->put('client_credentials:fleetbase-v2', '{}', 0); + + $this->assertSame(['set', ['oidc:an-identity-uuid:client_credentials:fleetbase-v2', '{}']], $this->calls[0]); + } + + public function testANegativeTtlIsTreatedTheSameAsZero(): void + { + $this->store->put('client_credentials:fleetbase-v2', '{}', -1); + + $this->assertSame('set', $this->calls[0][0]); + } + + public function testItDeletesOnForget(): void + { + $this->store->forget('nonce'); + + $this->assertSame(['del', ['oidc:an-identity-uuid:nonce']], $this->calls[0]); + } + + public function testItReturnsNullForANonStringValue(): void + { + $container = Container::getInstance(); + $container->instance('redis', new class { + /** + * @param array $arguments + */ + public function __call(string $method, array $arguments): mixed + { + return null; + } + }); + Facade::clearResolvedInstances(); + + $this->assertNull((new RedisStateStore('oidc:x:'))->get('state')); + } + + public function testItReturnsAStoredString(): void + { + $this->assertSame('a-stored-value', $this->store->get('state')); + } +} diff --git a/server/tests/Unit/SolidIdTokenVerifierTest.php b/server/tests/Unit/SolidIdTokenVerifierTest.php new file mode 100644 index 0000000..a868253 --- /dev/null +++ b/server/tests/Unit/SolidIdTokenVerifierTest.php @@ -0,0 +1,295 @@ + + */ + private function verify(string $jwt, ?string $nonce = 'nonce-value', ?ArrayJwksResolver $resolver = null, bool $requireNonce = true): array + { + return $this->verifier($resolver, $requireNonce)->verify($jwt, self::JWKS_URI, self::ISSUER, self::CLIENT_ID, $nonce); + } + + public function testItAcceptsAValidRs256TokenAndReturnsItsClaims(): void + { + $claims = $this->verify(TestSigner::sign(TestSigner::claims())); + + $this->assertSame(self::ISSUER, $claims['iss']); + $this->assertSame('https://pod.example.test/alice/profile/card#me', $claims['webid']); + } + + public function testItAcceptsAnEs256Token(): void + { + // A Solid provider may sign with EC. Core API's verifier is hardcoded to + // RS256, which is the reason this class does not reuse it. + $resolver = new ArrayJwksResolver(TestSigner::jwks([['ec-key', 'ES256']])); + $jwt = TestSigner::sign(TestSigner::claims(), 'ec-key', 'ES256'); + + $this->assertSame(self::ISSUER, $this->verify($jwt, 'nonce-value', $resolver)['iss']); + } + + public function testItRejectsATokenWhoseSignatureDoesNotMatchTheNamedKey(): void + { + $resolver = new ArrayJwksResolver(TestSigner::jwks([['key-1'], ['key-2']])); + $jwt = TestSigner::signWithWrongKey(TestSigner::claims(), 'key-1', 'key-2'); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/failed verification/'); + + $this->verify($jwt, 'nonce-value', $resolver); + } + + public function testItRejectsAnExpiredToken(): void + { + // Beyond the 60s leeway the verifier is configured with. + $jwt = TestSigner::sign(TestSigner::claims(['exp' => time() - 3600, 'iat' => time() - 7200])); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/failed verification/'); + + $this->verify($jwt); + } + + public function testItAcceptsATokenThatExpiredInsideTheLeeway(): void + { + $jwt = TestSigner::sign(TestSigner::claims(['exp' => time() - 10])); + + $this->assertSame(self::ISSUER, $this->verify($jwt)['iss']); + } + + public function testItRejectsATokenThatIsNotYetValid(): void + { + $jwt = TestSigner::sign(TestSigner::claims(['nbf' => time() + 3600])); + + $this->expectException(OpenIDConnectClientException::class); + + $this->verify($jwt); + } + + public function testItRejectsAnUnexpectedIssuer(): void + { + $jwt = TestSigner::sign(TestSigner::claims(['iss' => 'https://attacker.example.test'])); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/unexpected issuer/'); + + $this->verify($jwt); + } + + public function testItRejectsAnIssuerThatOnlyPrefixesTheExpectedOne(): void + { + $jwt = TestSigner::sign(TestSigner::claims(['iss' => self::ISSUER . '.attacker.test'])); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/unexpected issuer/'); + + $this->verify($jwt); + } + + public function testItRejectsATokenAddressedToAnotherClient(): void + { + $jwt = TestSigner::sign(TestSigner::claims(['aud' => 'some-other-client'])); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/not addressed to this client/'); + + $this->verify($jwt); + } + + public function testItAcceptsAnAudienceArrayContainingThisClientWithAMatchingAzp(): void + { + $jwt = TestSigner::sign(TestSigner::claims([ + 'aud' => [self::CLIENT_ID, 'another-client'], + 'azp' => self::CLIENT_ID, + ])); + + $this->assertSame(self::ISSUER, $this->verify($jwt)['iss']); + } + + public function testItRejectsMultipleAudiencesWithoutAnAzpNamingThisClient(): void + { + // OIDC Core 3.1.3.7. Without this, a token minted for another client that + // happens to list us as an audience would be accepted. + $jwt = TestSigner::sign(TestSigner::claims([ + 'aud' => [self::CLIENT_ID, 'another-client'], + 'azp' => 'another-client', + ])); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/azp/'); + + $this->verify($jwt); + } + + public function testItRejectsATokenWithNoSubject(): void + { + $claims = TestSigner::claims(); + unset($claims['sub']); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/no subject/'); + + $this->verify(TestSigner::sign($claims)); + } + + public function testItRejectsAMismatchedNonce(): void + { + $jwt = TestSigner::sign(TestSigner::claims(['nonce' => 'some-other-nonce'])); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/nonce does not match/'); + + $this->verify($jwt); + } + + public function testItRejectsAMissingNonceWhenOneWasSent(): void + { + $claims = TestSigner::claims(); + unset($claims['nonce']); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/echoed no nonce/'); + + $this->verify(TestSigner::sign($claims)); + } + + public function testItToleratesAMissingNonceWhenTheRequirementIsSwitchedOff(): void + { + $claims = TestSigner::claims(); + unset($claims['nonce']); + + $verified = $this->verify(TestSigner::sign($claims), 'nonce-value', null, false); + + $this->assertSame(self::ISSUER, $verified['iss']); + } + + public function testItRejectsAnUnsignedToken(): void + { + // `alg: none` must never be treated as a signature. + $jwt = TestSigner::unsigned(['typ' => 'JWT', 'alg' => 'none', 'kid' => 'key-1'], TestSigner::claims()); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/unsupported algorithm/'); + + $this->verify($jwt); + } + + public function testItRejectsAnHmacTokenSignedWithTheRsaPublicKey(): void + { + // The algorithm-confusion attack: the header claims HS256 so that the + // published RSA public key is used as a shared secret. + $jwt = TestSigner::hmacSignedWithPublicKey(TestSigner::claims()); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/unsupported algorithm/'); + + $this->verify($jwt); + } + + public function testItRejectsATokenWithNoKeyId(): void + { + $jwt = TestSigner::unsigned(['typ' => 'JWT', 'alg' => 'RS256'], TestSigner::claims(), 'not-a-signature'); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/no kid/'); + + $this->verify($jwt); + } + + public function testItRejectsAMalformedToken(): void + { + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/not a compact JWS/'); + + $this->verify('not.a-jwt'); + } + + public function testItRejectsATokenWithAnUndecodableHeader(): void + { + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/header could not be decoded/'); + + $this->verify('###.###.###'); + } + + public function testItRefetchesTheJwksOnceForAnUnknownKeyId(): void + { + // A rotated signing key looks exactly like a forged `kid`, so the cached + // document is dropped and refetched before the token is judged. + $resolver = new ArrayJwksResolver( + TestSigner::jwks([['key-1']]), + TestSigner::jwks([['key-1'], ['key-2']]) + ); + + $claims = $this->verify(TestSigner::sign(TestSigner::claims(), 'key-2'), 'nonce-value', $resolver); + + $this->assertSame(self::ISSUER, $claims['iss']); + $this->assertSame(1, $resolver->refreshCount); + } + + public function testItRejectsAKeyIdThatIsStillUnknownAfterARefetch(): void + { + $resolver = new ArrayJwksResolver(TestSigner::jwks([['key-1']])); + + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/does not publish/'); + + try { + $this->verify(TestSigner::sign(TestSigner::claims(), 'key-9'), 'nonce-value', $resolver); + } finally { + // Exactly one retry, not a loop against the provider. + $this->assertSame(1, $resolver->refreshCount); + } + } + + public function testItRefusesToVerifyWithoutAKnownIssuer(): void + { + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/without a known issuer/'); + + $this->verifier()->verify(TestSigner::sign(TestSigner::claims()), self::JWKS_URI, '', self::CLIENT_ID, null); + } + + public function testItRefusesToVerifyWithoutAClientId(): void + { + // An empty audience would make the audience check vacuous. + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/without a registered client id/'); + + $this->verifier()->verify(TestSigner::sign(TestSigner::claims()), self::JWKS_URI, self::ISSUER, '', null); + } + + public function testItRejectsAnEmptyToken(): void + { + $this->expectException(OpenIDConnectClientException::class); + $this->expectExceptionMessageMatches('/no id_token/'); + + $this->verify(''); + } +}