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
26 changes: 26 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,32 @@ jobs:
- name: Run RP-Initiated Logout conformance tests with dynamic client registration
run: |
./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json "oidcc-client-rp-initiated-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=dynamic_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-rp-logout-dynamic-ci.json
- name: Switch RP App back to static client for Back-Channel Logout tests
working-directory: ./main
run: |
LOGOUT_FLOW=rp_initiated docker compose -f docker/docker-compose.yml up -d
sleep 5
- name: Restart trigger-client daemon for Back-Channel Logout tests
run: |
pkill -f trigger-client.py || true
python3 ./main/conformance-tests/trigger-client.py &
sleep 2
- name: Run Back-Channel Logout conformance tests
run: |
./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json "oidcc-client-back-channel-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=static_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-backchannel-logout-ci.json
- name: Switch RP App to Back-Channel Logout with dynamic client registration
working-directory: ./main
run: |
CLIENT_REGISTRATION=dynamic_client LOGOUT_FLOW=rp_initiated docker compose -f docker/docker-compose.yml up -d
sleep 5
- name: Restart trigger-client daemon for dynamic Back-Channel Logout tests
run: |
pkill -f trigger-client.py || true
python3 ./main/conformance-tests/trigger-client.py &
sleep 2
- name: Run Back-Channel Logout conformance tests with dynamic client registration
run: |
./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json "oidcc-client-back-channel-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=dynamic_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-backchannel-logout-dynamic-ci.json
- name: Stop RP App
if: always()
working-directory: ./main
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ Generic OpenID Connect (OIDC) client (RP) written in PHP.
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, OIDC RP-Initiated Logout, and has support for
automatic client registration for federated environments, as well as OpenID
Connect Dynamic Client Registration.
'userinfo' user data fetching, OIDC RP-Initiated Logout, OIDC Back-Channel
Logout, and has support for automatic client 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).
28 changes: 28 additions & 0 deletions conformance-tests/conformance-backchannel-logout-ci.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"alias": "oidc-client-php",
"description": "OIDC Back-Channel Logout conformance tests for oidc-client-php",
"client": {
"client_id": "oidc-client-php-test",
"client_secret": "oidc-client-php-test-secret",
"redirect_uri": "https://rp.local.conformance.test/callback",
"post_logout_redirect_uris": [
"https://rp.local.conformance.test/logout-callback"
],
"backchannel_logout_uri": "https://rp.local.conformance.test/backchannel-logout",
"request_type": "plain_http_request"
},
"browser": [
{
"match": "https://rp.local.conformance.test*",
"tasks": [
{
"task": "Trigger RP login with logout and wait for completion",
"match": "https://rp.local.conformance.test/",
"commands": [
["wait", "id", "submission_complete", 30]
]
}
]
}
]
}
18 changes: 18 additions & 0 deletions conformance-tests/conformance-backchannel-logout-dynamic-ci.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"alias": "oidc-client-php",
"description": "OIDC Back-Channel Logout conformance tests for oidc-client-php (dynamic client registration)",
"browser": [
{
"match": "https://rp.local.conformance.test*",
"tasks": [
{
"task": "Trigger RP login with logout and wait for completion",
"match": "https://rp.local.conformance.test/",
"commands": [
["wait", "id", "submission_complete", 30]
]
}
]
}
]
}
35 changes: 16 additions & 19 deletions docker/rp-app/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,6 @@
try {
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($path === '/backchannel-logout') {
// Back-Channel Logout endpoint stub. The RP-Initiated Logout test
// modules require the client to have a backchannel_logout_uri or
// frontchannel_logout_uri registered, and the suite POSTs a logout
// token here while handling the end_session request. Proper logout
// token validation is a separate library feature (Back-Channel
// Logout support) - until it lands, only acknowledge the request.
// 'Cache-Control: no-store' is required per OIDC Back-Channel Logout
// 2.8 (the suite warns when missing).
header('Cache-Control: no-store');
http_response_code(200);
echo 'OK';
exit;
}

// Disable SSL verification for internal Guzzle client because conformance-suite uses a self-signed cert
$httpClient = new GuzzleClient(['verify' => false]);

Expand All @@ -59,10 +44,11 @@
httpClient: $httpClient,
defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::Query,
postLogoutRedirectUris: $logoutFlow === 'rp_initiated' ? [$postLogoutRedirectUri] : [],
// See the /backchannel-logout endpoint stub above.
additionalClientMetadata: $logoutFlow === 'rp_initiated'
? ['backchannel_logout_uri' => $backchannelLogoutUri]
: [],
// Registered so the OP can deliver Back-Channel Logout requests
// to the /backchannel-logout endpoint below (the logout test
// modules also require the client to have a
// backchannel_logout_uri or frontchannel_logout_uri).
backchannelLogoutUri: $logoutFlow === 'rp_initiated' ? $backchannelLogoutUri : null,
);
} else {
$client = new PreRegisteredClient(
Expand All @@ -76,6 +62,17 @@
);
}

if ($path === '/backchannel-logout') {
// OIDC Back-Channel Logout endpoint. The conformance suite POSTs a
// logout token here while handling the end_session request. The
// handler validates the logout token, records the login revocation,
// and emits the spec-compliant response itself (200 when the logout
// was performed, 400 with a JSON error body when the logout token
// was rejected, 'Cache-Control: no-store' in both cases).
$client->handleBackchannelLogoutRequest();
exit;
}

if ($path === '/callback') {
// Exchange authorization code for token and fetch user data
$userData = $client->getUserData();
Expand Down
72 changes: 72 additions & 0 deletions docs/2-Pre-Registered-Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,78 @@ The raw ID token received at login is also available using the
`getIdToken()` method (and related login data using `getLoginData()`), for
example, if you need to build a custom logout request yourself.

## Back-Channel Logout

The client can also act as the receiving side of
[OpenID Connect Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html):
the OP notifies the client about a logout (for example, initiated at the OP
or at another client of the same End-User session) by sending a logout
token directly to a dedicated client endpoint, outside of the user agent.

To use it, register an endpoint URI as the `backchannel_logout_uri` client
metadata on the OP, and on that endpoint call
`handleBackchannelLogoutRequest()`:

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

// File: backchannel-logout.php (registered as 'backchannel_logout_uri')
$oidcClient->handleBackchannelLogoutRequest();
```

The method validates the logout token from the request (signature against
the OP JWKS, signing algorithm, issuer, audience, required claims, freshness,
and `jti` replay detection), records the requested login revocation, and
emits the proper HTTP response itself (200 when the logout was performed, 400
with a JSON error body when not). It also accepts an optional PSR-7 server
request to read from, and an optional PSR-7 `response` instance which will be
populated and returned instead of emitting output directly.

The logout token must be signed with the algorithm the OP uses for this
client's ID tokens (the `id_token_signed_response_alg` client metadata),
which defaults to `RS256` per OpenID Connect. If your OP signs tokens with a
different algorithm, set the `idTokenSignedResponseAlg` constructor parameter
accordingly (or to `null` to accept any supported algorithm).

If this client was registered on the OP with
`backchannel_logout_session_required` set to true (meaning the OP always
includes a `sid` identifying the exact session to terminate), set the
`backchannelLogoutSessionRequired` constructor parameter to true. Logout
tokens that then arrive without a `sid` claim are rejected (HTTP 400),
instead of falling back to a broader subject-wide logout.

### How the affected login is terminated

A back-channel logout request arrives outside the context of the End-User's
session (no session cookie is sent with it), so the client can not remove
the affected login data from its session store directly. Instead, the
revocation is recorded in a login revocation registry, which is backed by
the shared client cache by default (the same file-based cache used for OP
configuration and JWKS content - shared between requests).

Logins are matched against recorded revocations whenever the persisted
login data is read (`getLoginData()`, `getIdToken()`...): a revoked login
is cleared from the session store (local logout) and observed as absent. A
logout token with a `sid` claim only terminates the login of that
particular OP session, while a logout token with only a `sub` claim
terminates all of that subject's logins (established before the
revocation).

For the application this means: if you use `getLoginData() !== null` (or
similar) as your "user is logged in via OIDC" check on each request, logins
terminated via Back-Channel Logout are picked up automatically. If you keep
your own application session state independent of the login data, check the
login data on each request and end your application session when it is
gone. Note that the client does not (and can not) destroy the PHP session
of the affected user itself.

If you use a custom cache or session setup, make sure the cache passed to
the client is shared between requests - otherwise revocations recorded on
the back-channel logout endpoint would not be observable in user sessions.
A custom registry implementation can be provided via the
`RequestDataHandler` constructor (`loginRevocationRegistry` parameter).

## Note on Caching

OIDC client uses caching to avoid sending HTTP requests to fetch OIDC
Expand Down
28 changes: 28 additions & 0 deletions docs/3-Federated-Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,34 @@ request using `validateLogoutCallback()` (verifies the returned `state`):
$client->validateLogoutCallback();
```

### 5. Back-Channel Logout

Back-Channel Logout is also available, same as for the
[Pre-Registered Client](2-Pre-Registered-Client.md#back-channel-logout):
call `handleBackchannelLogoutRequest()` on the endpoint which receives
back-channel logout requests from OPs. Register that endpoint as the
`backchannel_logout_uri` claim in this RP's OpenID Relying Party federation
metadata (which can be provided using the Relying Party configuration
additional claims).

```php
/** @var \Cicnavi\Oidc\FederatedClient $client */

// File: backchannel-logout.php (published as 'backchannel_logout_uri')
$client->handleBackchannelLogoutRequest();
```

Since the OP is not known upfront, the issuing OP is determined from the
logout token `iss` claim, and trusted only if a federation Trust Chain can
be resolved from it to one of the configured Trust Anchors. The OP JWKS
used to verify the logout token signature is taken from the resolved OP
metadata.

If this RP publishes `backchannel_logout_session_required` as true in its
Relying Party metadata (via the Relying Party configuration additional
claims), logout tokens that arrive without a `sid` claim are rejected
(HTTP 400) instead of falling back to a broader subject-wide logout.

## Entity Configuration Endpoint

To participate in a federation, your RP must publish its
Expand Down
52 changes: 51 additions & 1 deletion docs/4-Dynamically-Registered-Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ prepared from the constructor parameters:
parameter),
* `post_logout_redirect_uris` - if provided using the
`postLogoutRedirectUris` parameter (used for RP-Initiated Logout, see
below).
below),
* `backchannel_logout_uri` (and optionally
`backchannel_logout_session_required`) - if provided using the
`backchannelLogoutUri` / `backchannelLogoutSessionRequired` parameters
(used for Back-Channel Logout, see below).

Any additional client metadata claims can be provided using the
`additionalClientMetadata` parameter. Claims provided here override the
Expand Down Expand Up @@ -205,6 +209,52 @@ Note that providing `postLogoutRedirectUris` changes the client metadata
set, so an existing client registration will be updated (or replaced)
accordingly.

### Back-Channel Logout

Back-Channel Logout is also available, same as for the
[Pre-Registered Client](2-Pre-Registered-Client.md#back-channel-logout):
call `handleBackchannelLogoutRequest()` on the endpoint which receives
back-channel logout requests from the OP. Register that endpoint as the
`backchannel_logout_uri` client metadata using the `backchannelLogoutUri`
constructor parameter (and optionally register
`backchannel_logout_session_required` using the
`backchannelLogoutSessionRequired` 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',
backchannelLogoutUri: 'https://your-example.org/backchannel-logout',
);

// File: backchannel-logout.php
$oidcClient->handleBackchannelLogoutRequest();
```

The logout token audience is validated against the persisted client
registrations - the current one and any retained per-client entry of a
replaced registration - so a logout for a superseded (but still persisted)
registration is still honored while old-client sessions may exist. The
logout token is validated using the policy of the matched registration
itself - the signing algorithm (`id_token_signed_response_alg`) and the
`sid` requirement (`backchannel_logout_session_required`) it was registered
with - so a registration that was replaced after those settings changed
still validates its own logout tokens correctly. No client registration is
performed or updated while handling back-channel logout requests.

When the matched registration declares `backchannel_logout_session_required`
as true, a logout token that does not carry a `sid` claim is rejected
(responded to with HTTP 400), since such a client asked the OP to always
identify the exact session to terminate rather than falling back to a
subject-wide logout. This is registered through the
`backchannelLogoutSessionRequired` constructor parameter (or an
`additionalClientMetadata` override). Note that providing `backchannelLogoutUri`
changes the client metadata set, so an existing client registration will be
updated (or replaced) accordingly.

## Requirements on the OpenID Provider

* OP metadata must advertise a `registration_endpoint`.
Expand Down
Loading
Loading