blob: 17939f85fcd00e628a9f4e039dce9a85024d5ffc [file] [log] [blame]
<?php
/*
* SimpleID
*
* Copyright (C) Kelvin Mo 2007-9
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public
* License as published by the Free Software Foundation; either
* version 2 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public
* License along with this program; if not, write to the Free
* Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
*
* $Id$
*/
/**
* Support for XRDS based discovery.
*
* The functions for this file supports HTTP-based identifiers. For XRIs, the
* resolution service xri.net is used to resolve to HTTP-based URLs.
*
* @package simpleid
* @since 0.7
* @filesource
*/
include_once "http.inc.php";
/**
* The namespace identifier for an XRDS document.
*/
define('XRDS_NS', 'xri://$xrds');
/**
* The namespace identifier for XRDS version 2.
*/
define('XRD2_NS', 'xri://$xrd*($v*2.0)');
/**
* The namespace identifier for XRDS Simple.
*/
define('XRDS_SIMPLE_NS', 'http://xrds-simple.net/core/1.0');
/**
* The type identifier for XRDS Simple.
*/
define('XRDS_SIMPLE_TYPE', 'xri://$xrds*simple');
/**
* The namespace identifier for OpenID services.
*/
define('XRD_OPENID_NS', 'http://openid.net/xmlns/1.0');
/**
* Obtains the services for particular identifier.
*
* This function attempts to discover and obtain the XRDS document associated
* with the identifier, parses the XRDS document and returns an array of
* services.
*
* If an XRDS document is not found, and $openid is set to true, this function
* will also attempt to discover OpenID services by looking for link elements
* with rel of openid.server or openid2.provider in the discovered HTML document.
*
* @param string $identifier the identifier
* @param bool $openid if true, performs additional discovery of OpenID services
* by looking for link elements within the discovered document
* @return array an array of discovered services, or an empty array if no services
* are found
*/
function discovery_xrds_discover($identifier, $openid = FALSE) {
$identifier = discovery_xrds_normalize($identifier);
$url = discovery_xrds_url($identifier);
$xrds = discovery_xrds_get($url);
if ($xrds) {
return discovery_xrds_parse($xrds);
} else {
if ($openid) return discovery_html_get_services($url);
return array();
}
}
/**
* Given an array of discovered services, obtains information on services of
* a particular type.
*
* @param array $services the discovered services
* @param string $type the URI of the type of service to obtain
* @return array an array of matching services, or an empty array of no services
* match
*/
function discovery_xrds_services_by_type($services, $type) {
$matches = array();
foreach ($services as $service) {
foreach ($service['type'] as $service_type) {
if ($service_type == $type) $matches[] = $service;
}
}
return $matches;
}
/**
* Given an array of discovered services, obtains information on the service of
* a specified ID.
*
* @param array $services the discovered services
* @param string $id the XML ID of the service in the XRDS document
* @return array the matching service, or NULL of no services
* are found
*/
function discovery_xrds_service_by_id($services, $id) {
foreach ($services as $service) {
if ($service['#id'] == $id) return $service;
}
return NULL;
}
/**
* Obtains a XRDS document at a particular URL. Performs Yadis discovery if
* the URL does not produce a XRDS document.
*
* @param string $url the URL
* @param bool $check whether to check the content type of the response is
* application/xrds+xml
* @param int $retries the number of tries to make
* @return string the contents of the XRDS document
*/
function discovery_xrds_get($url, $check = TRUE, $retries = 5) {
if ($retries == 0) return NULL;
$response = http_make_request($url, array('Accept' => 'application/xrds+xml'));
if (isset($response['http-error'])) return NULL;
if (($response['content-type'] == 'application/xrds+xml') || ($check == FALSE)) {
return $response['data'];
} elseif (isset($response['headers']['x-xrds-location'])) {
return discovery_xrds_get($response['headers']['x-xrds-location'], false, $retries - 1);
} elseif (isset($response['data'])) {
$location = _discovery_meta_httpequiv('X-XRDS-Location', $response['data']);
if ($location) {
return discovery_xrds_get($location, false, $retries - 1);
}
return NULL;
} else {
return NULL;
}
}
/**
* Normalises an identifier for discovery.
*
* If the identifier begins with xri://, acct: or mailto:, this is stripped out. If the identifier
* does not begin with a valid URI scheme, http:// is assumed and added to the
* identifier.
*
* @param string $identifier the identifier to normalise
* @return string the normalised identifier
*/
function discovery_xrds_normalize($identifier) {
$normalized = $identifier;
if (discovery_is_xri($identifier)) {
if (stristr($identifier, 'xri://') !== false) $normalized = substr($identifier, 6);
} elseif (discovery_is_email($identifier)) {
if (stristr($identifier, 'acct:') !== false) $normalized = substr($identifier, 5);
if (stristr($identifier, 'mailto:') !== false) $normalized = substr($identifier, 7);
} else {
if (stristr($identifier, '://') === false) $normalized = 'http://'. $identifier;
if (substr_count($normalized, '/') < 3) $normalized .= '/';
}
return $normalized;
}
/**
* Obtains a URL for an identifier. If the identifier is a XRI, the XRI resolution
* service is used to convert the identifier to a URL.
*
* @param string $identifier the identifier
* @return string the URL
*/
function discovery_xrds_url($identifier) {
if (discovery_is_xri($identifier)) {
return 'http://xri.net/' . $identifier;
} elseif (discovery_is_email($identifier)) {
//list($user, $host) = explode('@', $identifier, 2);
//$host_meta = 'http://' . $host . '/.well-known/host-meta';
} else {
return $identifier;
}
}
/**
* Determines whether an identifier is an XRI.
*
* XRI identifiers either start with xri:// or with @, =, +, $ or !.
*
* @param string $identifier the parameter to test
* @return bool true if the identifier is an XRI
*/
function discovery_is_xri($identifier) {
$firstchar = substr($identifier, 0, 1);
if ($firstchar == "@" || $firstchar == "=" || $firstchar == "+" || $firstchar == "\$" || $firstchar == "!") return true;
if (stristr($identifier, 'xri://') !== FALSE) return true;
return false;
}
/**
* Determines whether an identifier is an e-mail address.
*
* An identifier is an e-mail address if it:
*
* - has a single @ character
* - does not have a slash character
*
* @param string $identifier the parameter to test
* @return bool true if the identifier is an e-mail address
*/
function discovery_is_email($identifier) {
// If it begins with acct: or mailto:, strip it out
if (stristr($identifier, 'acct:') !== false) $identifier = substr($identifier, 5);
if (stristr($identifier, 'mailto:') !== false) $identifier = substr($identifier, 7);
// If it contains a slash, it is not an e-mail address
if (strpos($identifier, "/") !== false) return false;
$at = strpos($identifier, "@");
// If it does not contain a @, it is not an e-mail address
if ($at === false) return false;
// If it contains more than one @, it is not an e-mail
if (strrpos($identifier, "@") != $at) return false;
return true;
}
/**
* Callback function to sort service and URI elements based on priorities
* specified in the XRDS document.
*
* The XRDS specification allows multiple instances of certain elements, such
* as Service and URI. The specification allows an attribute called priority
* so that the document creator can specify the order the elements should be used.
*
* @param array $a
* @param array $b
* @return int
*/
function discovery_xrds_priority_sort($a, $b) {
if (!isset($a['#priority']) && !isset($b['#priority'])) return 0;
// if #priority is missing, #priority is assumed to be infinity
if (!isset($a['#priority'])) return 1;
if (!isset($b['#priority'])) return -1;
if ($a['#priority'] == $b['#priority']) return 0;
return ($a['#priority'] < $b['#priority']) ? -1 : 1;
}
/**
* Parses an XRDS document to return services available.
*
* @param string $xrds the XRDS document
* @return array the parsed structure
*
* @see XRDSParser
*/
function discovery_xrds_parse($xrds) {
$parser = new XRDSParser();
$parser->parse($xrds);
$parser->free();
$services = $parser->services();
uasort($services, 'discovery_xrds_priority_sort');
return $services;
}
/**
* Obtains the OpenID services for particular identifier by scanning for link
* elements in the returned document.
*
* Note that this function does not use the YADIS protocol to scan for services.
* To use the YADIS protocol, use {@link discovery_get_services()}.
*
* @param string $url the URL
* @return array an array of discovered services, or an empty array if no services
* are found
*/
function discovery_html_get_services($url) {
$services = array();
$response = http_make_request($url);
$html = $response['data'];
$uri = _discovery_link_rel('openid2.provider', $html);
$delegate = _discovery_link_rel('openid2.local_id', $html);
if ($uri) {
$service = array(
'type' => 'http://specs.openid.net/auth/2.0/signon',
'uri' => $uri
);
if ($delegate) $service['localid'] = $delegate;
$services[] = $service;
}
$uri = _discovery_link_rel('openid.server', $html);
$delegate = _discovery_link_rel('openid.delegate', $html);
if ($uri) {
$service = array(
'type' => 'http://openid.net/signon/1.0',
'uri' => $uri
);
if ($delegate) $service['localid'] = $delegate;
$services[] = $service;
}
return $services;
}
/**
* Searches through an HTML document to obtain the value of a meta
* element with a specified http-equiv attribute.
*
* @param string $equiv the http-equiv attribute for which to search
* @param string $html the HTML document to search
* @return mixed the value of the meta element, or FALSE if the element is not
* found
*/
function _discovery_meta_httpequiv($equiv, $html) {
$html = preg_replace('/<!(?:--(?:[^-]*|-[^-]+)*--\s*)>/', '', $html); // Strip html comments
$equiv = preg_quote($equiv);
preg_match('|<meta\s+http-equiv=["\']'. $equiv .'["\'](.*)/?>|iUs', $html, $matches);
if (isset($matches[1])) {
preg_match('|content=["\']([^"]+)["\']|iUs', $matches[1], $content);
if (isset($content[1])) {
return $content[1];
}
}
return FALSE;
}
/**
* Searches through an HTML document to obtain the value of a link
* element with a specified rel attribute.
*
* @param string $rel the rel attribute for which to search
* @param string $html the HTML document to search
* @return mixed the href of the link element, or FALSE if the element is not
* found
*/
function _discovery_link_rel($rel, $html) {
$html = preg_replace('/<!(?:--(?:[^-]*|-[^-]+)*--\s*)>/s', '', $html); // Strip html comments
$rel = preg_quote($rel);
preg_match('|<link\s+rel=["\'](.*)'. $rel .'(.*)["\'](.*)/?>|iUs', $html, $matches);
if (isset($matches[3])) {
preg_match('|href=["\']([^"]+)["\']|iU', $matches[3], $href);
return trim($href[1]);
}
return FALSE;
}
/**
* A simple XRDS parser.
*
* This parser uses the classic expat functions available in PHP to parse the
* XRDS Simple XML document.
*
* The result is an array of discovered services.
*
* @link http://xrds-simple.net/
*/
class XRDSParser {
/**
* XML parser
* @var resource
* @access private
*/
var $parser;
/**
* Discovered services
* @var array
* @access private
*/
var $services = array();
/**
* State: are we parsing a service element?
* @var bool
* @access private
*/
var $in_service = FALSE;
/**
* CDATA buffer
* @var string
* @access private
*/
var $_buffer;
/**
* Attributes buffer
* @var array
* @access private
*/
var $_attribs = array();
/**
* priority attribute buffer
* @var string
* @access private
*/
var $priority = NULL;
/**
* Currently parsed service buffer
* @var array
* @access private
*/
var $service = array();
/**
* Creates an instance of the XRDS parser.
*
* This constructor also initialises the underlying XML parser.
*/
function __construct() {
$this->parser = xml_parser_create_ns();
xml_parser_set_option($this->parser, XML_OPTION_CASE_FOLDING,0);
xml_set_object($this->parser, $this);
xml_set_element_handler($this->parser, 'element_start', 'element_end');
xml_set_character_data_handler($this->parser, 'cdata');
}
/**
* Frees memory associated with the underlying XML parser.
*
* Note that only the memory associated with the underlying XML parser is
* freed. Memory associated with the class itself is not freed.
*
* @access public
*/
function free() {
xml_parser_free($this->parser);
}
/**
* Parses an XRDS document.
*
* Once the parsing is complete, use {@link XRDSParser::services()} to obtain
* the services extracted from the document.
*
* @param string $xml the XML document to parse
* @access public
*/
function parse($xml) {
xml_parse($this->parser, $xml);
}
/**
* Gets an array of discovered services.
*
* @return array an array of discovered services, or an empty array
* @access public
* @see XRDSParser::parse()
*/
function services() {
return $this->services;
}
/**
* XML parser callback
*
* @access private
*/
function element_start(&$parser, $qualified, $attribs) {
list($ns, $name) = $this->parse_namespace($qualified);
// Strictly speaking, XML namespace URIs are semi-case sensitive
// (i.e. the scheme and host are not case sensitive, but other elements
// are). However, the XRDS-Simple specifications defines a
// namespace URI for XRD (xri://$XRD*($v*2.0) rather than xri://$xrd*($v*2.0))
// with an unusual case.
if ((strtolower($ns) == strtolower(XRD2_NS)) && ($name == 'Service')) {
$this->in_service = TRUE;
$this->service = array();
if (in_array('priority', $attribs)) {
$this->service['#priority'] = $attribs['priority'];
}
if (in_array('id', $attribs)) {
$this->service['#id'] = $attribs['id'];
}
}
if ((strtolower($ns) == strtolower(XRD2_NS)) && ($this->in_service)) {
switch ($name) {
case 'Type':
case 'LocalID':
case 'URI':
if (in_array('priority', $attribs)) {
$this->priority = $attribs['priority'];
} else {
$this->priority = NULL;
}
}
}
$this->_buffer = '';
$this->_attribs = $attribs;
}
/**
* XML parser callback
*
* @access private
*/
function element_end(&$parser, $qualified) {
list($ns, $name) = $this->parse_namespace($qualified);
if ((strtolower($ns) == strtolower(XRD2_NS)) && ($this->in_service)) {
switch ($name) {
case 'Service':
foreach (array('type', 'localid', 'uri') as $key) {
if (!isset($this->service[$key])) continue;
$this->service[$key] = $this->flatten_uris($this->service[$key]);
}
$this->services[] = $this->service;
$this->in_service = FALSE;
break;
case 'Type':
case 'LocalID':
case 'URI':
$key = strtolower($name);
if (!isset($this->service[$key])) {
$this->service[$key] = array();
}
if ($this->priority != NULL) {
$this->service[$key][] = array('#uri' => trim($this->_buffer), '#priority' => $this->priority);
} else {
$this->service[$key][] = array('#uri' => trim($this->_buffer));
}
$this->priority = NULL;
break;
}
}
if ((strtolower($ns) == strtolower(XRD_OPENID_NS)) && ($this->in_service)) {
switch ($name) {
case 'Delegate':
$this->service['delegate'] = trim($this->_buffer);
}
}
$this->_attribs = array();
}
/**
* XML parser callback
*
* @access private
*/
function cdata(&$parser, $data) {
$this->_buffer .= $data;
}
/**
* Parses a namespace-qualified element name.
*
* @param string $qualified the qualified name
* @return array an array with two elements - the first element contains
* the namespace qualifier (or an empty string), the second element contains
* the element name
* @access protected
*/
function parse_namespace($qualified) {
$pos = strrpos($qualified, ':');
if ($pos !== FALSE) return array(substr($qualified, 0, $pos), substr($qualified, $pos + 1, strlen($qualified)));
return array('', $qualified);
}
/**
* Flattens the service array.
*
* In an XRDS document, child elements of the service element often contains
* a list of URIs, with the priority specified in the priority attribute.
*
* When the document is parsed in this class, the URI and the priority are first
* extracted into the #uri and the #priority keys respectively. This function
* takes this array, sorts the elements using the #priority keys (if $sort is
* true), then collapses the array using the value associated with the #uri key.
*
* @param array $array the service array, with URIs and priorities
* @param bool $sort whether to sort the service array using the #priority
* keys
* @return array the services array with URIs sorted by priority
* @access protected
*/
function flatten_uris($array, $sort = TRUE) {
$result = array();
if ($sort) uasort($array, 'discovery_xrds_priority_sort');
for ($i = 0; $i < count($array); $i++) {
$result[] = $array[$i]['#uri'];
}
return $result;
}
}
if (!function_exists('rfc3986_urlencode')) {
/**
* Encodes a URL using RFC 3986.
*
* PHP's rawurlencode function encodes a URL using RFC 1738. RFC 1738 has been
* updated by RFC 3986, which change the list of characters which needs to be
* encoded.
*
* Strictly correct encoding is required for various purposes, such as OAuth
* signature base strings.
*
* @param string $s the URL to encode
* @return string the encoded URL
*/
function rfc3986_urlencode($s) {
return str_replace('%7E', '~', rawurlencode($s));
}
}
?>