1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2014-2026
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: if (!isset($parts['scheme'])) {
182: $this->setError('server_error', 'redirect_uri must contain a scheme');
183: $this->renderJSON();
184: return;
185: }
186:
187: // 1. Firstly, get the query string
188: $query = str_replace([ '+', '%7E' ], [ '%20', '~' ], http_build_query($this->container));
189:
190: // 2. If there is no query string, then we just return the URL
191: if (!$query) {
192: $url = $redirect_uri;
193: } else {
194: // 3. The URL may already have a query and a fragment. If this is so, we
195: // need to slot in the new query string properly. We disassemble and
196: // reconstruct the URL.
197: if ($parts == false) {
198: $url = $redirect_uri;
199: } else {
200: $url = $parts['scheme'] . '://';
201: if (isset($parts['user'])) {
202: $url .= $parts['user'];
203: if (isset($parts['pass'])) $url .= ':' . $parts['pass'];
204: $url .= '@';
205: }
206: if (isset($parts['host'])) $url .= $parts['host'];
207: if (isset($parts['port'])) $url .= ':' . $parts['port'];
208: if (isset($parts['path'])) $url .= $parts['path'];
209:
210: if (($this->response_mode == self::QUERY_RESPONSE_MODE) || (strpos($url, '#') === FALSE)) {
211: $url .= '?' . ((isset($parts['query'])) ? $parts['query'] . '&' : '') . $query;
212: if (isset($parts['fragment'])) $url .= '#' . $parts['fragment'];
213: } elseif ($this->response_mode == self::FRAGMENT_RESPONSE_MODE) {
214: // In theory $parts['fragment'] should be an empty string, but the
215: // current draft specification does not prohibit putting other things
216: // in the fragment.
217: if (!isset($parts['fragment'])) $parts['fragment'] = '';
218: if (isset($parts['query'])) {
219: $url .= '?' . $parts['query'] . '#' . $parts['fragment'] . '&' . $query;
220: } else {
221: $url .= '#' . $parts['fragment'] . '&' . $query;
222: }
223: }
224: }
225: }
226:
227: if (isset($parts['scheme']) && ((strtolower($parts['scheme']) == 'https') || (strtolower($parts['scheme']) == 'http'))) {
228: $f3->status(303);
229: header('Location: ' . $url);
230: } else {
231: $redirect = new CustomRedirectResponse($url);
232: $redirect->render();
233: }
234: }
235:
236: /**
237: * Renders the response as a JSON object.
238: *
239: * JSON responses are used in the OAuth token endpoint, and other endpoints.
240: *
241: * @param int $status the HTTP status code. If null, the status code is `400`
242: * for error responses and `200` otherwise.
243: * @return void
244: */
245: public function renderJSON($status = NULL) {
246: $f3 = Base::instance();
247:
248: if ($status == NULL) {
249: $status = ($this->isError()) ? 400 : 200;
250: }
251: $f3->status($status);
252: $f3->expire(0);
253:
254: header('Content-Type: application/json;charset=UTF-8');
255: header('Pragma: no-cache');
256: print json_encode($this->container);
257: }
258:
259: /**
260: * Renders the response as a POST request.
261: *
262: * @param string $url the URL to which the response is sent
263: * @return void
264: */
265: public function renderFormPost($url = NULL) {
266: $form = new FormResponse($this->container);
267: if ($url == NULL) $url = $this->redirect_uri;
268: $form->render($url);
269: }
270:
271: /**
272: * {@inheritdoc}
273: */
274: public function toArray() {
275: $array = parent::toArray();
276: $array['#response_mode'] = $this->response_mode;
277: $array['#redirect_uri'] = $this->redirect_uri;
278: return $array;
279: }
280:
281: /**
282: * Returns the response modes supported by this class.
283: *
284: * @return array<string> list of response modes
285: */
286: public static function getResponseModesSupported() {
287: return [ self::QUERY_RESPONSE_MODE, self::FRAGMENT_RESPONSE_MODE, self::FORM_POST_RESPONSE_MODE ];
288: }
289: }
290:
291: ?>