1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2016-2025
6: *
7: * This program is free software; you can redistribute it and/or
8: * modify it under the terms of the GNU General Public
9: * License as published by the Free Software Foundation; either
10: * version 2 of the License, or (at your option) any later version.
11: *
12: * This program is distributed in the hope that it will be useful,
13: * but WITHOUT ANY WARRANTY; without even the implied warranty of
14: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15: * General Public License for more details.
16: *
17: * You should have received a copy of the GNU General Public
18: * License along with this program; if not, write to the Free
19: * Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
20: *
21: */
22:
23: namespace SimpleID\Protocols\Connect;
24:
25: use Psr\Log\LogLevel;
26: use SimpleID\Auth\AuthManager;
27: use SimpleID\Base\RequestState;
28: use SimpleID\Crypt\Random;
29: use SimpleID\Crypt\SecurityToken;
30: use SimpleID\Protocols\Connect\ConnectModule;
31: use SimpleID\Protocols\OAuth\Response;
32: use SimpleID\Protocols\OAuth\OAuthAuthGrantEvent;
33: use SimpleID\Store\StoreManager;
34: use SimpleID\Util\Events\BaseDataCollectionEvent;
35: use SimpleID\Util\Forms\FormState;
36: use SimpleID\Util\UI\Template;
37: use SimpleID\Module;
38: use SimpleID\ModuleManager;
39: use SimpleJWT\JWT;
40: use SimpleJWT\InvalidTokenException;
41:
42: class ConnectSessionModule extends Module {
43: static function init($f3) {
44: $f3->route('GET @connect_check_session: /connect/session', 'SimpleID\Protocols\Connect\ConnectSessionModule->check_session');
45: $f3->route('GET|POST @connect_logout: /connect/logout', 'SimpleID\Protocols\Connect\ConnectSessionModule->logout');
46: $f3->route('GET @connect_logout_complete: /connect/logout_complete/@token', 'SimpleID\Protocols\Connect\ConnectSessionModule->logoutComplete');
47: }
48:
49: /**
50: * Provides a page for use in an iframe to determine whether session status
51: * has changed
52: *
53: * @return void
54: */
55: public function check_session() {
56: $auth = AuthManager::instance();
57: $tpl = Template::instance();
58:
59: $this->f3->set('cookie_name', $auth->getCookieName('uals'));
60:
61: print $tpl->render('connect_check_session.html');
62: }
63:
64: /**
65: * Relying party-initiated logout endpoint
66: *
67: * @param \Base $f3
68: * @param array<string, mixed> $params
69: * @return void
70: */
71: public function logout($f3, $params) {
72: $store = StoreManager::instance();
73: $auth = AuthManager::instance();
74:
75: if ($this->f3->exists('POST.fs') !== false) {
76: $token = new SecurityToken();
77: $form_state = FormState::decode($token->getPayload($this->f3->get('POST.fs')));
78:
79: if (!$token->verify($this->f3->get('POST.tk'), 'connect_logout')) {
80: $this->logger->log(LogLevel::WARNING, 'Security token ' . $this->f3->get('POST.tk') . ' invalid.');
81: $this->f3->set('message', $this->f3->get('intl.common.invalid_tk'));
82: $this->logoutForm($form_state);
83: return;
84: }
85:
86: if ($this->f3->get('POST.op') == 'cancel') {
87: if ($form_state['connect_logout']['post_logout_redirect_uri']) {
88: $response = new Response();
89: if (isset($form_state['connect_logout']['state'])) $response['state'] = $form_state['connect_logout']['state'];
90: $response->renderRedirect($form_state['connect_logout']['post_logout_redirect_uri']);
91: } else {
92: $this->f3->set('message', $this->f3->get('intl.common.logout_cancelled'));
93:
94: /** @var \SimpleID\Base\IndexModule $index_module */
95: $index_module = ModuleManager::instance()->getModule('SimpleID\Base\IndexModule');
96: $index_module->index();
97: }
98: return;
99: } else {
100: if ($form_state['connect_logout']['post_logout_redirect_uri']) {
101: // set up continue param and redirect
102: $request_state = new RequestState();
103: $request_state->setRoute('connect/logout_complete/' . $token->generate($form_state['connect_logout']));
104:
105: $destination = 'continue/' . rawurlencode($token->generate($request_state));
106: $this->f3->reroute('@auth_logout(1=' . $destination . ')');
107: } else {
108: $this->f3->reroute('@auth_logout');
109: }
110: }
111: } else {
112: $form_state = new FormState([ 'connect_logout' => [] ]);
113: if ($this->f3->exists('REQUEST.state')) $form_state->set('connect_logout.state', $this->f3->get('REQUEST.state'));
114:
115: // Check for id_token_hint. If it is a valid ID token AND it is the
116: // current logged in user, then we can proceed with log out. Otherwise
117: // we ignore the logout request
118: if ($this->f3->exists('REQUEST.id_token_hint')) {
119: try {
120: $id_token_hint = $this->f3->get('REQUEST.id_token_hint');
121: $jwt = JWT::deserialise($id_token_hint);
122: $claims = $jwt['claims'];
123:
124: $client_id = $claims['aud'];
125: $sub = $claims['sub'];
126:
127: $client = $store->loadClient($client_id, 'SimpleID\Protocols\OAuth\OAuthClient');
128:
129: $user_match = $client && ($sub == ConnectModule::getSubject($auth->getUser(), $client));
130:
131: if ($client && $client['connect']['post_logout_redirect_uris'] && $this->f3->exists('REQUEST.post_logout_redirect_uri')) {
132: $post_logout_redirect_uri = $this->f3->get('REQUEST.post_logout_redirect_uri');
133:
134: if (in_array($post_logout_redirect_uri, $client['connect']['post_logout_redirect_uris']))
135: $form_state->set('connect_logout.post_logout_redirect_uri', $post_logout_redirect_uri);
136: }
137: } catch (InvalidTokenException $e) {
138: $user_match = false;
139: }
140:
141: if ($user_match) {
142: $this->logoutForm($form_state);
143: } else {
144: // The user that the id_token_hint points to is not the same user as the one
145: // currently logged in.
146: $this->fatalError($this->f3->get('intl.common.already_logged_out'), 400);
147: }
148: } elseif ($auth->isLoggedIn()) {
149: // Prompt for log out
150: $this->logoutForm($form_state);
151: } else {
152: // User has already been logged out
153: $this->f3->set('message', $this->f3->get('intl.core.auth.logout_success'));
154: /** @var \SimpleID\Auth\AuthModule $auth_module */
155: $auth_module = ModuleManager::instance()->getModule('SimpleID\Auth\AuthModule');
156: $auth_module->loginForm();
157: return;
158: }
159: }
160: }
161:
162: /**
163: * @param FormState|null $form_state
164: * @return void
165: */
166: protected function logoutForm($form_state = null) {
167: if ($form_state == null) $form_state = new FormState();
168: $tpl = Template::instance();
169:
170: $token = new SecurityToken();
171: $this->f3->set('tk', $token->generate('connect_logout', SecurityToken::OPTION_BIND_SESSION));
172: $this->f3->set('fs', $token->generate($form_state->encode()));
173:
174: // logout_label is already defined in Module
175:
176: $this->f3->set('user_header', true);
177: $this->f3->set('title', $this->f3->get('intl.common.logout'));
178: $this->f3->set('page_class', 'is-dialog-page');
179: $this->f3->set('layout', 'connect_logout.html');
180:
181: header('X-Frame-Options: DENY');
182: print $tpl->render('page.html');
183: }
184:
185: /**
186: * @param \Base $f3
187: * @param array<string, mixed> $params
188: * @return void
189: */
190: public function logoutComplete($f3, $params) {
191: $token = new SecurityToken();
192:
193: $payload = $token->getPayload($params['token']);
194:
195: if ($payload === null) {
196: $this->fatalError($this->f3->get('intl.common.invalid_request'), 400);
197: return;
198: }
199:
200: $response = new Response();
201: if (isset($payload['state'])) $response['state'] = $payload['state'];
202: $response->renderRedirect($payload['post_logout_redirect_uri']);
203: }
204:
205: /**
206: * Builds the OpenID Connect Session Management response on a successful
207: * authentication.
208: *
209: * @return void
210: * @see SimpleID\Protocols\OAuth\OAuthAuthGrantEvent
211: */
212: function onOAuthAuthGrantEvent(OAuthAuthGrantEvent $event) {
213: $request = $event->getRequest();
214: $response = $event->getResponse();
215:
216: $response['session_state'] = $this->buildSessionState($request['client_id'], $request['redirect_uri']);
217: }
218:
219: /**
220: * @return void
221: */
222: public function onOauthMetadata(BaseDataCollectionEvent $event) {
223: $event->addResult([
224: 'check_session_iframe' => $this->getCanonicalURL('@connect_check_session', '', 'https'),
225: 'end_session_endpoint' => $this->getCanonicalURL('@connect_logout', '', 'https')
226: ]);
227: }
228:
229: /**
230: * Builds a session state. The session state is bound to:
231: *
232: * - the client ID
233: * - the origin of the redirect URI
234: * - the user agent login state {@link \SimpleID\Auth\AuthManager::assignUALoginState()}
235: *
236: * @param string $client_id the client ID
237: * @param string $redirect_uri the redirect URI
238: * @return string the session state
239: * @link https://openid.net/specs/openid-connect-session-1_0.html#CreatingUpdatingSessions
240: */
241: private function buildSessionState($client_id, $redirect_uri) {
242: $auth = AuthManager::instance();
243: $rand = new Random();
244:
245: $origin = $this->getOrigin($redirect_uri);
246:
247: $uals = $auth->assignUALoginState();
248: $salt = $rand->secret(8);
249: return hash_hmac('sha256', $client_id . ' ' . $origin . ' ' . $salt, $uals) . '.' . $salt;
250: }
251: }
252:
253:
254:
255: ?>
256: