Skip to content

Commit 652dd30

Browse files
committed
feat: migrate to NC Connect V3 (Keycloak)
1 parent cbee91a commit 652dd30

File tree

8 files changed

+524
-163
lines changed

8 files changed

+524
-163
lines changed

.github/workflows/ci.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master, main]
6+
pull_request:
7+
8+
jobs:
9+
lint:
10+
name: Code style
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: shivammathur/setup-php@v2
16+
with:
17+
php-version: '8.2'
18+
19+
- run: composer install --no-interaction
20+
21+
- run: composer lint
22+
23+
syntax:
24+
name: PHP ${{ matrix.php }} syntax
25+
runs-on: ubuntu-latest
26+
strategy:
27+
matrix:
28+
php: ['8.2', '8.3', '8.4', '8.5']
29+
steps:
30+
- uses: actions/checkout@v4
31+
32+
- uses: shivammathur/setup-php@v2
33+
with:
34+
php-version: ${{ matrix.php }}
35+
36+
- name: Check syntax
37+
run: |
38+
find . -name "*.php" -not -path "./vendor/*" -print0 | xargs -0 -n1 php -l

LICENSE

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
MIT License
2+
3+
Copyright (c) 2018 Adil Kachbat and contributors
4+
Copyright (c) 2023-2026 Gecka
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
SOFTWARE.

NcConnectExtendSocialite.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
<?php
22

3+
/*
4+
* Copyright (c) 2018 Adil Kachbat and contributors
5+
* Copyright (c) 2023-2026 Gecka
6+
*
7+
* For the full copyright and license notice, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*/
10+
311
namespace SocialiteProviders\NcConnect;
412

513
use SocialiteProviders\Manager\SocialiteWasCalled;
@@ -8,8 +16,6 @@ class NcConnectExtendSocialite
816
{
917
/**
1018
* Register the provider.
11-
*
12-
* @param \SocialiteProviders\Manager\SocialiteWasCalled $socialiteWasCalled
1319
*/
1420
public function handle(SocialiteWasCalled $socialiteWasCalled)
1521
{

Provider.php

Lines changed: 148 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,100 @@
11
<?php
22

3+
/*
4+
* Copyright (c) 2018 Adil Kachbat and contributors
5+
* Copyright (c) 2023-2026 Gecka
6+
*
7+
* For the full copyright and license notice, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*/
10+
311
namespace SocialiteProviders\NcConnect;
412

513
use GuzzleHttp\RequestOptions;
614
use Illuminate\Support\Arr;
715
use Illuminate\Support\Str;
816
use Laravel\Socialite\Two\InvalidStateException;
917
use SocialiteProviders\Manager\OAuth2\AbstractProvider;
18+
use UnexpectedValueException;
1019

11-
/**
12-
* @see Provider's documentation: https://docs.google.com/document/d/13zo1E1eVMFUmbV6ECw2YvTiM-uPBL0DBuWh5wLtzCpA/edit?pli=1
13-
*/
1420
class Provider extends AbstractProvider
1521
{
16-
/**
17-
* API URLs.
18-
*/
19-
public const PROD_BASE_URL = 'https://connect.gouv.nc/v2/';
22+
public const PROD_BASE_URL = 'https://connect.gouv.nc/v3/realms/nc-connect/';
2023

21-
public const TEST_BASE_URL = 'https://connect-dev.gouv.nc/v2/';
24+
public const TEST_BASE_URL = 'https://connect-dev.gouv.nc/v3/realms/nc-connect/';
2225

2326
public const IDENTIFIER = 'NCCONNECT';
2427

25-
/**
26-
* The scopes being requested.
27-
*
28-
* @var array
29-
*/
3028
protected $scopes = [
3129
'openid',
3230
'identite_pivot',
3331
'profile',
3432
'email',
3533
];
3634

37-
/**
38-
* {@inheritdoc}
39-
*/
4035
protected $scopeSeparator = ' ';
4136

42-
/**
43-
* Return API Base URL.
44-
*
45-
* @return string
46-
*/
47-
protected function getBaseUrl()
37+
protected function getBaseUrl(): string
4838
{
49-
return config('app.env') === 'production' && ! $this->getConfig('force_dev') ? self::PROD_BASE_URL : self::TEST_BASE_URL;
39+
return app()->environment('production') && ! $this->getConfig('force_dev')
40+
? self::PROD_BASE_URL
41+
: self::TEST_BASE_URL;
5042
}
5143

52-
/**
53-
* {@inheritdoc}
54-
*/
55-
public static function additionalConfigKeys()
44+
public static function additionalConfigKeys(): array
5645
{
57-
return ['logout_redirect', 'force_dev'];
46+
return ['logout_redirect', 'force_dev', 'auth_method'];
5847
}
5948

60-
/**
61-
* {@inheritdoc}
62-
*/
63-
protected function getAuthUrl($state)
49+
protected function getAuthUrl($state): string
6450
{
65-
//It is used to prevent replay attacks
66-
$this->parameters['nonce'] = Str::random(20);
51+
$nonce = Str::random(40);
52+
$this->parameters['nonce'] = $nonce;
53+
$this->request->session()->put('ncconnect_nonce', $nonce);
54+
55+
return $this->buildAuthUrlFromBase(
56+
$this->getBaseUrl().'protocol/openid-connect/auth',
57+
$state
58+
);
59+
}
6760

68-
return $this->buildAuthUrlFromBase($this->getBaseUrl().'/authorize', $state);
61+
protected function getTokenUrl(): string
62+
{
63+
return $this->getBaseUrl().'protocol/openid-connect/token';
6964
}
7065

7166
/**
72-
* {@inheritdoc}
67+
* Build HTTP request options with the configured auth method.
7368
*/
74-
protected function getTokenUrl()
69+
protected function buildTokenRequestOptions(array $params): array
7570
{
76-
return $this->getBaseUrl().'/token';
71+
if ($this->getConfig('auth_method') === 'client_secret_post') {
72+
$params['client_id'] = $this->clientId;
73+
$params['client_secret'] = $this->clientSecret;
74+
75+
return [RequestOptions::FORM_PARAMS => $params];
76+
}
77+
78+
return [
79+
RequestOptions::HEADERS => [
80+
'Authorization' => 'Basic '.base64_encode($this->clientId.':'.$this->clientSecret),
81+
],
82+
RequestOptions::FORM_PARAMS => $params,
83+
];
7784
}
7885

79-
/**
80-
* {@inheritdoc}
81-
*/
82-
public function getAccessTokenResponse($code)
86+
public function getAccessTokenResponse($code): array
8387
{
84-
$response = $this->getHttpClient()->post($this->getBaseUrl().'/token', [
85-
RequestOptions::HEADERS => ['Authorization' => 'Basic '.base64_encode($this->clientId.':'.$this->clientSecret)],
86-
RequestOptions::FORM_PARAMS => $this->getTokenFields($code),
87-
]);
88+
$params = [
89+
'grant_type' => 'authorization_code',
90+
'code' => $code,
91+
'redirect_uri' => $this->redirectUrl,
92+
];
93+
94+
$response = $this->getHttpClient()->post(
95+
$this->getTokenUrl(),
96+
$this->buildTokenRequestOptions($params)
97+
);
8898

8999
return json_decode((string) $response->getBody(), true);
90100
}
@@ -94,70 +104,121 @@ public function getAccessTokenResponse($code)
94104
*/
95105
public function user()
96106
{
97-
if ($this->hasInvalidState()) {
98-
throw new InvalidStateException();
99-
}
107+
$user = parent::user();
100108

101-
$response = $this->getAccessTokenResponse($this->getCode());
109+
$idToken = Arr::get($this->credentialsResponseBody, 'id_token');
102110

103-
$user = $this->mapUserToObject($this->getUserByToken(
104-
$token = Arr::get($response, 'access_token')
105-
));
111+
$this->validateNonce($idToken);
106112

107-
//store tokenId session for logout url generation
108-
$this->request->session()->put('fc_token_id', Arr::get($response, 'id_token'));
113+
if ($user instanceof User) {
114+
$user->setTokenId($idToken);
115+
}
109116

110-
return $user->setTokenId(Arr::get($response, 'id_token'))
111-
->setToken($token)
112-
->setRefreshToken(Arr::get($response, 'refresh_token'))
113-
->setExpiresIn(Arr::get($response, 'expires_in'));
117+
return $user;
114118
}
115119

116120
/**
117-
* {@inheritdoc}
121+
* Validate the nonce claim in the id_token against the session value.
122+
*
123+
* @throws InvalidStateException
118124
*/
119-
protected function getUserByToken($token)
125+
protected function validateNonce(?string $idToken): void
120126
{
121-
$response = $this->getHttpClient()->get($this->getBaseUrl().'/userinfo', [
122-
RequestOptions::HEADERS => [
123-
'Authorization' => 'Bearer '.$token,
124-
],
125-
]);
127+
$expectedNonce = $this->request->session()->pull('ncconnect_nonce');
128+
129+
if (! $idToken || ! $expectedNonce) {
130+
throw new InvalidStateException('Missing nonce or id_token.');
131+
}
132+
133+
$parts = explode('.', $idToken);
134+
135+
if (count($parts) !== 3) {
136+
throw new InvalidStateException('Invalid id_token format.');
137+
}
138+
139+
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
140+
141+
if (! is_array($payload) || ($payload['nonce'] ?? null) !== $expectedNonce) {
142+
throw new InvalidStateException('Invalid nonce in id_token.');
143+
}
144+
}
145+
146+
protected function getUserByToken($token): array
147+
{
148+
$response = $this->getHttpClient()->get(
149+
$this->getBaseUrl().'protocol/openid-connect/userinfo',
150+
[RequestOptions::HEADERS => ['Authorization' => 'Bearer '.$token]]
151+
);
126152

127153
return json_decode((string) $response->getBody(), true);
128154
}
129155

130-
/**
131-
* {@inheritdoc}
132-
*/
133-
protected function mapUserToObject(array $user)
156+
protected function mapUserToObject(array $user): User
134157
{
135-
return (new User())->setRaw($user)->map([
136-
'id' => $user['sub'],
137-
'email' => $user['email'],
138-
'email_verified' => $user['email_verified'],
139-
'verified' => $user['verified'] ?? 0,
140-
'preferred_username' => $user['preferred_username'] ?? '',
158+
if (empty($user['sub'])) {
159+
throw new UnexpectedValueException('Missing "sub" claim in userinfo response.');
160+
}
141161

142-
'given_name' => $user['given_name'] ?? '',
143-
'family_name' => $user['family_name'] ?? '',
144-
'birthdate' => $user['birthdate'] ?? '',
145-
'gender' => $user['gender'] ?? '',
146-
'birthplace' => $user['birthplace'] ?? '',
147-
'birthcountry' => $user['birthcountry'] ?? '',
162+
return (new User)->setRaw($user)->map([
163+
'id' => $user['sub'],
164+
'email' => $user['email'] ?? null,
165+
'email_verified' => $user['email_verified'] ?? false,
166+
'verified' => $user['verified'] ?? 0,
167+
'preferred_username' => $user['preferred_username'] ?? '',
168+
'given_name' => $user['given_name'] ?? '',
169+
'first_name' => $user['first_name'] ?? '',
170+
'family_name' => $user['family_name'] ?? '',
171+
'birthdate' => $user['birthdate'] ?? '',
172+
'gender' => $user['gender'] ?? '',
173+
'birthplace' => $user['birthplace'] ?? '',
148174
]);
149175
}
150176

151177
/**
152-
* Generate logout URL for redirection to NcConnect.
178+
* {@inheritdoc}
153179
*/
154-
public function generateLogoutURL()
180+
protected function getRefreshTokenResponse($refreshToken): array
155181
{
156182
$params = [
157-
'post_logout_redirect_uri' => $this->getConfig('logout_redirect'),
158-
'id_token_hint' => $this->request->session()->get('fc_token_id'),
183+
'grant_type' => 'refresh_token',
184+
'refresh_token' => $refreshToken,
159185
];
160186

161-
return $this->getBaseUrl().'/logout?'.http_build_query($params);
187+
$response = $this->getHttpClient()->post(
188+
$this->getTokenUrl(),
189+
$this->buildTokenRequestOptions($params)
190+
);
191+
192+
return json_decode((string) $response->getBody(), true);
193+
}
194+
195+
/**
196+
* Generate logout URL for redirection to NcConnect.
197+
*
198+
* Without arguments, returns the bare logout endpoint.
199+
* With an id_token_hint and/or redirect URI, builds a full RP-Initiated Logout URL.
200+
*/
201+
public function generateLogoutURL(?string $idTokenHint = null, ?string $postLogoutRedirectUri = null): string
202+
{
203+
$logoutUrl = $this->getBaseUrl().'protocol/openid-connect/logout';
204+
205+
$redirectUri = $postLogoutRedirectUri ?? $this->getConfig('logout_redirect');
206+
207+
if ($redirectUri === null && $idTokenHint === null) {
208+
return $logoutUrl;
209+
}
210+
211+
$params = [];
212+
213+
if ($redirectUri !== null) {
214+
$params['client_id'] = $this->clientId;
215+
$params['post_logout_redirect_uri'] = $redirectUri;
216+
}
217+
218+
if ($idTokenHint !== null) {
219+
$params['id_token_hint'] = $idTokenHint;
220+
}
221+
222+
return $logoutUrl.'?'.http_build_query($params);
162223
}
163224
}

0 commit comments

Comments
 (0)