Skip to content

Commit f08dd62

Browse files
smarcetclaude
andcommitted
test: prove cold-session reload via back-channel-minted id_token_hint
Integration test for the native-app SSO handoff scenario: an id_token obtained through a cookie-less back-channel authorization-code exchange (as a native app redeems codes) is presented as id_token_hint on /oauth2/auth from a brand-new session. processUserHint() must reload the OP session from the token's jti (AuthService::reloadSession) and re-authenticate the user without any credential prompt, redirecting straight back to the client with a fresh code. Includes a negative control: the same cold request without the hint bounces to /auth/login. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 83eaa4a commit f08dd62

1 file changed

Lines changed: 179 additions & 0 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php namespace Tests;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Auth\User;
16+
use Database\Seeders\TestSeeder;
17+
use Illuminate\Support\Facades\Auth;
18+
use Illuminate\Support\Facades\Session;
19+
use Illuminate\Support\Str;
20+
use jwe\IJWE;
21+
use jwk\impl\RSAJWKFactory;
22+
use jwk\impl\RSAJWKPEMPrivateKeySpecification;
23+
use jwk\JSONWebKeyPublicKeyUseValues;
24+
use jws\IJWS;
25+
use LaravelDoctrine\ORM\Facades\EntityManager;
26+
use OAuth2\OAuth2Protocol;
27+
use utils\factories\BasicJWTFactory;
28+
use Utils\Services\IAuthService;
29+
use Utils\Services\UtilsServiceCatalog;
30+
31+
/**
32+
* Class OIDCColdSessionReloadTest
33+
*
34+
* Covers the native-app SSO handoff scenario: an id_token minted through a
35+
* back-channel authorization-code exchange (no browser cookies, as a native
36+
* app does it) is later presented as id_token_hint on /oauth2/auth from a
37+
* brand-new cold session. processUserHint() must reload the OP session via
38+
* the token's jti (AuthService::reloadSession) and authenticate the user
39+
* without any credential prompt.
40+
*/
41+
final class OIDCColdSessionReloadTest extends OpenStackIDBaseTestCase
42+
{
43+
const ClientId = '.-_~87D8/Vcvr6fvQbH4HyNgwTlfSyQ3x.openstack.client';
44+
const ClientSecret = 'ITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhgITc/6Y5N7kOtGKhg';
45+
const RedirectUri = 'https://www.test.com/oauth2';
46+
const Scope = 'openid profile email';
47+
48+
/**
49+
* @var User
50+
*/
51+
private $user;
52+
53+
public function createApplication()
54+
{
55+
$app = parent::createApplication();
56+
$app->singleton(UtilsServiceCatalog::ServerConfigurationService, StubServerConfigurationService::class);
57+
return $app;
58+
}
59+
60+
protected function prepareForTests(): void
61+
{
62+
parent::prepareForTests();
63+
$user_repository = EntityManager::getRepository(User::class);
64+
$this->user = $user_repository->findOneBy(['email' => 'sebastian@tipit.net']);
65+
Session::start();
66+
}
67+
68+
/**
69+
* Point the session facade at a brand-new anonymous session WITHOUT
70+
* destroying the previous session's server-side data - a real cold
71+
* browser never touches sessions it does not own (flush/regenerate here
72+
* would wipe the very session the id_token's jti references).
73+
*/
74+
private function startColdSession(): void
75+
{
76+
Session::save();
77+
Session::setId(Str::random(40));
78+
Session::start();
79+
Auth::logout();
80+
}
81+
82+
private function authorizeParams(): array
83+
{
84+
return [
85+
'client_id' => self::ClientId,
86+
'redirect_uri' => self::RedirectUri,
87+
'response_type' => OAuth2Protocol::OAuth2Protocol_ResponseType_Code,
88+
'scope' => self::Scope,
89+
];
90+
}
91+
92+
public function testColdSessionReloadFromBackChannelMintedIdTokenHint()
93+
{
94+
// Phase A - interactive "browser" session: user logged, consent granted,
95+
// auth code minted.
96+
$this->be($this->user);
97+
Session::put("openid.authorization.response", IAuthService::AuthorizationResponse_AllowOnce);
98+
99+
$response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", $this->authorizeParams());
100+
101+
$this->assertResponseStatus(302);
102+
$url = $response->getTargetUrl();
103+
$this->assertTrue(!str_contains($url, '/auth/login'), 'phase A must not require login');
104+
parse_str(parse_url($url, PHP_URL_QUERY), $query);
105+
$this->assertTrue(isset($query['code']) && !empty($query['code']), 'phase A must return an auth code');
106+
$code = $query['code'];
107+
108+
// Phase B - back-channel exchange on a fresh cookie-less session,
109+
// exactly as a native app redeems the code.
110+
$this->startColdSession();
111+
112+
$response = $this->action("POST", "OAuth2\OAuth2ProviderController@token",
113+
[
114+
'code' => $code,
115+
'redirect_uri' => self::RedirectUri,
116+
'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_AuthCode,
117+
],
118+
[], [], [],
119+
["HTTP_Authorization" => " Basic " . base64_encode(self::ClientId . ':' . self::ClientSecret)]);
120+
121+
$this->assertResponseStatus(200);
122+
$json = json_decode($response->getContent());
123+
$this->assertTrue(!empty($json->id_token), 'exchange must return an id_token');
124+
125+
// RP-side unwrap: if the id_token is encrypted for the client, decrypt it
126+
// and use the inner JWS as hint (OIDC Core 3.1.2.1 id_token_hint rules).
127+
$id_token_hint = $json->id_token;
128+
$jwt = BasicJWTFactory::build($json->id_token);
129+
if ($jwt instanceof IJWE) {
130+
$recipient_key = RSAJWKFactory::build
131+
(
132+
new RSAJWKPEMPrivateKeySpecification
133+
(
134+
TestSeeder::$client_private_key_1,
135+
RSAJWKPEMPrivateKeySpecification::WithoutPassword,
136+
$jwt->getJOSEHeader()->getAlgorithm()->getString()
137+
)
138+
);
139+
$recipient_key->setKeyUse(JSONWebKeyPublicKeyUseValues::Encryption)->setId('recipient_public_key');
140+
$jwt->setRecipientKey($recipient_key);
141+
$id_token_hint = $jwt->getPlainText();
142+
$jwt = BasicJWTFactory::build($id_token_hint);
143+
}
144+
$this->assertTrue($jwt instanceof IJWS);
145+
146+
// Negative control - cold session, no hint: authorize must bounce to login.
147+
$this->startColdSession();
148+
149+
$response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", $this->authorizeParams());
150+
151+
$this->assertResponseStatus(302);
152+
$this->assertTrue(str_contains($response->getTargetUrl(), '/auth/login'),
153+
'cold session without hint must require login');
154+
155+
// Phase C - cold session WITH the back-channel-minted id_token_hint:
156+
// reloadSession must authenticate the user and, with former consent on
157+
// file, redirect straight back with a fresh code. No login prompt.
158+
$this->startColdSession();
159+
160+
$params = $this->authorizeParams();
161+
$params[OAuth2Protocol::OAuth2Protocol_IDTokenHint] = $id_token_hint;
162+
163+
$response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", $params);
164+
165+
$this->assertResponseStatus(302);
166+
$url = $response->getTargetUrl();
167+
$this->assertTrue(!str_contains($url, '/auth/login'),
168+
sprintf('cold session with id_token_hint must not require login, got %s', $url));
169+
$this->assertTrue(str_starts_with($url, self::RedirectUri),
170+
sprintf('must redirect back to client, got %s', $url));
171+
parse_str(parse_url($url, PHP_URL_QUERY), $query);
172+
$this->assertTrue(isset($query['code']) && !empty($query['code']),
173+
'cold session with id_token_hint must yield a fresh auth code');
174+
175+
// The OP session was effectively rebuilt: user is authenticated again.
176+
$this->assertTrue(Auth::check(), 'reloadSession must leave the user authenticated');
177+
$this->assertEquals($this->user->getId(), Auth::user()->getId());
178+
}
179+
}

0 commit comments

Comments
 (0)