1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2014-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: namespace SimpleID\Protocols\OAuth;
23:
24: use \Base;
25: use SimpleID\Protocols\CustomRedirectResponse;
26: use SimpleID\Protocols\FormResponse;
27: use SimpleID\Util\ArrayWrapper;
28:
29: /**
30: * A class representing an OAuth authorization or token (or similar)
31: * response.
32: *
33: * This class is a subclass of {@link ArrayWrapper}. Response parameters
34: * are stored in {@link ArrayWrapper->container} and are accessed
35: * using array syntax.
36: */
37: class Response extends ArrayWrapper {
38: /** Parameter for {@link $response_mode} */
39: const QUERY_RESPONSE_MODE = 'query';
40:
41: /** Parameter for {@link $response_mode} */
42: const FRAGMENT_RESPONSE_MODE = 'fragment';
43:
44: /** Parameter for {@link $response_mode} */
45: const FORM_POST_RESPONSE_MODE = 'form_post';
46:
47: /** @var string for redirect response, the response mode.
48: * This can be one of {@link QUERY_RESPONSE_MODE}
49: * (the query string), {@link FRAGMENT_RESPONSE_MODE} (the fragment) or
50: * {@link FORM_POST_RESPONSE_MODE} (as a page with an automaticlly submitting
51: * form using the `POST` method)
52: */
53: protected $response_mode = self::QUERY_RESPONSE_MODE;
54:
55: /** @var string the redirect URI */
56: protected $redirect_uri = null;
57:
58: /**
59: * Creates an OAuth response.
60: *
61: * An OAuth response is created based on an OAuth request. The
62: * response will contain the same `state` and `redirect_uri`
63: * parameters as the underlying request.
64: *
65: * @param Request $request the request to which the response will
66: * be made
67: * @param array<string, string> $data the initial response parameters
68: */
69: public function __construct($request = NULL, $data = []) {
70: if (isset($data['#response_mode'])) {
71: $this->response_mode = $data['#response_mode'];
72: unset($data['#response_mode']);
73: }
74: if (isset($data['#redirect_uri'])) {
75: $this->redirect_uri = $data['#redirect_uri'];
76: unset($data['#redirect_uri']);
77: }
78:
79: parent::__construct($data);
80:
81: if ($request != NULL) {
82: if (isset($request['state'])) $this->container['state'] = $request['state'];
83: if (isset($request['redirect_uri']) && ($this->redirect_uri == null)) $this->redirect_uri = $request['redirect_uri'];
84: }
85: }
86:
87: /**
88: * Gets the flow to be used in redirect responses.
89: *
90: * @return string the response mode
91: */
92: public function getResponseMode() {
93: return $this->response_mode;
94: }
95:
96: /**
97: * Sets the response mode to be used in redirect responses. This should
98: * be either QUERY_RESPONSE_MODE or FRAGMENT_RESPONSE_MODE
99: *
100: * @param string $response_mode the response mode
101: * @return void
102: */
103: public function setResponseMode($response_mode) {
104: $this->response_mode = $response_mode;
105: }
106:
107: /**
108: * Sets the redirect URI.
109: *
110: * @param string $redirect_uri the redirect URI to set
111: * @return void
112: */
113: public function setRedirectURI($redirect_uri) {
114: $this->redirect_uri = $redirect_uri;
115: }
116:
117: /**
118: * Returns the redirect URI.
119: *
120: * @return string the redirect URI
121: */
122: public function getRedirectURI() {
123: return $this->redirect_uri;
124: }
125:
126: /**
127: * Determines whether the current OAuth response is an error
128: * response.
129: *
130: * A response is an error response if it contains the key `error`.
131: *
132: * @return bool true if the response is an error response
133: */
134: public function isError() {
135: return isset($this->container['error']);
136: }
137:
138: /**
139: * Sets parameters in the current response so that it is an error response.
140: *
141: * Note that existing parameters set in the response are not removed.
142: *
143: * @param string $error the OAuth error code
144: * @param string $error_description the OAuth error description
145: * @param array<string, string> $additional additional parameters to include
146: * @return Response this object (for chaining)
147: */
148: public function setError($error, $error_description = NULL, $additional = []) {
149: foreach (array_keys($this->container) as $key) {
150: if ($key != 'state') unset($this->container[$key]);
151: }
152: $this->container['error'] = $error;
153: if ($error_description != null) $this->container['error_description'] = $error_description;
154: $this->container = array_merge($this->container, $additional);
155: return $this;
156: }
157:
158: /**
159: * Renders the response as a redirect or a form post.
160: *
161: * Redirect responses are used in the OAuth authorization endpoint.
162: *
163: * @param string $redirect_uri the URL to which the response is sent.
164: * If null, the {@link $redirect_uri} property will be used
165: * @return void
166: */
167: public function renderRedirect($redirect_uri = NULL) {
168: $f3 = Base::instance();
169:
170: if ($redirect_uri == NULL) $redirect_uri = $this->redirect_uri;
171: // If $redirect_uri is still null we should output an error
172: if ($redirect_uri == NULL) {
173: $this->setError('server_error', 'Missing redirect_uri');
174: $this->renderJSON();
175: return;
176: }
177:
178: if ($this->response_mode == self::FORM_POST_RESPONSE_MODE) $this->renderFormPost($redirect_uri);
179:
180: $parts = parse_url($redirect_uri);
181:
182: // 1. Firstly, get the query string
183: $query = str_replace([ '+', '%7E' ], [ '%20', '~' ], http_build_query($this->container));
184:
185: // 2. If there is no query string, then we just return the URL
186: if (!$query) {
187: $url = $redirect_uri;
188: } else {
189: // 3. The URL may already have a query and a fragment. If this is so, we
190: // need to slot in the new query string properly. We disassemble and
191: // reconstruct the URL.
192: if ($parts == false) {
193: $url = $redirect_uri;
194: } else {
195: $url = $parts['scheme'] . '://';
196: if (isset($parts['user'])) {
197: $url .= $parts['user'];
198: if (isset($parts['pass'])) $url .= ':' . $parts['pass'];
199: $url .= '@';
200: }
201: if (isset($parts['host'])) $url .= $parts['host'];
202: if (isset($parts['port'])) $url .= ':' . $parts['port'];
203: if (isset($parts['path'])) $url .= $parts['path'];
204:
205: if (($this->response_mode == self::QUERY_RESPONSE_MODE) || (strpos($url, '#') === FALSE)) {
206: $url .= '?' . ((isset($parts['query'])) ? $parts['query'] . '&' : '') . $query;
207: if (isset($parts['fragment'])) $url .= '#' . $parts['fragment'];
208: } elseif ($this->response_mode == self::FRAGMENT_RESPONSE_MODE) {
209: // In theory $parts['fragment'] should be an empty string, but the
210: // current draft specification does not prohibit putting other things
211: // in the fragment.
212: if (!isset($parts['fragment'])) $parts['fragment'] = '';
213: if (isset($parts['query'])) {
214: $url .= '?' . $parts['query'] . '#' . $parts['fragment'] . '&' . $query;
215: } else {
216: $url .= '#' . $parts['fragment'] . '&' . $query;
217: }
218: }
219: }
220: }
221:
222: if (isset($parts['scheme']) && ((strtolower($parts['scheme']) == 'https') || (strtolower($parts['scheme']) == 'http'))) {
223: $f3->status(303);
224: header('Location: ' . $url);
225: } else {
226: $redirect = new CustomRedirectResponse($url);
227: $redirect->render();
228: }
229: }
230:
231: /**
232: * Renders the response as a JSON object.
233: *
234: * JSON responses are used in the OAuth token endpoint, and other endpoints.
235: *
236: * @param int $status the HTTP status code. If null, the status code is `400`
237: * for error responses and `200` otherwise.
238: * @return void
239: */
240: public function renderJSON($status = NULL) {
241: $f3 = Base::instance();
242:
243: if ($status == NULL) {
244: $status = ($this->isError()) ? 400 : 200;
245: }
246: $f3->status($status);
247: $f3->expire(0);
248:
249: header('Content-Type: application/json;charset=UTF-8');
250: header('Pragma: no-cache');
251: print json_encode($this->container);
252: }
253:
254: /**
255: * Renders the response as a POST request.
256: *
257: * @param string $url the URL to which the response is sent
258: * @return void
259: */
260: public function renderFormPost($url = NULL) {
261: $form = new FormResponse($this->container);
262: if ($url == NULL) $url = $this->redirect_uri;
263: $form->render($url);
264: }
265:
266: /**
267: * {@inheritdoc}
268: */
269: public function toArray() {
270: $array = parent::toArray();
271: $array['#response_mode'] = $this->response_mode;
272: $array['#redirect_uri'] = $this->redirect_uri;
273: return $array;
274: }
275:
276: /**
277: * Returns the response modes supported by this class.
278: *
279: * @return array<string> list of response modes
280: */
281: public static function getResponseModesSupported() {
282: return [ self::QUERY_RESPONSE_MODE, self::FRAGMENT_RESPONSE_MODE, self::FORM_POST_RESPONSE_MODE ];
283: }
284: }
285:
286: ?>