blob: 30c50e614146e34b1235a599d285d5ab85a43682 [file] [log] [blame]
Nico Huberee52fbc2023-06-24 11:52:57 +00001<?php
2/*
3 * SimpleID
4 *
5 * Copyright (C) Kelvin Mo 2007-9
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 * $Id$
22 */
23
24/**
25 * Support for XRDS based discovery.
26 *
27 * The functions for this file supports HTTP-based identifiers. For XRIs, the
28 * resolution service xri.net is used to resolve to HTTP-based URLs.
29 *
30 * @package simpleid
31 * @since 0.7
32 * @filesource
33 */
34
35include_once "http.inc.php";
36
37/**
38 * The namespace identifier for an XRDS document.
39 */
40define('XRDS_NS', 'xri://$xrds');
41
42/**
43 * The namespace identifier for XRDS version 2.
44 */
45define('XRD2_NS', 'xri://$xrd*($v*2.0)');
46
47/**
48 * The namespace identifier for XRDS Simple.
49 */
50define('XRDS_SIMPLE_NS', 'http://xrds-simple.net/core/1.0');
51
52/**
53 * The type identifier for XRDS Simple.
54 */
55define('XRDS_SIMPLE_TYPE', 'xri://$xrds*simple');
56
57/**
58 * The namespace identifier for OpenID services.
59 */
60define('XRD_OPENID_NS', 'http://openid.net/xmlns/1.0');
61
62/**
63 * Obtains the services for particular identifier.
64 *
65 * This function attempts to discover and obtain the XRDS document associated
66 * with the identifier, parses the XRDS document and returns an array of
67 * services.
68 *
69 * If an XRDS document is not found, and $openid is set to true, this function
70 * will also attempt to discover OpenID services by looking for link elements
71 * with rel of openid.server or openid2.provider in the discovered HTML document.
72 *
73 * @param string $identifier the identifier
74 * @param bool $openid if true, performs additional discovery of OpenID services
75 * by looking for link elements within the discovered document
76 * @return array an array of discovered services, or an empty array if no services
77 * are found
78 */
79function discovery_xrds_discover($identifier, $openid = FALSE) {
80 $identifier = discovery_xrds_normalize($identifier);
81 $url = discovery_xrds_url($identifier);
82
83 $xrds = discovery_xrds_get($url);
84
85 if ($xrds) {
86 return discovery_xrds_parse($xrds);
87 } else {
88 if ($openid) return discovery_html_get_services($url);
89 return array();
90 }
91}
92
93/**
94 * Given an array of discovered services, obtains information on services of
95 * a particular type.
96 *
97 * @param array $services the discovered services
98 * @param string $type the URI of the type of service to obtain
99 * @return array an array of matching services, or an empty array of no services
100 * match
101 */
102function discovery_xrds_services_by_type($services, $type) {
103 $matches = array();
104
105 foreach ($services as $service) {
106 foreach ($service['type'] as $service_type) {
107 if ($service_type == $type) $matches[] = $service;
108 }
109 }
110 return $matches;
111}
112
113/**
114 * Given an array of discovered services, obtains information on the service of
115 * a specified ID.
116 *
117 * @param array $services the discovered services
118 * @param string $id the XML ID of the service in the XRDS document
119 * @return array the matching service, or NULL of no services
120 * are found
121 */
122function discovery_xrds_service_by_id($services, $id) {
123 foreach ($services as $service) {
124 if ($service['#id'] == $id) return $service;
125 }
126 return NULL;
127}
128
129/**
130 * Obtains a XRDS document at a particular URL. Performs Yadis discovery if
131 * the URL does not produce a XRDS document.
132 *
133 * @param string $url the URL
134 * @param bool $check whether to check the content type of the response is
135 * application/xrds+xml
136 * @param int $retries the number of tries to make
137 * @return string the contents of the XRDS document
138 */
139function discovery_xrds_get($url, $check = TRUE, $retries = 5) {
140 if ($retries == 0) return NULL;
141
142 $response = http_make_request($url, array('Accept' => 'application/xrds+xml'));
143
144 if (isset($response['http-error'])) return NULL;
145 if (($response['content-type'] == 'application/xrds+xml') || ($check == FALSE)) {
146 return $response['data'];
147 } elseif (isset($response['headers']['x-xrds-location'])) {
148 return discovery_xrds_get($response['headers']['x-xrds-location'], false, $retries - 1);
149 } elseif (isset($response['data'])) {
150 $location = _discovery_meta_httpequiv('X-XRDS-Location', $response['data']);
151 if ($location) {
152 return discovery_xrds_get($location, false, $retries - 1);
153 }
154 return NULL;
155 } else {
156 return NULL;
157 }
158}
159
160/**
161 * Normalises an identifier for discovery.
162 *
163 * If the identifier begins with xri://, acct: or mailto:, this is stripped out. If the identifier
164 * does not begin with a valid URI scheme, http:// is assumed and added to the
165 * identifier.
166 *
167 * @param string $identifier the identifier to normalise
168 * @return string the normalised identifier
169 */
170function discovery_xrds_normalize($identifier) {
171 $normalized = $identifier;
172
173 if (discovery_is_xri($identifier)) {
174 if (stristr($identifier, 'xri://') !== false) $normalized = substr($identifier, 6);
175 } elseif (discovery_is_email($identifier)) {
176 if (stristr($identifier, 'acct:') !== false) $normalized = substr($identifier, 5);
177 if (stristr($identifier, 'mailto:') !== false) $normalized = substr($identifier, 7);
178 } else {
179 if (stristr($identifier, '://') === false) $normalized = 'http://'. $identifier;
180 if (substr_count($normalized, '/') < 3) $normalized .= '/';
181 }
182
183 return $normalized;
184}
185
186/**
187 * Obtains a URL for an identifier. If the identifier is a XRI, the XRI resolution
188 * service is used to convert the identifier to a URL.
189 *
190 * @param string $identifier the identifier
191 * @return string the URL
192 */
193function discovery_xrds_url($identifier) {
194 if (discovery_is_xri($identifier)) {
195 return 'http://xri.net/' . $identifier;
196 } elseif (discovery_is_email($identifier)) {
197 //list($user, $host) = explode('@', $identifier, 2);
198 //$host_meta = 'http://' . $host . '/.well-known/host-meta';
199 } else {
200 return $identifier;
201 }
202
203}
204
205/**
206 * Determines whether an identifier is an XRI.
207 *
208 * XRI identifiers either start with xri:// or with @, =, +, $ or !.
209 *
210 * @param string $identifier the parameter to test
211 * @return bool true if the identifier is an XRI
212 */
213function discovery_is_xri($identifier) {
214 $firstchar = substr($identifier, 0, 1);
215 if ($firstchar == "@" || $firstchar == "=" || $firstchar == "+" || $firstchar == "\$" || $firstchar == "!") return true;
216 if (stristr($identifier, 'xri://') !== FALSE) return true;
217 return false;
218}
219
220/**
221 * Determines whether an identifier is an e-mail address.
222 *
223 * An identifier is an e-mail address if it:
224 *
225 * - has a single @ character
226 * - does not have a slash character
227 *
228 * @param string $identifier the parameter to test
229 * @return bool true if the identifier is an e-mail address
230 */
231function discovery_is_email($identifier) {
232 // If it begins with acct: or mailto:, strip it out
233 if (stristr($identifier, 'acct:') !== false) $identifier = substr($identifier, 5);
234 if (stristr($identifier, 'mailto:') !== false) $identifier = substr($identifier, 7);
235
236 // If it contains a slash, it is not an e-mail address
237 if (strpos($identifier, "/") !== false) return false;
238
239 $at = strpos($identifier, "@");
240
241 // If it does not contain a @, it is not an e-mail address
242 if ($at === false) return false;
243
244 // If it contains more than one @, it is not an e-mail
245 if (strrpos($identifier, "@") != $at) return false;
246
247 return true;
248}
249
250
251/**
252 * Callback function to sort service and URI elements based on priorities
253 * specified in the XRDS document.
254 *
255 * The XRDS specification allows multiple instances of certain elements, such
256 * as Service and URI. The specification allows an attribute called priority
257 * so that the document creator can specify the order the elements should be used.
258 *
259 * @param array $a
260 * @param array $b
261 * @return int
262 */
263function discovery_xrds_priority_sort($a, $b) {
264 if (!isset($a['#priority']) && !isset($b['#priority'])) return 0;
265
266 // if #priority is missing, #priority is assumed to be infinity
267 if (!isset($a['#priority'])) return 1;
268 if (!isset($b['#priority'])) return -1;
269
270 if ($a['#priority'] == $b['#priority']) return 0;
271 return ($a['#priority'] < $b['#priority']) ? -1 : 1;
272}
273
274/**
275 * Parses an XRDS document to return services available.
276 *
277 * @param string $xrds the XRDS document
278 * @return array the parsed structure
279 *
280 * @see XRDSParser
281 */
282function discovery_xrds_parse($xrds) {
283 $parser = new XRDSParser();
284 $parser->parse($xrds);
285 $parser->free();
286 $services = $parser->services();
287 uasort($services, 'discovery_xrds_priority_sort');
288
289 return $services;
290}
291
292/**
293 * Obtains the OpenID services for particular identifier by scanning for link
294 * elements in the returned document.
295 *
296 * Note that this function does not use the YADIS protocol to scan for services.
297 * To use the YADIS protocol, use {@link discovery_get_services()}.
298 *
299 * @param string $url the URL
300 * @return array an array of discovered services, or an empty array if no services
301 * are found
302 */
303function discovery_html_get_services($url) {
304 $services = array();
305
306 $response = http_make_request($url);
307 $html = $response['data'];
308
309 $uri = _discovery_link_rel('openid2.provider', $html);
310 $delegate = _discovery_link_rel('openid2.local_id', $html);
311
312 if ($uri) {
313 $service = array(
314 'type' => 'http://specs.openid.net/auth/2.0/signon',
315 'uri' => $uri
316 );
317 if ($delegate) $service['localid'] = $delegate;
318 $services[] = $service;
319 }
320
321 $uri = _discovery_link_rel('openid.server', $html);
322 $delegate = _discovery_link_rel('openid.delegate', $html);
323
324 if ($uri) {
325 $service = array(
326 'type' => 'http://openid.net/signon/1.0',
327 'uri' => $uri
328 );
329 if ($delegate) $service['localid'] = $delegate;
330 $services[] = $service;
331 }
332
333 return $services;
334}
335
336/**
337 * Searches through an HTML document to obtain the value of a meta
338 * element with a specified http-equiv attribute.
339 *
340 * @param string $equiv the http-equiv attribute for which to search
341 * @param string $html the HTML document to search
342 * @return mixed the value of the meta element, or FALSE if the element is not
343 * found
344 */
345function _discovery_meta_httpequiv($equiv, $html) {
346 $html = preg_replace('/<!(?:--(?:[^-]*|-[^-]+)*--\s*)>/', '', $html); // Strip html comments
347
348 $equiv = preg_quote($equiv);
349 preg_match('|<meta\s+http-equiv=["\']'. $equiv .'["\'](.*)/?>|iUs', $html, $matches);
350 if (isset($matches[1])) {
351 preg_match('|content=["\']([^"]+)["\']|iUs', $matches[1], $content);
352 if (isset($content[1])) {
353 return $content[1];
354 }
355 }
356 return FALSE;
357}
358
359/**
360 * Searches through an HTML document to obtain the value of a link
361 * element with a specified rel attribute.
362 *
363 * @param string $rel the rel attribute for which to search
364 * @param string $html the HTML document to search
365 * @return mixed the href of the link element, or FALSE if the element is not
366 * found
367 */
368function _discovery_link_rel($rel, $html) {
369 $html = preg_replace('/<!(?:--(?:[^-]*|-[^-]+)*--\s*)>/s', '', $html); // Strip html comments
370
371 $rel = preg_quote($rel);
372 preg_match('|<link\s+rel=["\'](.*)'. $rel .'(.*)["\'](.*)/?>|iUs', $html, $matches);
373 if (isset($matches[3])) {
374 preg_match('|href=["\']([^"]+)["\']|iU', $matches[3], $href);
375 return trim($href[1]);
376 }
377 return FALSE;
378}
379
380/**
381 * A simple XRDS parser.
382 *
383 * This parser uses the classic expat functions available in PHP to parse the
384 * XRDS Simple XML document.
385 *
386 * The result is an array of discovered services.
387 *
388 * @link http://xrds-simple.net/
389 */
390class XRDSParser {
391 /**
392 * XML parser
393 * @var resource
394 * @access private
395 */
396 var $parser;
397
398 /**
399 * Discovered services
400 * @var array
401 * @access private
402 */
403 var $services = array();
404
405 /**
406 * State: are we parsing a service element?
407 * @var bool
408 * @access private
409 */
410 var $in_service = FALSE;
411
412 /**
413 * CDATA buffer
414 * @var string
415 * @access private
416 */
417 var $_buffer;
418 /**
419 * Attributes buffer
420 * @var array
421 * @access private
422 */
423 var $_attribs = array();
424
425 /**
426 * priority attribute buffer
427 * @var string
428 * @access private
429 */
430 var $priority = NULL;
431
432 /**
433 * Currently parsed service buffer
434 * @var array
435 * @access private
436 */
437 var $service = array();
438
439 /**
440 * Creates an instance of the XRDS parser.
441 *
442 * This constructor also initialises the underlying XML parser.
443 */
444 function XRDSParser() {
445 $this->parser = xml_parser_create_ns();
446 xml_parser_set_option($this->parser, XML_OPTION_CASE_FOLDING,0);
447 xml_set_object($this->parser, $this);
448 xml_set_element_handler($this->parser, 'element_start', 'element_end');
449 xml_set_character_data_handler($this->parser, 'cdata');
450 }
451
452 /**
453 * Frees memory associated with the underlying XML parser.
454 *
455 * Note that only the memory associated with the underlying XML parser is
456 * freed. Memory associated with the class itself is not freed.
457 *
458 * @access public
459 */
460 function free() {
461 xml_parser_free($this->parser);
462 }
463
464 /**
465 * Parses an XRDS document.
466 *
467 * Once the parsing is complete, use {@link XRDSParser::services()} to obtain
468 * the services extracted from the document.
469 *
470 * @param string $xml the XML document to parse
471 * @access public
472 */
473 function parse($xml) {
474 xml_parse($this->parser, $xml);
475 }
476
477 /**
478 * Gets an array of discovered services.
479 *
480 * @return array an array of discovered services, or an empty array
481 * @access public
482 * @see XRDSParser::parse()
483 */
484 function services() {
485 return $this->services;
486 }
487
488 /**
489 * XML parser callback
490 *
491 * @access private
492 */
493 function element_start(&$parser, $qualified, $attribs) {
494 list($ns, $name) = $this->parse_namespace($qualified);
495
496 // Strictly speaking, XML namespace URIs are semi-case sensitive
497 // (i.e. the scheme and host are not case sensitive, but other elements
498 // are). However, the XRDS-Simple specifications defines a
499 // namespace URI for XRD (xri://$XRD*($v*2.0) rather than xri://$xrd*($v*2.0))
500 // with an unusual case.
501 if ((strtolower($ns) == strtolower(XRD2_NS)) && ($name == 'Service')) {
502 $this->in_service = TRUE;
503 $this->service = array();
504
505 if (in_array('priority', $attribs)) {
506 $this->service['#priority'] = $attribs['priority'];
507 }
508 if (in_array('id', $attribs)) {
509 $this->service['#id'] = $attribs['id'];
510 }
511 }
512
513 if ((strtolower($ns) == strtolower(XRD2_NS)) && ($this->in_service)) {
514 switch ($name) {
515 case 'Type':
516 case 'LocalID':
517 case 'URI':
518 if (in_array('priority', $attribs)) {
519 $this->priority = $attribs['priority'];
520 } else {
521 $this->priority = NULL;
522 }
523 }
524 }
525
526 $this->_buffer = '';
527 $this->_attribs = $attribs;
528 }
529
530 /**
531 * XML parser callback
532 *
533 * @access private
534 */
535 function element_end(&$parser, $qualified) {
536 list($ns, $name) = $this->parse_namespace($qualified);
537
538 if ((strtolower($ns) == strtolower(XRD2_NS)) && ($this->in_service)) {
539 switch ($name) {
540 case 'Service':
541 foreach (array('type', 'localid', 'uri') as $key) {
542 if (!isset($this->service[$key])) continue;
543 $this->service[$key] = $this->flatten_uris($this->service[$key]);
544 }
545
546 $this->services[] = $this->service;
547 $this->in_service = FALSE;
548 break;
549
550 case 'Type':
551 case 'LocalID':
552 case 'URI':
553 $key = strtolower($name);
554 if (!isset($this->service[$key])) {
555 $this->service[$key] = array();
556 }
557 if ($this->priority != NULL) {
558 $this->service[$key][] = array('#uri' => trim($this->_buffer), '#priority' => $this->priority);
559 } else {
560 $this->service[$key][] = array('#uri' => trim($this->_buffer));
561 }
562 $this->priority = NULL;
563 break;
564 }
565 }
566
567 if ((strtolower($ns) == strtolower(XRD_OPENID_NS)) && ($this->in_service)) {
568 switch ($name) {
569 case 'Delegate':
570 $this->service['delegate'] = trim($this->_buffer);
571 }
572 }
573
574 $this->_attribs = array();
575 }
576
577 /**
578 * XML parser callback
579 *
580 * @access private
581 */
582 function cdata(&$parser, $data) {
583 $this->_buffer .= $data;
584 }
585
586 /**
587 * Parses a namespace-qualified element name.
588 *
589 * @param string $qualified the qualified name
590 * @return array an array with two elements - the first element contains
591 * the namespace qualifier (or an empty string), the second element contains
592 * the element name
593 * @access protected
594 */
595 function parse_namespace($qualified) {
596 $pos = strrpos($qualified, ':');
597 if ($pos !== FALSE) return array(substr($qualified, 0, $pos), substr($qualified, $pos + 1, strlen($qualified)));
598 return array('', $qualified);
599 }
600
601 /**
602 * Flattens the service array.
603 *
604 * In an XRDS document, child elements of the service element often contains
605 * a list of URIs, with the priority specified in the priority attribute.
606 *
607 * When the document is parsed in this class, the URI and the priority are first
608 * extracted into the #uri and the #priority keys respectively. This function
609 * takes this array, sorts the elements using the #priority keys (if $sort is
610 * true), then collapses the array using the value associated with the #uri key.
611 *
612 * @param array $array the service array, with URIs and priorities
613 * @param bool $sort whether to sort the service array using the #priority
614 * keys
615 * @return array the services array with URIs sorted by priority
616 * @access protected
617 */
618 function flatten_uris($array, $sort = TRUE) {
619 $result = array();
620
621 if ($sort) uasort($array, 'discovery_xrds_priority_sort');
622
623 for ($i = 0; $i < count($array); $i++) {
624 $result[] = $array[$i]['#uri'];
625 }
626
627 return $result;
628 }
629}
630
631if (!function_exists('rfc3986_urlencode')) {
632 /**
633 * Encodes a URL using RFC 3986.
634 *
635 * PHP's rawurlencode function encodes a URL using RFC 1738. RFC 1738 has been
636 * updated by RFC 3986, which change the list of characters which needs to be
637 * encoded.
638 *
639 * Strictly correct encoding is required for various purposes, such as OAuth
640 * signature base strings.
641 *
642 * @param string $s the URL to encode
643 * @return string the encoded URL
644 */
645 function rfc3986_urlencode($s) {
646 return str_replace('%7E', '~', rawurlencode($s));
647 }
648}
649?>