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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-suggest
- name: Run test suite
run: composer run-script pre-commit
run: composer run-script on-commit
- name: Show PHP version
run: php -v

Expand Down Expand Up @@ -63,6 +63,19 @@ jobs:
- name: Run Basic conformance tests
run: |
./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-basic-certification-test-plan[client_registration=static_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-basic-ci.json
- name: Switch RP App to dynamic client registration
working-directory: ./main
run: |
CLIENT_REGISTRATION=dynamic_client docker compose -f docker/docker-compose.yml up -d
sleep 5
- name: Restart trigger-client daemon
run: |
pkill -f trigger-client.py || true
python3 ./main/conformance-tests/trigger-client.py &
sleep 2
- name: Run Basic conformance tests with dynamic client registration
run: |
./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-basic-certification-test-plan[client_registration=dynamic_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-basic-dynamic-ci.json
- name: Stop RP App
if: always()
working-directory: ./main
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ It uses OIDC Authorization Code Flow to perform authentication. It implements
JWKS public key usage and automatic key rollover, caching mechanism
(file based by default), ID token verification and claims extraction,
'userinfo' user data fetching and has support for automatic client
registration for federated environments.
registration for federated environments, as well as OpenID Connect Dynamic
Client Registration.

For information on how to use this client, refer to the
[documentation](docs/1-Index.md).
7 changes: 7 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@
"scripts": {
"pre-commit": [
"vendor/bin/phpcbf",
"vendor/bin/phpcs -p",
"vendor/bin/phpstan --memory-limit=1024M",
"vendor/bin/phpstan analyze -c phpstan-dev.neon --memory-limit=1024M",
"vendor/bin/rector",
"vendor/bin/phpunit --no-coverage"
],
"on-commit": [
"vendor/bin/phpcs -p",
"vendor/bin/phpstan --memory-limit=1024M",
"vendor/bin/phpstan analyze -c phpstan-dev.neon --memory-limit=1024M",
Expand Down
18 changes: 18 additions & 0 deletions conformance-tests/conformance-basic-dynamic-ci.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"alias": "oidc-client-php",
"description": "OIDC RP conformance tests for oidc-client-php (dynamic client registration)",
"browser": [
{
"match": "https://rp.local.conformance.test*",
"tasks": [
{
"task": "Trigger RP login and wait for completion",
"match": "https://rp.local.conformance.test/",
"commands": [
["wait", "id", "submission_complete", 30]
]
}
]
}
]
}
2 changes: 2 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ services:
- "443:443"
environment:
- OP_DISCOVERY_URL=https://localhost.emobix.co.uk:8443/test/a/oidc-client-php/.well-known/openid-configuration
# static_client uses CLIENT_ID / CLIENT_SECRET below; dynamic_client registers itself on the OP.
- CLIENT_REGISTRATION=${CLIENT_REGISTRATION:-static_client}
- CLIENT_ID=oidc-client-php-test
- CLIENT_SECRET=oidc-client-php-test-secret
- REDIRECT_URI=https://rp.local.conformance.test/callback
Expand Down
41 changes: 31 additions & 10 deletions docker/rp-app/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

require_once __DIR__ . '/vendor/autoload.php';

use Cicnavi\Oidc\DynamicallyRegisteredClient;
use Cicnavi\Oidc\PreRegisteredClient;
use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum;
use GuzzleHttp\Client as GuzzleClient;
Expand All @@ -15,6 +16,7 @@

// Read configurations from environment variables or use default test values matching the JSON config
$opConfigurationUrl = getenv('OP_DISCOVERY_URL') ?: 'https://localhost.emobix.co.uk:8443/test/a/oidc-client-php/.well-known/openid-configuration';
$clientRegistration = getenv('CLIENT_REGISTRATION') ?: 'static_client';
$clientId = getenv('CLIENT_ID') ?: 'oidc-client-php-test';
$clientSecret = getenv('CLIENT_SECRET') ?: 'oidc-client-php-test-secret';
$redirectUri = getenv('REDIRECT_URI') ?: 'https://rp.local.conformance.test/callback';
Expand All @@ -24,28 +26,47 @@
// Disable SSL verification for internal Guzzle client because conformance-suite uses a self-signed cert
$httpClient = new GuzzleClient(['verify' => false]);

$client = new PreRegisteredClient(
opConfigurationUrl: $opConfigurationUrl,
clientId: $clientId,
clientSecret: $clientSecret,
redirectUri: $redirectUri,
scope: $scope,
httpClient: $httpClient,
defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::Query
);
if ($clientRegistration === 'dynamic_client') {
$client = new DynamicallyRegisteredClient(
opConfigurationUrl: $opConfigurationUrl,
redirectUri: $redirectUri,
scope: $scope,
clientName: 'oidc-client-php',
httpClient: $httpClient,
defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::Query
);
} else {
$client = new PreRegisteredClient(
opConfigurationUrl: $opConfigurationUrl,
clientId: $clientId,
clientSecret: $clientSecret,
redirectUri: $redirectUri,
scope: $scope,
httpClient: $httpClient,
defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::Query
);
}

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($path === '/callback') {
// Exchange authorization code for token and fetch user data
$userData = $client->getUserData();

// Print success div for automated browser/curl matching
echo '<html><head><title>OIDC RP Test Completion</title></head><body>';
echo '<div id="submission_complete">OIDC Flow Successful!</div>';
echo '<h1>User Data</h1><pre>' . htmlspecialchars(json_encode($userData, JSON_PRETTY_PRINT)) . '</pre>';
echo '</body></html>';
} else {
if ($client instanceof DynamicallyRegisteredClient) {
// Each conformance test module is a fresh OP instance served on the
// same issuer URL, so a registration persisted during a previous
// test module is stale — always register anew when starting a flow.
// The callback request then reuses the persisted registration.
$client->register(forceNewRegistration: true);
}

// Initiate OIDC authorization flow
$client->authorize();
}
Expand Down
9 changes: 7 additions & 2 deletions docs/1-Index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,23 @@ composer require cicnavi/oidc-client-php

## Client Usage

There are two ways to instantiate an OIDC client:
There are three ways to instantiate an OIDC client:
* Pre-registered Client (`Cicnavi\Oidc\PreRegisteredClient`) - can be used if
the client is already registered with the OpenID Provider.
* Federated Client (`Cicnavi\Oidc\FederatedClient`) - can be used in federated
environments (as per OpenID Federation specification). This client type
currently supports Automatic Client Registration flow using Request Object
passed by value.
* Dynamically Registered Client (`Cicnavi\Oidc\DynamicallyRegisteredClient`) -
can be used if the OpenID Provider supports OpenID Connect Dynamic Client
Registration 1.0. The client registers itself with the OpenID Provider and
uses the issued client credentials.

Check the dedicated sections below for more details about each client type:
* [Pre-registered Client](2-Pre-Registered-Client.md)
* [Federated Client](3-Federated-Client.md)
* [Conformance Testing](4-Conformance-Testing.md)
* [Dynamically Registered Client](4-Dynamically-Registered-Client.md)
* [Conformance Testing](5-Conformance-Testing.md)


## Note on SameSite Cookie Attribute
Expand Down
184 changes: 184 additions & 0 deletions docs/4-Dynamically-Registered-Client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Dynamically Registered Client

Dynamically Registered Client can be used when the OpenID Provider supports
[OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html)
(RFC 7591), meaning the OP advertises a `registration_endpoint` in its
metadata. Instead of providing a pre-issued client ID and client secret, the
client registers itself on the OP and uses the issued client credentials.
After registration, it behaves exactly like a
[Pre-Registered Client](2-Pre-Registered-Client.md), using the
`client_secret_basic` client authentication method.

To instantiate a client, provide configuration parameters to the
`\Cicnavi\Oidc\DynamicallyRegisteredClient` constructor. Here's a basic
example with required parameters:

```php
use Cicnavi\Oidc\DynamicallyRegisteredClient;

$oidcClient = new DynamicallyRegisteredClient(
opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration',
redirectUri: 'https://your-example.org/callback',
scope: 'openid profile',
);
```

Registration is performed lazily on first use (first call to `authorize()`,
`getUserData()`, or any method which needs client credentials), or you can
trigger it explicitly:

```php
$registrationData = $oidcClient->register();

echo $registrationData->getClientId();
```

## Client Registration Persistence

The client registration (client credentials, Registration Access Token,
client configuration endpoint URI) is persisted using a client registration
store, so registration is performed only once, not on every request. On
subsequent instantiations, the persisted registration is used.

By default, a simple file-based store
(`Cicnavi\Oidc\Registration\FileClientRegistrationStore`) is used, which
stores registrations as JSON files in a dedicated directory in the system
`tmp` folder. Note that this is a *default for convenience*: the system `tmp`
folder is typically subject to periodic cleanup, and losing a stored
registration means losing the issued client credentials (forcing a new
registration on the OP). For production, provide a durable storage directory
(make sure it exists, is writable by the web server, and is properly
protected, since it contains client credentials):

```php
use Cicnavi\Oidc\DynamicallyRegisteredClient;
use Cicnavi\Oidc\Registration\FileClientRegistrationStore;

$registrationStore = new FileClientRegistrationStore(__DIR__ . '/../storage/registrations');

$oidcClient = new DynamicallyRegisteredClient(
opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration',
redirectUri: 'https://your-example.org/callback',
scope: 'openid profile',
registrationStore: $registrationStore,
);
```

Alternatively, implement
`Cicnavi\Oidc\Registration\Interfaces\ClientRegistrationStoreInterface` to
store registrations in a database or other durable storage.

Registrations are keyed per OP configuration URL and redirect URI, so one
store instance can be shared between clients for different OPs.

If the OP issues a client secret with an expiration time
(`client_secret_expires_at`), the client will automatically perform a new
registration once the persisted client secret expires.

## Initial Access Token

The OP can protect its registration endpoint, requiring an Initial Access
Token (an OAuth 2.0 Bearer token) for registration requests. Provide it with
the `initialAccessToken` parameter:

```php
use Cicnavi\Oidc\DynamicallyRegisteredClient;

$oidcClient = new DynamicallyRegisteredClient(
opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration',
redirectUri: 'https://your-example.org/callback',
scope: 'openid profile',
initialAccessToken: 'some-initial-access-token',
);
```

## Registered Client Metadata

During registration, the client sends the following client metadata claims,
prepared from the constructor parameters:

* `redirect_uris` - containing the provided redirect URI,
* `grant_types` - `["authorization_code"]`,
* `response_types` - `["code"]`,
* `token_endpoint_auth_method` - `client_secret_basic`,
* `scope` - the provided scope,
* `client_name` - if provided using the `clientName` parameter,
* `software_id` - by default (can be disabled using the `includeSoftwareId`
parameter).

Any additional client metadata claims can be provided using the
`additionalClientMetadata` parameter. Claims provided here override the
prepared claims, so make sure to use the correct format for the particular
claim:

```php
use Cicnavi\Oidc\DynamicallyRegisteredClient;

$oidcClient = new DynamicallyRegisteredClient(
opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration',
redirectUri: 'https://your-example.org/callback',
scope: 'openid profile',
clientName: 'My Application',
additionalClientMetadata: [
'contacts' => ['admin@your-example.org'],
'client_uri' => 'https://your-example.org',
],
);
```

## Client Configuration Management (RFC 7592)

If the OP returns a Registration Access Token
(`registration_access_token`) and a client configuration endpoint URI
(`registration_client_uri`) in the registration response, the client can
also read, update, and delete its registration on the OP, as per
[RFC 7592](https://www.rfc-editor.org/rfc/rfc7592.html):

```php
// Read the current client registration from the OP (also persists the
// returned client information locally):
$registrationData = $oidcClient->readRegistration();

// Update the client registration on the OP. The whole client metadata set is
// sent (as prepared from constructor parameters), with provided claims taking
// precedence:
$registrationData = $oidcClient->updateRegistration([
'client_name' => 'My Renamed Application',
]);

// Delete (deprovision) the client registration on the OP, and remove it from
// the local client registration store:
$oidcClient->deleteRegistration();
```

## Client Usage

After registration, client usage is the same as for the
[Pre-Registered Client](2-Pre-Registered-Client.md): use `authorize()` to
initiate the authorization flow, and `getUserData()` on the callback to
exchange the authorization code for tokens and fetch user claims. All
optional parameters known from the Pre-Registered Client (PKCE, state, nonce,
authorization request method, response mode, PAR mode, caching, logging...)
are available and forwarded to the underlying client instance.

```php
use Cicnavi\Oidc\DynamicallyRegisteredClient;
/** @var DynamicallyRegisteredClient $oidcClient */

// File: authorize.php
$oidcClient->authorize();

// File: callback.php
$userData = $oidcClient->getUserData();
```

## Requirements on the OpenID Provider

* OP metadata must advertise a `registration_endpoint`.
* The registration response must contain a `client_secret`, since the client
uses the `client_secret_basic` client authentication method.

As a reference, the [SimpleSAMLphp OIDC module](https://github.com/simplesamlphp/simplesamlphp-module-oidc)
OP implementation supports Dynamic Client Registration, including client
configuration management (read, update, delete) and optional Initial Access
Token protection.
Loading
Loading